Skip to content

Commit a363a02

Browse files
authored
refactor(ios): make unscoped simctl argv a type error at the Apple tool port (#2898)
* refactor(platform-apple): take simctl set scope from the device record Brand scoped simctl argv in contracts (ScopedSimctlArgs) so the Apple tool port and the provider simctl executor refuse raw argv. Mint a SimulatorAddress from a DeviceInfo, make the set-scope builder take an explicit scope, and delete buildSimctlArgs. Move the log-stream and AX bridge spawns off the set-scope builder: the log stream takes the set from its DeviceInfo, and the snapshot target carries a SimulatorAddress in place of udid and simulatorSetPath. Doctor keeps set scope for 'simctl help'. No argv changes. Refs #2878 * test(platform-apple): pin scoped-set simctl argv for doctor, log stream and network recovery Refs #2878 * chore(gates): add R79 apple-simulator-scope layering rule Refuse hand-built 'simctl' argv arrays, a '--set' literal, casts that forge ScopedSimctlArgs or SimulatorAddress, and any reference to the set-scope builder outside its owners (core/simctl.ts, simulator-inventory.ts, logs/doctor.ts). Refs #2878
1 parent b916605 commit a363a02

36 files changed

Lines changed: 609 additions & 136 deletions

‎packages/contracts/src/network-runtime.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ function compileTimeNetworkProjectionProof(
2121
void compileTimeNetworkProjectionProof;
2222

2323
function compileTimeCanonicalHostProof(host: NetworkRuntimeHost): void {
24+
// @ts-expect-error Raw simctl argv cannot cross the Apple tool port; scope it in platform-apple.
2425
void host.appleTools.run({ tool: 'simctl', args: ['spawn', 'sim-1', 'log', 'show'] });
2526
void host.appleTools.run({
2627
tool: 'simctl',

‎packages/contracts/src/platform-runtime-host.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,15 @@ export type HostCommandRunner = Readonly<{
5555

5656
export type AppleXcrunTool = 'simctl' | 'devicectl' | 'xctrace';
5757

58-
export type AppleToolRequest = Readonly<{
59-
tool: AppleXcrunTool;
60-
args: readonly string[];
61-
timeoutMs?: number;
62-
allowFailure?: boolean;
63-
}>;
58+
declare const scopedSimctlArgs: unique symbol;
59+
/** simctl argv (after the tool name) already scoped to its simulator set; minted only by platform-apple. */
60+
export type ScopedSimctlArgs = readonly string[] & { readonly [scopedSimctlArgs]: true };
61+
62+
export type AppleToolRequest = Readonly<{ timeoutMs?: number; allowFailure?: boolean }> &
63+
(
64+
| Readonly<{ tool: 'simctl'; args: ScopedSimctlArgs }>
65+
| Readonly<{ tool: Exclude<AppleXcrunTool, 'simctl'>; args: readonly string[] }>
66+
);
6467

6568
/** Request-bound foreground Apple tooling backed by the selected scoped provider. */
6669
export type AppleToolHost = Readonly<{

‎packages/platform-apple/src/core/__tests__/app-launch-device-open.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ test('openIosDevice leaves the macOS desktop target alone', async () => {
6464
const provider = createLocalAppleToolProvider({
6565
simctl: {
6666
run: async (args) => {
67-
simctlCalls.push(args);
67+
simctlCalls.push([...args]);
6868
return { exitCode: 0, stdout: '', stderr: '' };
6969
},
7070
},

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { screenshotIos } from '../screenshot.ts';
88
import { createLocalAppleToolProvider, withAppleToolProvider } from '../tool-provider.ts';
99
import { mkdtempForTest } from '../../__tests__/tmp-dir.ts';
1010

11-
function startsWithArgs(args: string[], expected: string[]): boolean {
11+
function startsWithArgs(args: readonly string[], expected: string[]): boolean {
1212
return expected.every((value, index) => args[index] === value);
1313
}
1414

@@ -29,7 +29,7 @@ test('screenshotIos caches simulator screen scale per device', async () => {
2929
const provider = createLocalAppleToolProvider({
3030
simctl: {
3131
run: async (args) => {
32-
calls.push(args);
32+
calls.push([...args]);
3333
if (startsWithArgs(args, ['getenv', device.id, 'SIMULATOR_MAINSCREEN_SCALE'])) {
3434
return { exitCode: 0, stdout: '3\n', stderr: '' };
3535
}

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

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { test } from 'vitest';
22
import assert from 'node:assert/strict';
33
import {
4-
buildSimctlArgs,
4+
buildSimctlArgsForAddress,
55
buildSimctlArgsForDevice,
66
readSimctlDevicesByRuntime,
77
readSimctlDeviceState,
88
scopeSimctlArgs,
99
scopeSimctlArgsForDevice,
10+
simulatorAddressFor,
11+
type SimulatorAddress,
1012
} from '../simctl.ts';
1113
import type { DeviceInfo } from '@agent-device/kernel/device';
1214

@@ -18,10 +20,11 @@ const IOS_SIMULATOR: DeviceInfo = {
1820
target: 'mobile',
1921
};
2022

21-
test('buildSimctlArgs uses --set when simulator set path is provided', () => {
22-
const args = buildSimctlArgs(['list', 'devices', '-j'], {
23-
simulatorSetPath: '/tmp/tenant-a/simulator-set',
24-
});
23+
test('buildSimctlArgsForAddress uses --set when the address names a simulator set', () => {
24+
const args = buildSimctlArgsForAddress(
25+
simulatorAddressFor({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/simulator-set' }),
26+
['list', 'devices', '-j'],
27+
);
2528
assert.deepEqual(args, [
2629
'simctl',
2730
'--set',
@@ -85,6 +88,31 @@ test('scopeSimctlArgsForDevice scopes simulators only', () => {
8588
]);
8689
});
8790

91+
test('simulatorAddressFor carries the set of iOS-family simulators only', () => {
92+
const scoped = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-d/simulator-set' };
93+
assert.deepEqual(simulatorAddressFor(scoped), {
94+
udid: 'sim-1',
95+
simulatorSetPath: '/tmp/tenant-d/simulator-set',
96+
});
97+
assert.deepEqual(simulatorAddressFor({ ...scoped, kind: 'device' }), {
98+
udid: 'sim-1',
99+
simulatorSetPath: undefined,
100+
});
101+
assert.deepEqual(simulatorAddressFor({ ...scoped, platform: 'android', kind: 'emulator' }), {
102+
udid: 'sim-1',
103+
simulatorSetPath: undefined,
104+
});
105+
});
106+
107+
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']);
110+
// @ts-expect-error A simulator address is minted from its DeviceInfo, never written by hand.
111+
const forged: SimulatorAddress = { udid: 'sim-1', simulatorSetPath: undefined };
112+
void forged;
113+
}
114+
void compileTimeSimulatorScopeProof;
115+
88116
const LISTING = JSON.stringify({
89117
devices: {
90118
'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@ import { test } from 'vitest';
33
import {
44
createLocalAppleToolProvider,
55
readApplePlistJson,
6+
resolveAppleToolProvider,
67
runAppleToolCommand,
78
runXcrun,
89
withAppleToolProvider,
910
} from '../tool-provider.ts';
1011

12+
function compileTimeScopedSimctlProof(): void {
13+
// @ts-expect-error Raw simctl argv cannot reach the provider; scope it in core/simctl.ts.
14+
void resolveAppleToolProvider().simctl.run(['spawn', 'sim-1', 'bridge']);
15+
}
16+
void compileTimeScopedSimctlProof;
17+
1118
test('scoped Apple tool provider handles xcrun execution', async () => {
1219
const calls: Array<[string, string[]]> = [];
1320
const provider = createLocalAppleToolProvider({
@@ -35,7 +42,7 @@ test('scoped Apple tool provider prefers semantic simctl and devicectl hooks', a
3542
},
3643
simctl: {
3744
run: async (args) => {
38-
calls.push(['simctl', args]);
45+
calls.push(['simctl', [...args]]);
3946
return { exitCode: 0, stdout: 'simctl', stderr: '' };
4047
},
4148
},

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

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,55 @@
11
import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device';
22
import { type ExecOptions, type ExecResult } from '@agent-device/host-kit/command';
33
import { resolveIosSimulatorDeviceSetPath } from '@agent-device/kernel/device-isolation';
4+
import type { ScopedSimctlArgs } from '@agent-device/contracts/platform-runtime-host';
45
import { runXcrun } from './tool-provider.ts';
56

6-
type SimctlArgsOptions = {
7-
simulatorSetPath?: string;
7+
/** The set of a simctl call that names no device; `undefined` names the default set on purpose. */
8+
export type SimulatorSetScope = Readonly<{ simulatorSetPath: string | undefined }>;
9+
10+
declare const simulatorAddress: unique symbol;
11+
/** A simulator udid with the set that holds it; minted only from a DeviceInfo. */
12+
export type SimulatorAddress = Readonly<{ udid: string; simulatorSetPath: string | undefined }> & {
13+
readonly [simulatorAddress]: true;
814
};
915

10-
/** Arguments that follow the `simctl` tool name, scoped to the simulator set when one is given. */
16+
export function simulatorAddressFor(device: DeviceInfo): SimulatorAddress {
17+
const simulatorSetPath =
18+
isIosFamily(device) && device.kind === 'simulator' ? device.simulatorSetPath : undefined;
19+
return Object.freeze({ udid: device.id, simulatorSetPath }) as SimulatorAddress;
20+
}
21+
22+
/** Arguments that follow the `simctl` tool name for a call that names no device. */
1123
export function scopeSimctlArgs(
1224
args: readonly string[],
13-
options: SimctlArgsOptions = {},
14-
): string[] {
15-
const simulatorSetPath = resolveIosSimulatorDeviceSetPath(options.simulatorSetPath);
16-
if (!simulatorSetPath) return [...args];
17-
return ['--set', simulatorSetPath, ...args];
25+
scope: SimulatorSetScope,
26+
): ScopedSimctlArgs {
27+
const simulatorSetPath = resolveIosSimulatorDeviceSetPath(scope.simulatorSetPath);
28+
const scoped = simulatorSetPath ? ['--set', simulatorSetPath, ...args] : [...args];
29+
return Object.freeze(scoped) as ScopedSimctlArgs;
30+
}
31+
32+
/** Arguments that follow the `simctl` tool name, scoped to the set holding the addressed simulator. */
33+
export function scopeSimctlArgsForAddress(
34+
address: SimulatorAddress,
35+
args: readonly string[],
36+
): ScopedSimctlArgs {
37+
return scopeSimctlArgs(args, { simulatorSetPath: address.simulatorSetPath });
1838
}
1939

2040
/** Arguments that follow the `simctl` tool name, scoped to the simulator set holding the device. */
21-
export function scopeSimctlArgsForDevice(device: DeviceInfo, args: readonly string[]): string[] {
22-
if (!isIosFamily(device) || device.kind !== 'simulator') return [...args];
23-
return scopeSimctlArgs(args, { simulatorSetPath: device.simulatorSetPath });
41+
export function scopeSimctlArgsForDevice(
42+
device: DeviceInfo,
43+
args: readonly string[],
44+
): ScopedSimctlArgs {
45+
return scopeSimctlArgsForAddress(simulatorAddressFor(device), args);
2446
}
2547

26-
export function buildSimctlArgs(
48+
export function buildSimctlArgsForAddress(
49+
address: SimulatorAddress,
2750
args: readonly string[],
28-
options: SimctlArgsOptions = {},
2951
): string[] {
30-
return ['simctl', ...scopeSimctlArgs(args, options)];
52+
return ['simctl', ...scopeSimctlArgsForAddress(address, args)];
3153
}
3254

3355
export function buildSimctlArgsForDevice(device: DeviceInfo, args: readonly string[]): string[] {

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AppsFilter } from '@agent-device/contracts/device';
2+
import type { ScopedSimctlArgs } from '@agent-device/contracts/platform-runtime-host';
23
import { type ExecOptions, type ExecResult } from '@agent-device/host-kit/command';
34
import type { IosAppInfo } from './app-info.ts';
45

@@ -19,6 +20,10 @@ export type AppleXcrunToolProvider = {
1920
run: AppleToolSubcommandExecutor;
2021
};
2122

23+
export type AppleSimctlToolProvider = {
24+
run: (args: ScopedSimctlArgs, options?: ExecOptions) => Promise<ExecResult>;
25+
};
26+
2227
export type AppleMacOsHelperProvider = {
2328
run: AppleToolSubcommandExecutor;
2429
};

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ import {
55
type ExecOptions,
66
type ExecResult,
77
} from '@agent-device/host-kit/command';
8+
import type { ScopedSimctlArgs } from '@agent-device/contracts/platform-runtime-host';
89
import { createScopedProvider } from '@agent-device/kernel/scoped-provider';
910
import { createLocalAppleMacOsHostProvider } from '../os/macos/host-provider.ts';
1011
import type {
1112
AppleMacOsHelperProvider,
1213
AppleMacOsHostProvider,
1314
ApplePlistProvider,
15+
AppleSimctlToolProvider,
1416
AppleToolAvailabilityChecker,
1517
AppleToolCommandExecutor,
16-
AppleToolSubcommandExecutor,
1718
AppleXcrunToolProvider,
1819
} from './tool-provider-types.ts';
1920

@@ -29,7 +30,7 @@ export type {
2930

3031
export type AppleToolProvider = {
3132
runCommand: AppleToolCommandExecutor;
32-
simctl: AppleXcrunToolProvider;
33+
simctl: AppleSimctlToolProvider;
3334
devicectl: AppleXcrunToolProvider;
3435
macosHelper?: AppleMacOsHelperProvider;
3536
macosHost?: AppleMacOsHostProvider;
@@ -118,7 +119,7 @@ export async function runXcrun(args: string[], options?: ExecOptions): Promise<E
118119
const provider = resolveAppleToolProvider();
119120
const [tool, ...toolArgs] = args;
120121
if (tool === 'simctl') {
121-
return await provider.simctl.run(toolArgs, options);
122+
return await provider.simctl.run(toolArgs as unknown as ScopedSimctlArgs, options);
122123
}
123124
if (tool === 'devicectl') {
124125
return await provider.devicectl.run(toolArgs, options);
@@ -149,7 +150,9 @@ function coerceRunCommand(run: AppleToolCommandExecutor): AppleToolCommandExecut
149150
return async (cmd, args, options) => coerceExecResult(await run(cmd, args, options));
150151
}
151152

152-
function coerceRun(run: AppleToolSubcommandExecutor): AppleToolSubcommandExecutor {
153+
function coerceRun<Args>(
154+
run: (args: Args, options?: ExecOptions) => Promise<ExecResult>,
155+
): (args: Args, options?: ExecOptions) => Promise<ExecResult> {
153156
return async (args, options) => coerceExecResult(await run(args, options));
154157
}
155158

‎packages/platform-apple/src/deployment/runtime.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ async function pushAppleNotification(
228228
*/
229229
async function runAppleTool(
230230
host: PlatformRuntimeHost,
231-
request: Omit<AppleToolRequest, 'allowFailure'>,
231+
request: AppleToolRequest,
232232
signal: AbortSignal,
233233
message: string,
234234
options?: Readonly<{ tolerate?: (result: HostCommandResult) => boolean }>,

0 commit comments

Comments
 (0)