Skip to content

Commit cdfbad6

Browse files
authored
refactor(ios): brand the simctl builders and require the brand where runXcrun runs simctl (#2917)
* refactor(platform-apple): brand the simctl command that runXcrun runs buildSimctlArgsForAddress and buildSimctlArgsForDevice now return a ScopedSimctlCommand, minted in core/tool-provider.ts from set-scoped arguments. runXcrun takes that command or an argv whose tool is a named non-simctl tool (devicectl, xcdevice, xctrace), so a literal, const-held, string-typed or copied simctl argv is a type error. The simctl branch unwraps the command to its scoped arguments; the ScopedSimctlArgs double cast is gone. The Apple tool host hands a simctl request's scoped args straight to the simctl provider. The set-scope primitive is private to core/simctl.ts; doctor and inventory use the named mints simctlAvailabilityProbeArgs and simctlListDevicesArgs. Every argv stays byte-identical. Refs #2878 * chore(gates): narrow R79 to the simctl bypasses tsc cannot reject Drop the '--set' literal ban, the global ArrayExpression clause, the set-scope identifier check and the tool-provider argv exemption. R79 now checks the argv at an xcrun invocation (a call with an 'xcrun' argument or an executable: 'xcrun' spec), which must name its tool as a literal other than simctl, and refuses casts to the scope brands outside the two modules that mint them. Refs #2878 * chore(gates): refuse a simctl argv array outside the brand mints however it reaches an executor runCmd, runCmdBackground and an executable: 'xcrun' spec take any string argv, so the runXcrun brand does not reach them. R79 again flags every array whose first element names simctl (literal, quasi-only template, or a same-file binding of either) outside core/simctl.ts and core/tool-provider.ts, so a variable-held, aliased or spread simctl argv fails the gate as the inline form does. * refactor(platform-apple): run every simctl executor through one argv form * chore(gates): refuse a simctl tool named after leading xcrun options
1 parent 5dee3be commit cdfbad6

25 files changed

Lines changed: 428 additions & 260 deletions

‎packages/platform-apple/src/core/__tests__/simctl.test.ts‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import {
55
buildSimctlArgsForDevice,
66
readSimctlDevicesByRuntime,
77
readSimctlDeviceState,
8-
scopeSimctlArgs,
98
scopeSimctlArgsForDevice,
9+
simctlAvailabilityProbeArgs,
10+
simctlListDevicesArgs,
1011
simulatorAddressFor,
1112
type SimulatorAddress,
1213
} from '../simctl.ts';
@@ -59,19 +60,20 @@ test('buildSimctlArgsForDevice leaves non-simulator commands unchanged', () => {
5960
assert.deepEqual(args, ['simctl', 'bootstatus', 'sim-1', '-b']);
6061
});
6162

62-
test('scopeSimctlArgs prefixes a trimmed simulator set and omits a blank one', () => {
63-
assert.deepEqual(scopeSimctlArgs(['list', 'devices', '-j'], { simulatorSetPath: ' /tmp/set ' }), [
63+
test('simctlListDevicesArgs prefixes a trimmed simulator set and omits a blank one', () => {
64+
assert.deepEqual(simctlListDevicesArgs(' /tmp/set '), [
6465
'--set',
6566
'/tmp/set',
6667
'list',
6768
'devices',
6869
'-j',
6970
]);
70-
assert.deepEqual(scopeSimctlArgs(['list', 'devices', '-j'], { simulatorSetPath: ' ' }), [
71-
'list',
72-
'devices',
73-
'-j',
74-
]);
71+
assert.deepEqual(simctlListDevicesArgs(' '), ['list', 'devices', '-j']);
72+
assert.deepEqual(simctlListDevicesArgs(undefined), ['list', 'devices', '-j']);
73+
});
74+
75+
test('simctlAvailabilityProbeArgs names no set', () => {
76+
assert.deepEqual(simctlAvailabilityProbeArgs(), ['help']);
7577
});
7678

7779
test('scopeSimctlArgsForDevice scopes simulators only', () => {
@@ -105,11 +107,13 @@ test('simulatorAddressFor carries the set of iOS-family simulators only', () =>
105107
});
106108

107109
function compileTimeSimulatorScopeProof(): void {
108-
// @ts-expect-error A set-scope call states its set; leaving it out does not mean the default set.
109-
void scopeSimctlArgs(['list']);
110110
// @ts-expect-error A simulator address is minted from its DeviceInfo, never written by hand.
111111
const forged: SimulatorAddress = { udid: 'sim-1', simulatorSetPath: undefined };
112112
void forged;
113+
// @ts-expect-error Set scope is private; a call that names no device goes through a named mint.
114+
type SetScope = (typeof import('../simctl.ts'))['scopeSimctlArgs'];
115+
const setScope: SetScope | undefined = undefined;
116+
void setScope;
113117
}
114118
void compileTimeSimulatorScopeProof;
115119

‎packages/platform-apple/src/core/__tests__/simulator-booted-memo.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
} from '../simulator.ts';
1010
import { runXcrun } from '../tool-provider.ts';
1111

12-
vi.mock('../tool-provider.ts', () => ({
12+
vi.mock('../tool-provider.ts', async (importOriginal) => ({
13+
...(await importOriginal<typeof import('../tool-provider.ts')>()),
1314
runAppleToolCommand: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
1415
runXcrun: vi.fn(),
1516
}));

‎packages/platform-apple/src/core/__tests__/tool-provider.test.ts‎

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,49 @@ import {
66
resolveAppleToolProvider,
77
runAppleToolCommand,
88
runXcrun,
9+
simctlCommand,
910
withAppleToolProvider,
11+
type ScopedSimctlCommand,
1012
} from '../tool-provider.ts';
11-
12-
function compileTimeScopedSimctlProof(): void {
13+
import { buildSimctlArgsForDevice, scopeSimctlArgsForDevice } from '../simctl.ts';
14+
import type { AppleToolRequest } from '@agent-device/contracts/platform-runtime-host';
15+
import type { DeviceInfo } from '@agent-device/kernel/device';
16+
17+
const IOS_SIMULATOR: DeviceInfo = {
18+
platform: 'apple',
19+
id: 'sim-1',
20+
name: 'iPhone 17',
21+
kind: 'simulator',
22+
target: 'mobile',
23+
};
24+
const LAUNCH_COMMAND = buildSimctlArgsForDevice(IOS_SIMULATOR, [
25+
'launch',
26+
'sim-1',
27+
'com.example.app',
28+
]);
29+
30+
function compileTimeScopedSimctlProof(request: AppleToolRequest, argv: string[]): void {
1331
// @ts-expect-error Raw simctl argv cannot reach the provider; scope it in core/simctl.ts.
1432
void resolveAppleToolProvider().simctl.run(['spawn', 'sim-1', 'bridge']);
33+
// @ts-expect-error A literal simctl argv is not a ScopedSimctlCommand.
34+
void runXcrun(['simctl', 'spawn', 'sim-1', 'bridge']);
35+
const tool = 'simctl';
36+
// @ts-expect-error A tool name held in a const keeps its literal type, so it is refused too.
37+
void runXcrun([tool, 'spawn', 'sim-1', 'bridge']);
38+
const widenedTool: string = 'simctl';
39+
// @ts-expect-error runXcrun names every other tool it runs, so a string tool name is refused.
40+
void runXcrun([widenedTool, 'spawn', 'sim-1', 'bridge']);
41+
// @ts-expect-error An argv typed string[] may start with simctl, so it is refused.
42+
void runXcrun(argv);
43+
// @ts-expect-error Copying a scoped command drops its brand.
44+
void runXcrun([...LAUNCH_COMMAND]);
45+
// @ts-expect-error A request whose tool may be simctl goes to the simctl provider with its scoped args.
46+
void runXcrun([request.tool, ...request.args]);
47+
// @ts-expect-error A hand-built simctl argv is not a ScopedSimctlCommand.
48+
const handBuilt: ScopedSimctlCommand = ['simctl', 'boot', 'sim-1'];
49+
void handBuilt;
50+
// @ts-expect-error simctlCommand takes set-scoped arguments, never a raw argv.
51+
void simctlCommand(['boot', 'sim-1']);
1552
}
1653
void compileTimeScopedSimctlProof;
1754

@@ -24,10 +61,7 @@ test('scoped Apple tool provider handles xcrun execution', async () => {
2461
},
2562
});
2663

27-
const result = await withAppleToolProvider(
28-
provider,
29-
async () => await runXcrun(['simctl', 'launch', 'sim-1', 'com.example.app']),
30-
);
64+
const result = await withAppleToolProvider(provider, async () => await runXcrun(LAUNCH_COMMAND));
3165

3266
assert.equal(result.stdout, 'ok');
3367
assert.deepEqual(calls, [['xcrun', ['simctl', 'launch', 'sim-1', 'com.example.app']]]);
@@ -56,7 +90,7 @@ test('scoped Apple tool provider prefers semantic simctl and devicectl hooks', a
5690

5791
const simctlResult = await withAppleToolProvider(
5892
provider,
59-
async () => await runXcrun(['simctl', 'launch', 'sim-1', 'com.example.app']),
93+
async () => await runXcrun(LAUNCH_COMMAND),
6094
);
6195
const devicectlResult = await withAppleToolProvider(
6296
provider,
@@ -71,6 +105,38 @@ test('scoped Apple tool provider prefers semantic simctl and devicectl hooks', a
71105
]);
72106
});
73107

108+
test('simctlCommand prefixes set-scoped arguments with the tool name and freezes the argv', () => {
109+
const command = simctlCommand(
110+
scopeSimctlArgsForDevice({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant/simulator-set' }, [
111+
'boot',
112+
'sim-1',
113+
]),
114+
);
115+
116+
assert.deepEqual(command, ['simctl', '--set', '/tmp/tenant/simulator-set', 'boot', 'sim-1']);
117+
assert.ok(Object.isFrozen(command));
118+
});
119+
120+
test('runXcrun hands a scoped simctl command to the simctl provider with its set intact', async () => {
121+
const received: Array<readonly string[]> = [];
122+
const provider = createLocalAppleToolProvider({
123+
simctl: {
124+
run: async (args) => {
125+
received.push(args);
126+
return { exitCode: 0, stdout: '', stderr: '' };
127+
},
128+
},
129+
});
130+
const command = buildSimctlArgsForDevice(
131+
{ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant/simulator-set' },
132+
['boot', 'sim-1'],
133+
);
134+
135+
await withAppleToolProvider(provider, async () => await runXcrun(command));
136+
137+
assert.deepEqual(received, [['--set', '/tmp/tenant/simulator-set', 'boot', 'sim-1']]);
138+
});
139+
74140
test('scoped Apple tool provider exposes plist JSON reads as semantic operation', async () => {
75141
const provider = createLocalAppleToolProvider({
76142
runCommand: async () => {

‎packages/platform-apple/src/core/app-launch.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
launchFailureHint,
2828
} from './launch-diagnostics.ts';
2929
import { ensureBootedSimulator } from './simulator.ts';
30-
import { runXcrun } from './tool-provider.ts';
30+
import { runXcrun, type ScopedSimctlCommand } from './tool-provider.ts';
3131
import { closeMacOsApp, openMacOsApp } from '../os/macos/apps.ts';
3232
import { resolveIosApp } from './app-resolution.ts';
3333
import { buildSimctlArgsForDevice, runSimctlForDevice } from './simctl.ts';
@@ -301,7 +301,7 @@ function buildIosSimulatorLaunchArgs(
301301
}
302302

303303
async function runIosSimulatorConsoleLaunch(
304-
launchArgs: string[],
304+
launchArgs: ScopedSimctlCommand,
305305
logPath: string,
306306
): Promise<Awaited<ReturnType<typeof runXcrun>>> {
307307
await ensureHostDirectory(path.dirname(logPath));

‎packages/platform-apple/src/core/devicectl.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export async function runIosDevicectl(
6060
tolerateOutput?: (stdout: string, stderr: string) => boolean;
6161
} = {},
6262
): Promise<void> {
63-
const fullArgs = ['devicectl', ...args];
63+
const fullArgs: ['devicectl', ...string[]] = ['devicectl', ...args];
6464
const result = await runXcrun(fullArgs, {
6565
allowFailure: true,
6666
signal: options.signal,
@@ -202,7 +202,7 @@ export type IosDevicectlJsonOutcome =
202202
*/
203203
export async function runIosDevicectlJsonRequest(options: {
204204
jsonPrefix: string;
205-
args: string[];
205+
args: ['devicectl', ...string[]];
206206
timeoutMs?: number;
207207
signal?: AbortSignal;
208208
tolerateFailurePayload?: (payload: unknown) => boolean;
@@ -211,7 +211,7 @@ export async function runIosDevicectlJsonRequest(options: {
211211
hostTemporaryDirectory(),
212212
`${options.jsonPrefix}-${hostProcessId()}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
213213
);
214-
const args = [...options.args, '--json-output', jsonPath];
214+
const args: ['devicectl', ...string[]] = [...options.args, '--json-output', jsonPath];
215215
try {
216216
const result = await runXcrun(args, {
217217
allowFailure: true,
@@ -248,7 +248,7 @@ async function runIosDevicectlJsonCommand(
248248
device: DeviceInfo,
249249
options: {
250250
jsonPrefix: string;
251-
args: string[];
251+
args: ['devicectl', ...string[]];
252252
failureMessage: string;
253253
parseFailureMessage: string;
254254
fallbackHint?: string;

‎packages/platform-apple/src/core/hinge-angle.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export async function readAppleHingeAngle(
2828
device: DeviceInfo,
2929
options: { signal?: AbortSignal } = {},
3030
): Promise<number> {
31-
const args = [
31+
const args: ['devicectl', ...string[]] = [
3232
'devicectl',
3333
'device',
3434
'motion',

‎packages/platform-apple/src/core/perf-target.ts‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { IosDeviceProcessInfo } from './app-info.ts';
88
import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts';
99
import { readInfoPlistString } from './plist.ts';
1010
import { buildSimctlArgsForDevice } from './simctl.ts';
11-
import { runAppleToolCommand, runXcrun } from './tool-provider.ts';
11+
import { runAppleToolCommand, runXcrun, type ScopedSimctlCommand } from './tool-provider.ts';
1212

1313
const APPLE_PERF_TIMEOUT_MS = 15_000;
1414

@@ -152,25 +152,26 @@ export async function readAppleProcessSamples(
152152
device: DeviceInfo,
153153
executable: { executableName: string; executablePath?: string },
154154
): Promise<AppleProcessSample[]> {
155-
const args = isMacOs(device)
156-
? ['-axo', 'pid=,%cpu=,rss=,command=']
157-
: buildSimctlArgsForDevice(device, [
158-
'spawn',
159-
device.id,
160-
'ps',
161-
'-axo',
162-
'pid=,%cpu=,rss=,command=',
163-
]);
164155
const result = isMacOs(device)
165-
? await runAppleToolCommand('ps', args, { timeoutMs: APPLE_PERF_TIMEOUT_MS })
166-
: await runAppleSimulatorProcessCommand(args);
156+
? await runAppleToolCommand('ps', ['-axo', 'pid=,%cpu=,rss=,command='], {
157+
timeoutMs: APPLE_PERF_TIMEOUT_MS,
158+
})
159+
: await runAppleSimulatorProcessCommand(
160+
buildSimctlArgsForDevice(device, [
161+
'spawn',
162+
device.id,
163+
'ps',
164+
'-axo',
165+
'pid=,%cpu=,rss=,command=',
166+
]),
167+
);
167168
const { matchesAppleExecutableProcess } = await import('./perf-process-identity.ts');
168169
return parseApplePsOutput(result.stdout).filter((processInfo) =>
169170
matchesAppleExecutableProcess(processInfo.command, executable),
170171
);
171172
}
172173

173-
async function runAppleSimulatorProcessCommand(args: string[]): Promise<ExecResult> {
174+
async function runAppleSimulatorProcessCommand(args: ScopedSimctlCommand): Promise<ExecResult> {
174175
const result = await runXcrun(args, {
175176
allowFailure: true,
176177
timeoutMs: APPLE_PERF_TIMEOUT_MS,

‎packages/platform-apple/src/core/perf-xctrace.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ export async function exportAppleXctraceData(params: {
306306
failureMessage: string;
307307
failureDetails: Record<string, unknown>;
308308
}): Promise<string> {
309-
const exportArgs = [
309+
const exportArgs: ['xctrace', ...string[]] = [
310310
'xctrace',
311311
'export',
312312
'--input',
@@ -376,7 +376,7 @@ function buildAppleXctraceRecordArgs(params: {
376376
target: AppleXctraceRecordTarget;
377377
timeLimit?: string;
378378
outPath: string;
379-
}): string[] {
379+
}): ['xctrace', ...string[]] {
380380
return [
381381
'xctrace',
382382
'record',

‎packages/platform-apple/src/core/physical-device-control.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,13 @@ async function rejectXctestRunnerFileCopy(device: DeviceInfo): Promise<never> {
159159

160160
async function ensureXctestDeviceReady(device: DeviceInfo, signal?: AbortSignal): Promise<void> {
161161
const timeoutSeconds = Math.max(1, Math.ceil(IOS_DEVICE_READY_TIMEOUT_MS / 1000));
162-
const args = ['xcdevice', 'wait', '--both', `--timeout=${timeoutSeconds}`, device.id];
162+
const args: ['xcdevice', ...string[]] = [
163+
'xcdevice',
164+
'wait',
165+
'--both',
166+
`--timeout=${timeoutSeconds}`,
167+
device.id,
168+
];
163169
const result = await runXcrun(args, {
164170
allowFailure: true,
165171
signal,

‎packages/platform-apple/src/core/settings-parsing.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function parseSettingState(state: string): boolean {
2121

2222
// fallow-ignore-next-line code-duplication
2323
export type CommandAttemptFailure = {
24-
args: string[];
24+
args: readonly string[];
2525
stdout: string;
2626
stderr: string;
2727
exitCode: number;

0 commit comments

Comments
 (0)