diff --git a/packages/platform-apple/src/core/__tests__/simctl.test.ts b/packages/platform-apple/src/core/__tests__/simctl.test.ts index d24f1ce197..294e1120f4 100644 --- a/packages/platform-apple/src/core/__tests__/simctl.test.ts +++ b/packages/platform-apple/src/core/__tests__/simctl.test.ts @@ -5,8 +5,9 @@ import { buildSimctlArgsForDevice, readSimctlDevicesByRuntime, readSimctlDeviceState, - scopeSimctlArgs, scopeSimctlArgsForDevice, + simctlAvailabilityProbeArgs, + simctlListDevicesArgs, simulatorAddressFor, type SimulatorAddress, } from '../simctl.ts'; @@ -59,19 +60,20 @@ test('buildSimctlArgsForDevice leaves non-simulator commands unchanged', () => { assert.deepEqual(args, ['simctl', 'bootstatus', 'sim-1', '-b']); }); -test('scopeSimctlArgs prefixes a trimmed simulator set and omits a blank one', () => { - assert.deepEqual(scopeSimctlArgs(['list', 'devices', '-j'], { simulatorSetPath: ' /tmp/set ' }), [ +test('simctlListDevicesArgs prefixes a trimmed simulator set and omits a blank one', () => { + assert.deepEqual(simctlListDevicesArgs(' /tmp/set '), [ '--set', '/tmp/set', 'list', 'devices', '-j', ]); - assert.deepEqual(scopeSimctlArgs(['list', 'devices', '-j'], { simulatorSetPath: ' ' }), [ - 'list', - 'devices', - '-j', - ]); + assert.deepEqual(simctlListDevicesArgs(' '), ['list', 'devices', '-j']); + assert.deepEqual(simctlListDevicesArgs(undefined), ['list', 'devices', '-j']); +}); + +test('simctlAvailabilityProbeArgs names no set', () => { + assert.deepEqual(simctlAvailabilityProbeArgs(), ['help']); }); test('scopeSimctlArgsForDevice scopes simulators only', () => { @@ -105,11 +107,13 @@ test('simulatorAddressFor carries the set of iOS-family simulators only', () => }); function compileTimeSimulatorScopeProof(): void { - // @ts-expect-error A set-scope call states its set; leaving it out does not mean the default set. - void scopeSimctlArgs(['list']); // @ts-expect-error A simulator address is minted from its DeviceInfo, never written by hand. const forged: SimulatorAddress = { udid: 'sim-1', simulatorSetPath: undefined }; void forged; + // @ts-expect-error Set scope is private; a call that names no device goes through a named mint. + type SetScope = (typeof import('../simctl.ts'))['scopeSimctlArgs']; + const setScope: SetScope | undefined = undefined; + void setScope; } void compileTimeSimulatorScopeProof; diff --git a/packages/platform-apple/src/core/__tests__/simulator-booted-memo.test.ts b/packages/platform-apple/src/core/__tests__/simulator-booted-memo.test.ts index 7e1afe7671..541f30b9eb 100644 --- a/packages/platform-apple/src/core/__tests__/simulator-booted-memo.test.ts +++ b/packages/platform-apple/src/core/__tests__/simulator-booted-memo.test.ts @@ -9,7 +9,8 @@ import { } from '../simulator.ts'; import { runXcrun } from '../tool-provider.ts'; -vi.mock('../tool-provider.ts', () => ({ +vi.mock('../tool-provider.ts', async (importOriginal) => ({ + ...(await importOriginal()), runAppleToolCommand: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), runXcrun: vi.fn(), })); diff --git a/packages/platform-apple/src/core/__tests__/tool-provider.test.ts b/packages/platform-apple/src/core/__tests__/tool-provider.test.ts index 43584b19f9..88cc233ad1 100644 --- a/packages/platform-apple/src/core/__tests__/tool-provider.test.ts +++ b/packages/platform-apple/src/core/__tests__/tool-provider.test.ts @@ -6,12 +6,49 @@ import { resolveAppleToolProvider, runAppleToolCommand, runXcrun, + simctlCommand, withAppleToolProvider, + type ScopedSimctlCommand, } from '../tool-provider.ts'; - -function compileTimeScopedSimctlProof(): void { +import { buildSimctlArgsForDevice, scopeSimctlArgsForDevice } from '../simctl.ts'; +import type { AppleToolRequest } from '@agent-device/contracts/platform-runtime-host'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +const IOS_SIMULATOR: DeviceInfo = { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17', + kind: 'simulator', + target: 'mobile', +}; +const LAUNCH_COMMAND = buildSimctlArgsForDevice(IOS_SIMULATOR, [ + 'launch', + 'sim-1', + 'com.example.app', +]); + +function compileTimeScopedSimctlProof(request: AppleToolRequest, argv: string[]): void { // @ts-expect-error Raw simctl argv cannot reach the provider; scope it in core/simctl.ts. void resolveAppleToolProvider().simctl.run(['spawn', 'sim-1', 'bridge']); + // @ts-expect-error A literal simctl argv is not a ScopedSimctlCommand. + void runXcrun(['simctl', 'spawn', 'sim-1', 'bridge']); + const tool = 'simctl'; + // @ts-expect-error A tool name held in a const keeps its literal type, so it is refused too. + void runXcrun([tool, 'spawn', 'sim-1', 'bridge']); + const widenedTool: string = 'simctl'; + // @ts-expect-error runXcrun names every other tool it runs, so a string tool name is refused. + void runXcrun([widenedTool, 'spawn', 'sim-1', 'bridge']); + // @ts-expect-error An argv typed string[] may start with simctl, so it is refused. + void runXcrun(argv); + // @ts-expect-error Copying a scoped command drops its brand. + void runXcrun([...LAUNCH_COMMAND]); + // @ts-expect-error A request whose tool may be simctl goes to the simctl provider with its scoped args. + void runXcrun([request.tool, ...request.args]); + // @ts-expect-error A hand-built simctl argv is not a ScopedSimctlCommand. + const handBuilt: ScopedSimctlCommand = ['simctl', 'boot', 'sim-1']; + void handBuilt; + // @ts-expect-error simctlCommand takes set-scoped arguments, never a raw argv. + void simctlCommand(['boot', 'sim-1']); } void compileTimeScopedSimctlProof; @@ -24,10 +61,7 @@ test('scoped Apple tool provider handles xcrun execution', async () => { }, }); - const result = await withAppleToolProvider( - provider, - async () => await runXcrun(['simctl', 'launch', 'sim-1', 'com.example.app']), - ); + const result = await withAppleToolProvider(provider, async () => await runXcrun(LAUNCH_COMMAND)); assert.equal(result.stdout, 'ok'); 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 const simctlResult = await withAppleToolProvider( provider, - async () => await runXcrun(['simctl', 'launch', 'sim-1', 'com.example.app']), + async () => await runXcrun(LAUNCH_COMMAND), ); const devicectlResult = await withAppleToolProvider( provider, @@ -71,6 +105,38 @@ test('scoped Apple tool provider prefers semantic simctl and devicectl hooks', a ]); }); +test('simctlCommand prefixes set-scoped arguments with the tool name and freezes the argv', () => { + const command = simctlCommand( + scopeSimctlArgsForDevice({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant/simulator-set' }, [ + 'boot', + 'sim-1', + ]), + ); + + assert.deepEqual(command, ['simctl', '--set', '/tmp/tenant/simulator-set', 'boot', 'sim-1']); + assert.ok(Object.isFrozen(command)); +}); + +test('runXcrun hands a scoped simctl command to the simctl provider with its set intact', async () => { + const received: Array = []; + const provider = createLocalAppleToolProvider({ + simctl: { + run: async (args) => { + received.push(args); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }, + }); + const command = buildSimctlArgsForDevice( + { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant/simulator-set' }, + ['boot', 'sim-1'], + ); + + await withAppleToolProvider(provider, async () => await runXcrun(command)); + + assert.deepEqual(received, [['--set', '/tmp/tenant/simulator-set', 'boot', 'sim-1']]); +}); + test('scoped Apple tool provider exposes plist JSON reads as semantic operation', async () => { const provider = createLocalAppleToolProvider({ runCommand: async () => { diff --git a/packages/platform-apple/src/core/app-launch.ts b/packages/platform-apple/src/core/app-launch.ts index 652eeb3ab4..0d133040dd 100644 --- a/packages/platform-apple/src/core/app-launch.ts +++ b/packages/platform-apple/src/core/app-launch.ts @@ -27,7 +27,7 @@ import { launchFailureHint, } from './launch-diagnostics.ts'; import { ensureBootedSimulator } from './simulator.ts'; -import { runXcrun } from './tool-provider.ts'; +import { runXcrun, type ScopedSimctlCommand } from './tool-provider.ts'; import { closeMacOsApp, openMacOsApp } from '../os/macos/apps.ts'; import { resolveIosApp } from './app-resolution.ts'; import { buildSimctlArgsForDevice, runSimctlForDevice } from './simctl.ts'; @@ -301,7 +301,7 @@ function buildIosSimulatorLaunchArgs( } async function runIosSimulatorConsoleLaunch( - launchArgs: string[], + launchArgs: ScopedSimctlCommand, logPath: string, ): Promise>> { await ensureHostDirectory(path.dirname(logPath)); diff --git a/packages/platform-apple/src/core/devicectl.ts b/packages/platform-apple/src/core/devicectl.ts index 3403b37068..92ec8ea808 100644 --- a/packages/platform-apple/src/core/devicectl.ts +++ b/packages/platform-apple/src/core/devicectl.ts @@ -60,7 +60,7 @@ export async function runIosDevicectl( tolerateOutput?: (stdout: string, stderr: string) => boolean; } = {}, ): Promise { - const fullArgs = ['devicectl', ...args]; + const fullArgs: ['devicectl', ...string[]] = ['devicectl', ...args]; const result = await runXcrun(fullArgs, { allowFailure: true, signal: options.signal, @@ -202,7 +202,7 @@ export type IosDevicectlJsonOutcome = */ export async function runIosDevicectlJsonRequest(options: { jsonPrefix: string; - args: string[]; + args: ['devicectl', ...string[]]; timeoutMs?: number; signal?: AbortSignal; tolerateFailurePayload?: (payload: unknown) => boolean; @@ -211,7 +211,7 @@ export async function runIosDevicectlJsonRequest(options: { hostTemporaryDirectory(), `${options.jsonPrefix}-${hostProcessId()}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, ); - const args = [...options.args, '--json-output', jsonPath]; + const args: ['devicectl', ...string[]] = [...options.args, '--json-output', jsonPath]; try { const result = await runXcrun(args, { allowFailure: true, @@ -248,7 +248,7 @@ async function runIosDevicectlJsonCommand( device: DeviceInfo, options: { jsonPrefix: string; - args: string[]; + args: ['devicectl', ...string[]]; failureMessage: string; parseFailureMessage: string; fallbackHint?: string; diff --git a/packages/platform-apple/src/core/hinge-angle.ts b/packages/platform-apple/src/core/hinge-angle.ts index d7d8a1531b..bd864847dc 100644 --- a/packages/platform-apple/src/core/hinge-angle.ts +++ b/packages/platform-apple/src/core/hinge-angle.ts @@ -28,7 +28,7 @@ export async function readAppleHingeAngle( device: DeviceInfo, options: { signal?: AbortSignal } = {}, ): Promise { - const args = [ + const args: ['devicectl', ...string[]] = [ 'devicectl', 'device', 'motion', diff --git a/packages/platform-apple/src/core/perf-target.ts b/packages/platform-apple/src/core/perf-target.ts index facc6e8650..120bc5f90e 100644 --- a/packages/platform-apple/src/core/perf-target.ts +++ b/packages/platform-apple/src/core/perf-target.ts @@ -8,7 +8,7 @@ import type { IosDeviceProcessInfo } from './app-info.ts'; import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; import { readInfoPlistString } from './plist.ts'; import { buildSimctlArgsForDevice } from './simctl.ts'; -import { runAppleToolCommand, runXcrun } from './tool-provider.ts'; +import { runAppleToolCommand, runXcrun, type ScopedSimctlCommand } from './tool-provider.ts'; const APPLE_PERF_TIMEOUT_MS = 15_000; @@ -152,25 +152,26 @@ export async function readAppleProcessSamples( device: DeviceInfo, executable: { executableName: string; executablePath?: string }, ): Promise { - const args = isMacOs(device) - ? ['-axo', 'pid=,%cpu=,rss=,command='] - : buildSimctlArgsForDevice(device, [ - 'spawn', - device.id, - 'ps', - '-axo', - 'pid=,%cpu=,rss=,command=', - ]); const result = isMacOs(device) - ? await runAppleToolCommand('ps', args, { timeoutMs: APPLE_PERF_TIMEOUT_MS }) - : await runAppleSimulatorProcessCommand(args); + ? await runAppleToolCommand('ps', ['-axo', 'pid=,%cpu=,rss=,command='], { + timeoutMs: APPLE_PERF_TIMEOUT_MS, + }) + : await runAppleSimulatorProcessCommand( + buildSimctlArgsForDevice(device, [ + 'spawn', + device.id, + 'ps', + '-axo', + 'pid=,%cpu=,rss=,command=', + ]), + ); const { matchesAppleExecutableProcess } = await import('./perf-process-identity.ts'); return parseApplePsOutput(result.stdout).filter((processInfo) => matchesAppleExecutableProcess(processInfo.command, executable), ); } -async function runAppleSimulatorProcessCommand(args: string[]): Promise { +async function runAppleSimulatorProcessCommand(args: ScopedSimctlCommand): Promise { const result = await runXcrun(args, { allowFailure: true, timeoutMs: APPLE_PERF_TIMEOUT_MS, diff --git a/packages/platform-apple/src/core/perf-xctrace.ts b/packages/platform-apple/src/core/perf-xctrace.ts index 44602afbc4..03fc3f0d9d 100644 --- a/packages/platform-apple/src/core/perf-xctrace.ts +++ b/packages/platform-apple/src/core/perf-xctrace.ts @@ -306,7 +306,7 @@ export async function exportAppleXctraceData(params: { failureMessage: string; failureDetails: Record; }): Promise { - const exportArgs = [ + const exportArgs: ['xctrace', ...string[]] = [ 'xctrace', 'export', '--input', @@ -376,7 +376,7 @@ function buildAppleXctraceRecordArgs(params: { target: AppleXctraceRecordTarget; timeLimit?: string; outPath: string; -}): string[] { +}): ['xctrace', ...string[]] { return [ 'xctrace', 'record', diff --git a/packages/platform-apple/src/core/physical-device-control.ts b/packages/platform-apple/src/core/physical-device-control.ts index 9d174219c1..54ddb3651f 100644 --- a/packages/platform-apple/src/core/physical-device-control.ts +++ b/packages/platform-apple/src/core/physical-device-control.ts @@ -159,7 +159,13 @@ async function rejectXctestRunnerFileCopy(device: DeviceInfo): Promise { async function ensureXctestDeviceReady(device: DeviceInfo, signal?: AbortSignal): Promise { const timeoutSeconds = Math.max(1, Math.ceil(IOS_DEVICE_READY_TIMEOUT_MS / 1000)); - const args = ['xcdevice', 'wait', '--both', `--timeout=${timeoutSeconds}`, device.id]; + const args: ['xcdevice', ...string[]] = [ + 'xcdevice', + 'wait', + '--both', + `--timeout=${timeoutSeconds}`, + device.id, + ]; const result = await runXcrun(args, { allowFailure: true, signal, diff --git a/packages/platform-apple/src/core/settings-parsing.ts b/packages/platform-apple/src/core/settings-parsing.ts index 2f6eccff0b..fe98413908 100644 --- a/packages/platform-apple/src/core/settings-parsing.ts +++ b/packages/platform-apple/src/core/settings-parsing.ts @@ -21,7 +21,7 @@ export function parseSettingState(state: string): boolean { // fallow-ignore-next-line code-duplication export type CommandAttemptFailure = { - args: string[]; + args: readonly string[]; stdout: string; stderr: string; exitCode: number; diff --git a/packages/platform-apple/src/core/simctl.ts b/packages/platform-apple/src/core/simctl.ts index eddb2d7933..6c156bdd60 100644 --- a/packages/platform-apple/src/core/simctl.ts +++ b/packages/platform-apple/src/core/simctl.ts @@ -2,10 +2,7 @@ import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { type ExecOptions, type ExecResult } from '@agent-device/host-kit/command'; import { resolveIosSimulatorDeviceSetPath } from '@agent-device/kernel/device-isolation'; import type { ScopedSimctlArgs } from '@agent-device/contracts/platform-runtime-host'; -import { runXcrun } from './tool-provider.ts'; - -/** The set of a simctl call that names no device; `undefined` names the default set on purpose. */ -export type SimulatorSetScope = Readonly<{ simulatorSetPath: string | undefined }>; +import { runXcrun, simctlCommand, type ScopedSimctlCommand } from './tool-provider.ts'; declare const simulatorAddress: unique symbol; /** A simulator udid with the set that holds it; minted only from a DeviceInfo. */ @@ -19,22 +16,23 @@ export function simulatorAddressFor(device: DeviceInfo): SimulatorAddress { return Object.freeze({ udid: device.id, simulatorSetPath }) as SimulatorAddress; } -/** Arguments that follow the `simctl` tool name for a call that names no device. */ -export function scopeSimctlArgs( +function scopeSimctlArgs( + simulatorSetPath: string | undefined, args: readonly string[], - scope: SimulatorSetScope, ): ScopedSimctlArgs { - const simulatorSetPath = resolveIosSimulatorDeviceSetPath(scope.simulatorSetPath); - const scoped = simulatorSetPath ? ['--set', simulatorSetPath, ...args] : [...args]; + const resolvedSetPath = resolveIosSimulatorDeviceSetPath(simulatorSetPath); + const scoped = resolvedSetPath ? ['--set', resolvedSetPath, ...args] : [...args]; return Object.freeze(scoped) as ScopedSimctlArgs; } -/** Arguments that follow the `simctl` tool name, scoped to the set holding the addressed simulator. */ -export function scopeSimctlArgsForAddress( - address: SimulatorAddress, - args: readonly string[], -): ScopedSimctlArgs { - return scopeSimctlArgs(args, { simulatorSetPath: address.simulatorSetPath }); +/** `simctl help`: probes that simctl runs at all, so it names no set. */ +export function simctlAvailabilityProbeArgs(): ScopedSimctlArgs { + return scopeSimctlArgs(undefined, ['help']); +} + +/** `simctl list devices -j` over one simulator set; `undefined` lists the default set. */ +export function simctlListDevicesArgs(simulatorSetPath: string | undefined): ScopedSimctlArgs { + return scopeSimctlArgs(simulatorSetPath, ['list', 'devices', '-j']); } /** Arguments that follow the `simctl` tool name, scoped to the simulator set holding the device. */ @@ -42,18 +40,21 @@ export function scopeSimctlArgsForDevice( device: DeviceInfo, args: readonly string[], ): ScopedSimctlArgs { - return scopeSimctlArgsForAddress(simulatorAddressFor(device), args); + return scopeSimctlArgs(simulatorAddressFor(device).simulatorSetPath, args); } export function buildSimctlArgsForAddress( address: SimulatorAddress, args: readonly string[], -): string[] { - return ['simctl', ...scopeSimctlArgsForAddress(address, args)]; +): ScopedSimctlCommand { + return simctlCommand(scopeSimctlArgs(address.simulatorSetPath, args)); } -export function buildSimctlArgsForDevice(device: DeviceInfo, args: readonly string[]): string[] { - return ['simctl', ...scopeSimctlArgsForDevice(device, args)]; +export function buildSimctlArgsForDevice( + device: DeviceInfo, + args: readonly string[], +): ScopedSimctlCommand { + return buildSimctlArgsForAddress(simulatorAddressFor(device), args); } export function runSimctlForDevice( diff --git a/packages/platform-apple/src/core/tool-provider.ts b/packages/platform-apple/src/core/tool-provider.ts index 1850f4abef..8e0667e32b 100644 --- a/packages/platform-apple/src/core/tool-provider.ts +++ b/packages/platform-apple/src/core/tool-provider.ts @@ -28,6 +28,28 @@ export type { AppleXcrunToolProvider, } from './tool-provider-types.ts'; +declare const scopedSimctlCommand: unique symbol; +/** The xcrun argv of one simctl call: `simctl` followed by arguments already scoped to their set. */ +export type ScopedSimctlCommand = readonly ['simctl', ...string[]] & { + readonly [scopedSimctlCommand]: true; +}; + +export function simctlCommand(args: ScopedSimctlArgs): ScopedSimctlCommand { + return Object.freeze(['simctl', ...args] as const) as ScopedSimctlCommand; +} + +async function runSimctlCommand( + runCommand: AppleToolCommandExecutor, + args: ScopedSimctlArgs, + options?: ExecOptions, +): Promise { + return await runCommand('xcrun', [...simctlCommand(args)], options); +} + +function simctlCommandArgs(command: ScopedSimctlCommand): ScopedSimctlArgs { + return Object.freeze(command.slice(1)) as ScopedSimctlArgs; +} + export type AppleToolProvider = { runCommand: AppleToolCommandExecutor; simctl: AppleSimctlToolProvider; @@ -41,7 +63,7 @@ export type AppleToolProvider = { const localAppleToolProvider: AppleToolProvider = { runCommand: runCmd, simctl: { - run: async (args, options) => await runCmd('xcrun', ['simctl', ...args], options), + run: async (args, options) => await runSimctlCommand(runCmd, args, options), }, devicectl: { run: async (args, options) => await runCmd('xcrun', ['devicectl', ...args], options), @@ -76,7 +98,7 @@ export function createLocalAppleToolProvider( return { ...merged, simctl: provider.simctl ?? { - run: async (args, options) => await merged.runCommand('xcrun', ['simctl', ...args], options), + run: async (args, options) => await runSimctlCommand(merged.runCommand, args, options), }, devicectl: provider.devicectl ?? { run: async (args, options) => @@ -115,16 +137,22 @@ export async function runAppleToolCommand( return await resolveAppleToolProvider().runCommand(cmd, args, options); } -export async function runXcrun(args: string[], options?: ExecOptions): Promise { +/** An xcrun argv for a tool other than simctl; a simctl argv is a ScopedSimctlCommand. */ +type XcrunToolArgs = readonly ['devicectl' | 'xcdevice' | 'xctrace', ...string[]]; + +export async function runXcrun( + args: ScopedSimctlCommand | XcrunToolArgs, + options?: ExecOptions, +): Promise { const provider = resolveAppleToolProvider(); - const [tool, ...toolArgs] = args; - if (tool === 'simctl') { - return await provider.simctl.run(toolArgs as unknown as ScopedSimctlArgs, options); + if (args[0] === 'simctl') { + return await provider.simctl.run(simctlCommandArgs(args), options); } + const [tool, ...toolArgs] = args; if (tool === 'devicectl') { return await provider.devicectl.run(toolArgs, options); } - return await runAppleToolCommand('xcrun', args, options); + return await runAppleToolCommand('xcrun', [...args], options); } export async function readApplePlistJson( diff --git a/packages/platform-apple/src/logs/doctor.test.ts b/packages/platform-apple/src/logs/doctor.test.ts index f9747bc00b..fede82780f 100644 --- a/packages/platform-apple/src/logs/doctor.test.ts +++ b/packages/platform-apple/src/logs/doctor.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from 'vitest'; +import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; import { doctorAppleAppLogs } from './doctor.ts'; import { appleDevice, hostFixture } from './runtime.fixtures.ts'; @@ -24,12 +25,10 @@ test('simulator doctor routes the exact simctl probe through appleTools', async test('simulator doctor probes simctl without a set, even for a scoped-set simulator', async () => { const appleToolRun = vi.fn(async () => ({ stdout: 'simctl help', stderr: '', exitCode: 0 })); const fixture = hostFixture({ appleToolRun }); + const device = appleDevice({ simulatorSetPath: '/tmp/scoped-set' }); + expect(scopeSimctlArgsForDevice(device, ['help'])).toEqual(['--set', '/tmp/scoped-set', 'help']); - await doctorAppleAppLogs( - fixture.host, - appleDevice({ simulatorSetPath: '/tmp/scoped-set' }), - 'com.example.app', - ); + await doctorAppleAppLogs(fixture.host, device, 'com.example.app'); expect(appleToolRun).toHaveBeenCalledWith( { tool: 'simctl', args: ['help'], allowFailure: true }, undefined, diff --git a/packages/platform-apple/src/logs/doctor.ts b/packages/platform-apple/src/logs/doctor.ts index 2414566351..56a6f1dfc6 100644 --- a/packages/platform-apple/src/logs/doctor.ts +++ b/packages/platform-apple/src/logs/doctor.ts @@ -1,7 +1,7 @@ import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; import type { AppLogRuntimeHost } from '@agent-device/contracts/app-log-runtime'; import { appLogCommandSucceeded, bestEffortAppLogCheck } from '@agent-device/capture-kit'; -import { scopeSimctlArgs } from '../core/simctl.ts'; +import { simctlAvailabilityProbeArgs } from '../core/simctl.ts'; import { APPLE_XCTEST_LOGS_HINT, backendForAppleDevice } from './backend.ts'; import { checkCoreDeviceConsoleCaptureSupport, @@ -28,7 +28,7 @@ export async function doctorAppleAppLogs( await host.appleTools.run( { tool: 'simctl', - args: scopeSimctlArgs(['help'], { simulatorSetPath: undefined }), + args: simctlAvailabilityProbeArgs(), allowFailure: true, }, signal, diff --git a/packages/platform-apple/src/logs/log-predicate.ts b/packages/platform-apple/src/logs/log-predicate.ts index 15893d2f2b..b967fa55ac 100644 --- a/packages/platform-apple/src/logs/log-predicate.ts +++ b/packages/platform-apple/src/logs/log-predicate.ts @@ -1,5 +1,6 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { buildSimctlArgsForDevice } from '../core/simctl.ts'; +import type { ScopedSimctlCommand } from '../core/tool-provider.ts'; export function buildAppleLogPredicate(appBundleId: string, executableName?: string): string { const escapedBundleId = escapePredicateString(appBundleId); @@ -25,7 +26,7 @@ export function buildAppleLogPredicate(appBundleId: string, executableName?: str export function buildIosSimulatorLogStreamArgs( device: DeviceInfo, params: { appBundleId: string; executableName?: string }, -): string[] { +): ScopedSimctlCommand { return buildSimctlArgsForDevice(device, [ 'spawn', device.id, diff --git a/packages/platform-apple/src/simctl-facade.ts b/packages/platform-apple/src/simctl-facade.ts index c1d5a806c3..752e1ad262 100644 --- a/packages/platform-apple/src/simctl-facade.ts +++ b/packages/platform-apple/src/simctl-facade.ts @@ -1,6 +1,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { buildSimctlArgsForDevice } from './core/simctl.ts'; +import type { ScopedSimctlCommand } from './core/tool-provider.ts'; export { buildSimctlArgsForDevice }; @@ -19,7 +20,7 @@ export async function buildAppleSimulatorRecordVideoArgs( device: DeviceInfo, outputPath: string, options: { timeoutMs?: number; signal?: AbortSignal } = {}, -): Promise { +): Promise { const { appleSimulatorDisplayArgvFragment, resolveAppleCaptureDisplay } = await import('./core/display-inventory.ts'); const display = await resolveAppleCaptureDisplay(device, options); diff --git a/packages/platform-apple/src/simulator-inventory.test.ts b/packages/platform-apple/src/simulator-inventory.test.ts index bf2c66c02d..17ab04e6bd 100644 --- a/packages/platform-apple/src/simulator-inventory.test.ts +++ b/packages/platform-apple/src/simulator-inventory.test.ts @@ -1,11 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { - buildSimctlListArgs, - listAppleSimulators, - parseSimctlAppleDevices, -} from './simulator-inventory.ts'; +import { listAppleSimulators, parseSimctlAppleDevices } from './simulator-inventory.ts'; import { commandResult, createInventoryHost, inventoryScope } from './inventory.fixtures.ts'; const simulatorPayload = { @@ -118,7 +114,6 @@ test('simulator inventory scopes bounded simctl and reports fresh booted observa ['iphone-1', 'tv-1'], ); assert.deepEqual(observed, ['iphone-1']); - assert.deepEqual(buildSimctlListArgs(undefined), ['list', 'devices', '-j']); }); test('simulator inventory classifies malformed native output as a command failure', async () => { diff --git a/packages/platform-apple/src/simulator-inventory.ts b/packages/platform-apple/src/simulator-inventory.ts index dcdc03147e..1c2a57730a 100644 --- a/packages/platform-apple/src/simulator-inventory.ts +++ b/packages/platform-apple/src/simulator-inventory.ts @@ -2,11 +2,10 @@ import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; import type { DeviceInventoryHostFor, PlatformRequestScope, - ScopedSimctlArgs, } from '@agent-device/contracts/platform-runtime-host'; import { sortAppleDevicesForSelection, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { scopeSimctlArgs } from './core/simctl.ts'; +import { simctlListDevicesArgs } from './core/simctl.ts'; import { isSupportedAppleRuntime, resolveAppleOs, @@ -27,10 +26,6 @@ type SimctlListDevicesPayload = { const BOOTED_SIMULATOR_PROBE_TIMEOUT_MS = 3_000; -export function buildSimctlListArgs(simulatorSetPath: string | undefined): ScopedSimctlArgs { - return scopeSimctlArgs(['list', 'devices', '-j'], { simulatorSetPath }); -} - export function parseSimctlAppleDevices( payload: SimctlListDevicesPayload, simulatorSetPath: string | undefined, @@ -65,7 +60,7 @@ export async function listAppleSimulators( const result = await host.appleTools.run( { tool: 'simctl', - args: buildSimctlListArgs(simulatorSetPath), + args: simctlListDevicesArgs(simulatorSetPath), ...(request.booted === true ? { timeoutMs: BOOTED_SIMULATOR_PROBE_TIMEOUT_MS } : {}), }, scope.signal, diff --git a/scripts/layering/apple-simulator-scope-policy.test.ts b/scripts/layering/apple-simulator-scope-policy.test.ts index 652633518d..f0f8d6ffe4 100644 --- a/scripts/layering/apple-simulator-scope-policy.test.ts +++ b/scripts/layering/apple-simulator-scope-policy.test.ts @@ -6,10 +6,12 @@ import { } from './apple-simulator-scope-policy.ts'; const APPLE_SRC = 'packages/platform-apple/src/'; -const ARGV_MESSAGE = - /^builds or forges simctl argv outside core\/simctl\.ts; use scopeSimctlArgsForDevice\/runSimctlForDevice or a SimulatorAddress from simulatorAddressFor\(device\)$/; -const SET_SCOPE_MESSAGE = - /^set-scope simctl builder outside its owners; a call that names a udid takes its set from the device \(scopeSimctlArgsForDevice\) or its SimulatorAddress$/; +const SIMCTL_ARGV_MESSAGE = + /^builds a simctl argv outside core\/simctl\.ts and core\/tool-provider\.ts; build it with buildSimctlArgsForDevice or buildSimctlArgsForAddress$/; +const HAND_BUILT_MESSAGE = + /^hands xcrun an argv whose tool is not a literal non-simctl name; build a simctl argv with buildSimctlArgsForDevice or buildSimctlArgsForAddress$/; +const FORGED_MESSAGE = + /^forges a simulator-scope brand outside core\/simctl\.ts and core\/tool-provider\.ts; mint it with simulatorAddressFor, scopeSimctlArgsForDevice or buildSimctlArgsForDevice$/; function violationsFor(file: string, source: string) { return appleSimulatorScopeViolations(new Map([[file, source]])); @@ -23,80 +25,101 @@ function assertFlagged(file: string, source: string, message: RegExp): void { assert.match(violations[0]!.message, message); } -const HAND_BUILT_SPAWN = "runXcrun(['simctl', 'spawn', udid, binary]);\n"; const HAND_BUILT_BRIDGE = "runCmdBackground('xcrun', ['simctl', 'spawn', udid, bridge]);\n"; -const EXPLICIT_DEFAULT_SET = - "scopeSimctlArgs(['spawn', udid, bin], { simulatorSetPath: undefined });\n"; -const ALIASED_SET_SCOPE = "import { scopeSimctlArgs as scope } from '../core/simctl.ts';\n"; const FORGED_ARGV = "resolveAppleToolProvider().simctl.run(['spawn', udid, bin] as unknown as ScopedSimctlArgs);\n"; const FORGED_ADDRESS = 'const address = { udid, simulatorSetPath } as SimulatorAddress;\n'; -const HAND_PREFIX = "const args = ['--set', path, ...args];\n"; -test('the two #2818 hand-built simctl spawns are refused', () => { - assertFlagged(`${APPLE_SRC}foldable/simulator-hid.ts`, HAND_BUILT_SPAWN, ARGV_MESSAGE); - assertFlagged(`${APPLE_SRC}snapshot-source/host.ts`, HAND_BUILT_BRIDGE, ARGV_MESSAGE); +test('the #2818 bridge spawn through a plain xcrun executor is refused', () => { + assertFlagged(`${APPLE_SRC}snapshot-source/host.ts`, HAND_BUILT_BRIDGE, SIMCTL_ARGV_MESSAGE); }); -test('the set-scope builder is refused outside its owners, aliased or not', () => { - assertFlagged(`${APPLE_SRC}foldable/simulator-hid.ts`, EXPLICIT_DEFAULT_SET, SET_SCOPE_MESSAGE); - assertFlagged(`${APPLE_SRC}snapshot-source/host.ts`, ALIASED_SET_SCOPE, SET_SCOPE_MESSAGE); +test('a simctl argv is refused however it reaches an executor', () => { + for (const source of [ + "const argv = ['simctl', 'spawn', udid, bin];\nrunCmd('xcrun', argv);\n", + "const argv = ['simctl', 'spawn', udid, bridge];\nrunCmdBackground('xcrun', argv, { detached: true });\n", + "const argv = ['simctl', 'spawn', udid, 'log', 'stream'];\nhost.commands.run({ executable: 'xcrun', args: argv });\n", + "const argv = ['simctl', 'boot', udid];\nconst alias = argv;\nrunCmd('xcrun', alias);\n", + "const argv = ['simctl', 'boot', udid];\nrunCmd('xcrun', [...argv]);\n", + "const head = ['simctl'];\nrunCmd('xcrun', [...head, 'boot', udid]);\n", + "const tool = 'simctl';\nconst argv = [tool, 'spawn', udid, bin];\nrunCmd('xcrun', argv);\n", + "const tool = `simctl`;\nrunCmdBackground('xcrun', [tool, 'spawn', udid, bin]);\n", + "runCmd('xcrun', [`simctl`, 'boot', udid]);\n", + "host.commands.run({ executable: 'xcrun', args: ['simctl', 'spawn', udid, 'log', 'stream'] });\n", + "const tools = ['simctl', 'devicectl'];\n", + "runCmd('xcrun', ['--sdk', 'iphonesimulator', 'simctl', 'boot', udid]);\n", + "const tool = 'simctl';\nconst argv = ['-v', tool, 'spawn', udid, bin];\nrunCmd('xcrun', argv);\n", + ]) { + assertFlagged(`${APPLE_SRC}logs/start.ts`, source, SIMCTL_ARGV_MESSAGE); + } }); -test('a forged scoped argv or simulator address is refused', () => { - assertFlagged('src/platform-runtime-planted.ts', FORGED_ARGV, ARGV_MESSAGE); - assertFlagged(`${APPLE_SRC}foldable/simulator-hid.ts`, FORGED_ADDRESS, ARGV_MESSAGE); +test('an inline xcrun argv must name its tool as a literal other than simctl', () => { + for (const source of [ + "runCmd('xcrun', [tool, 'spawn', udid, bin]);\n", + "runCmd('xcrun', [`${tool}`, 'boot', udid]);\n", + "host.commands.run({ executable: 'xcrun', args: [request.tool, ...request.args] });\n", + ]) { + assertFlagged(`${APPLE_SRC}logs/start.ts`, source, HAND_BUILT_MESSAGE); + } +}); + +test('the tool provider has no exemption for a hand-built simctl argv', () => { + assertFlagged( + `${APPLE_SRC}core/tool-provider.ts`, + "runCmd('xcrun', ['simctl', ...args]);\n", + HAND_BUILT_MESSAGE, + ); +}); + +test('a cast to a simulator-scope brand is refused outside the mint modules', () => { + assertFlagged('src/platform-runtime-planted.ts', FORGED_ARGV, FORGED_MESSAGE); + assertFlagged( + `${APPLE_SRC}core/simulator.ts`, + 'return argv as ScopedSimctlCommand;\n', + FORGED_MESSAGE, + ); + assertFlagged(`${APPLE_SRC}foldable/simulator-hid.ts`, FORGED_ADDRESS, FORGED_MESSAGE); assertFlagged( `${APPLE_SRC}foldable/simulator-hid.ts`, 'const address = { udid, simulatorSetPath };\n', - ARGV_MESSAGE, + FORGED_MESSAGE, ); }); -test('a hand-rolled --set prefix is refused outside core/simctl.ts', () => { - assertFlagged(`${APPLE_SRC}foldable/simulator-hid.ts`, HAND_PREFIX, ARGV_MESSAGE); +test('the mint modules may build and cast to the brands they mint', () => { + const mintSource = + FORGED_ARGV + + FORGED_ADDRESS + + "return Object.freeze(['simctl', ...args] as const) as ScopedSimctlCommand;\n"; + for (const mint of [`${APPLE_SRC}core/simctl.ts`, `${APPLE_SRC}core/tool-provider.ts`]) { + assert.deepEqual(violationsFor(mint, mintSource), [], mint); + } }); -test('the argv owners may build, prefix and mint', () => { - const owner = `${APPLE_SRC}core/simctl.ts`; - for (const source of [ - HAND_BUILT_SPAWN, - HAND_BUILT_BRIDGE, - EXPLICIT_DEFAULT_SET, - ALIASED_SET_SCOPE, - FORGED_ARGV, - FORGED_ADDRESS, - HAND_PREFIX, - ]) { - assert.deepEqual(violationsFor(owner, source), [], source); - } +test('named tools, builder output, pass-through argv and simctl text elsewhere are not violations', () => { assert.deepEqual( violationsFor( `${APPLE_SRC}core/tool-provider.ts`, - "provider.simctl.run(toolArgs as unknown as ScopedSimctlArgs, options);\nrunCmd('xcrun', ['simctl', ...args]);\n", + [ + "runCmd('xcrun', ['devicectl', ...args]);", + "runCmd('xcrun', ['--find', name]);", + "runCmd('xcrun', ['--sdk', sdkName, '--show-sdk-version']);", + "runCmd('xcrun', simctlCommand(args));", + "runCmd('xcrun', [...argv]);", + "runCmdBackground('xcrun', args);", + "runCmdBackground('xcrun', buildSimctlArgsForAddress(simulator, ['spawn', simulator.udid]));", + "host.commands.run({ executable: 'log', args: ['stream'] });", + "host.appleTools.run({ tool: 'simctl', args: scopeSimctlArgsForDevice(device, ['boot', id]) });", + "const tools = ['devicectl', 'simctl'];", + "const tool = 'simctl';", + "if (args.includes('--set') || tool === 'simctl') note(tool);", + ].join('\n'), ), [], ); }); -test('the tool provider may not forge an address or a --set prefix', () => { - assertFlagged(`${APPLE_SRC}core/tool-provider.ts`, FORGED_ADDRESS, ARGV_MESSAGE); - assertFlagged(`${APPLE_SRC}core/tool-provider.ts`, HAND_PREFIX, ARGV_MESSAGE); -}); - -test('the calls that name no device may take set scope', () => { - for (const file of [`${APPLE_SRC}simulator-inventory.ts`, `${APPLE_SRC}logs/doctor.ts`]) { - assert.deepEqual( - violationsFor( - file, - "import { scopeSimctlArgs } from './core/simctl.ts';\nscopeSimctlArgs(['help'], { simulatorSetPath: undefined });\n", - ), - [], - file, - ); - } -}); - test('tests, fixtures and scripts are not policed', () => { for (const file of [ `${APPLE_SRC}foldable/simulator-hid.test.ts`, @@ -106,38 +129,16 @@ test('tests, fixtures and scripts are not policed', () => { 'test/integration/provider-scenarios/providers.ts', ]) { assert.equal(isPolicedSimulatorScopeFile(file), false, file); - assert.deepEqual(violationsFor(file, HAND_BUILT_SPAWN + EXPLICIT_DEFAULT_SET), [], file); + assert.deepEqual(violationsFor(file, HAND_BUILT_BRIDGE + FORGED_ADDRESS), [], file); } assert.equal(isPolicedSimulatorScopeFile(`${APPLE_SRC}foldable/simulator-hid.ts`), true); assert.equal(isPolicedSimulatorScopeFile('src/platform-runtime-apple-tool-host.ts'), true); }); -test('devicectl argv and scoped device calls are not simctl violations', () => { - assert.deepEqual( - violationsFor( - `${APPLE_SRC}deployment/runtime.ts`, - [ - "host.appleTools.run({ tool: 'devicectl', args: ['device', 'install', 'app', '--device', id] });", - "host.appleTools.run({ tool: 'simctl', args: scopeSimctlArgsForDevice(device, ['boot', device.id]) });", - "runXcrun(buildSimctlArgsForAddress(simulatorAddressFor(device), ['spawn', device.id]));", - ].join('\n'), - ), - [], - ); -}); - -test('an unaliased import of the set-scope builder is one violation', () => { - assertFlagged( - `${APPLE_SRC}snapshot-source/host.ts`, - "import { scopeSimctlArgs } from '../core/simctl.ts';\n", - SET_SCOPE_MESSAGE, - ); -}); - -test('a violation reports the line of the offending node', () => { +test('a violation reports the line of the offending argv', () => { const [violation] = violationsFor( - `${APPLE_SRC}foldable/simulator-hid.ts`, - `const a = 1;\n\n${HAND_BUILT_SPAWN}`, + `${APPLE_SRC}snapshot-source/host.ts`, + `const a = 1;\n\n${HAND_BUILT_BRIDGE}`, ); assert.equal(violation!.line, 3); }); diff --git a/scripts/layering/apple-simulator-scope-policy.ts b/scripts/layering/apple-simulator-scope-policy.ts index b83f4dd67a..3ac7446298 100644 --- a/scripts/layering/apple-simulator-scope-policy.ts +++ b/scripts/layering/apple-simulator-scope-policy.ts @@ -1,46 +1,47 @@ -// Catches: a simctl argv that addresses a simulator without the set that holds it -- built by hand -// (`['simctl', 'spawn', udid, ...]` into runXcrun, runCmdBackground or an `executable: 'xcrun'` -// spec), prefixed by hand (`'--set'`), forged through a cast to `ScopedSimctlArgs` or -// `SimulatorAddress`, or scoped through the set-scope builder with the set written out as -// `undefined`. Every form type-checks where the brand does not reach (plain argv executors) or -// where the set-scope builder accepts an explicit `undefined`, and every form runs against the -// default CoreSimulator set: `Invalid device` for a simulator in a scoped set, or a different -// simulator with the same udid. +// Catches: the simctl scope bypasses `tsc` cannot reject. `runXcrun` accepts only a +// `ScopedSimctlCommand` or a named non-simctl tool, but the plain executors (`runCmd`, +// `runCmdBackground`, an `executable: 'xcrun'` spec) take any string argv, so every simctl argv +// reaching them must be builder output. (1) An array whose tool names simctl (the literal, a +// quasi-only template, or a same-file binding of either; the first element, or any element +// after leading xcrun options such as `--sdk`) outside the brand mints, however it later +// travels: inline, held in a variable, aliased or spread. (2) At an xcrun +// invocation, an inline argv whose tool is not a string literal other than `simctl`. (3) A cast +// to `ScopedSimctlArgs`, `ScopedSimctlCommand` or `SimulatorAddress` outside the brand mints. +// Every form runs against the default CoreSimulator set: `Invalid device` for a simulator in a +// scoped set, or a different simulator with the same udid. // Evidence: #2784 (fixed by #2818): the AX snapshot bridge (`snapshot-source/host.ts`) and the // fold HID helper (`foldable/simulator-hid.ts`) built `['simctl', 'spawn', udid, ...]` from a // bare udid and lost the set; #2824 moved every call site onto `core/simctl.ts` and checked it -// with a manual `git grep "'--set'"`, which this rule turns into a gate. -// Cost: 271 LOC (128 rule + 143 test). +// with a manual `git grep "'--set'"`. +// Cost: 311 LOC (167 rule + 144 test). // Kill criterion: none enforced today; retire only by maintainer decision that scoped simulator -// sets (`--ios-simulator-device-set`) are no longer supported, or when every simctl executor -// takes an argv type that only `core/simctl.ts` can mint. +// sets (`--ios-simulator-device-set`) are no longer supported, or when no production xcrun +// executor takes a plain string argv. import { parseSync } from 'oxc-parser'; -import { visitAst } from './layering-ast.ts'; +import { propertyName, visitAst } from './layering-ast.ts'; import type { LayeringViolation } from './model.ts'; type AstNode = Record; const RULE = 'R79 apple-simulator-scope'; -const APPLE_SRC = 'packages/platform-apple/src/'; -const SIMCTL_OWNER = `${APPLE_SRC}core/simctl.ts`; -const TOOL_PROVIDER = `${APPLE_SRC}core/tool-provider.ts`; -const ARGV_OWNERS = new Set([SIMCTL_OWNER, TOOL_PROVIDER]); -/** The owners whose simctl calls name no device, so they take set scope. */ -const SET_SCOPE_OWNERS = new Set([ - SIMCTL_OWNER, - `${APPLE_SRC}simulator-inventory.ts`, - `${APPLE_SRC}logs/doctor.ts`, +/** The modules that mint the simulator-scope brands; a cast to a brand anywhere else forges it. */ +const BRAND_MINTS = new Set([ + 'packages/platform-apple/src/core/simctl.ts', + 'packages/platform-apple/src/core/tool-provider.ts', ]); -const SET_SCOPE_BUILDER = 'scopeSimctlArgs'; +const BRANDS = new Set(['ScopedSimctlArgs', 'ScopedSimctlCommand', 'SimulatorAddress']); -const ARGV_MESSAGE = - 'builds or forges simctl argv outside core/simctl.ts; use scopeSimctlArgsForDevice/runSimctlForDevice ' + - 'or a SimulatorAddress from simulatorAddressFor(device)'; -const SET_SCOPE_MESSAGE = - 'set-scope simctl builder outside its owners; a call that names a udid takes its set from the ' + - 'device (scopeSimctlArgsForDevice) or its SimulatorAddress'; +const SIMCTL_ARGV_MESSAGE = + 'builds a simctl argv outside core/simctl.ts and core/tool-provider.ts; build it with ' + + 'buildSimctlArgsForDevice or buildSimctlArgsForAddress'; +const HAND_BUILT_MESSAGE = + 'hands xcrun an argv whose tool is not a literal non-simctl name; build a simctl argv with ' + + 'buildSimctlArgsForDevice or buildSimctlArgsForAddress'; +const FORGED_MESSAGE = + 'forges a simulator-scope brand outside core/simctl.ts and core/tool-provider.ts; mint it ' + + 'with simulatorAddressFor, scopeSimctlArgsForDevice or buildSimctlArgsForDevice'; /** Production TypeScript under `packages/*\/src/` and `src/`; tests and fixtures are exempt. */ export function isPolicedSimulatorScopeFile(file: string): boolean { @@ -58,71 +59,109 @@ export function appleSimulatorScopeViolations( const violations: LayeringViolation[] = []; for (const [file, source] of sources) { if (!isPolicedSimulatorScopeFile(file)) continue; - const reported = new Set(); - const report = (node: AstNode, message: string) => { - const offset = Number(node.start ?? 0); - if (reported.has(`${offset}:${message}`)) return; - reported.add(`${offset}:${message}`); - violations.push({ rule: RULE, file, line: lineAt(source, offset), message }); + const program = parseSync(file, source).program; + const simctlBindings = simctlNameBindings(program); + const isMint = BRAND_MINTS.has(file); + const isSimctl = (element: AstNode | null) => + namesSimctl(element ?? undefined) || + (element?.type === 'Identifier' && simctlBindings.has(element.name)); + const buildsSimctlArgv = (array: AstNode) => { + if (isMint) return false; + const elements = array.elements as (AstNode | null)[]; + const [tool] = elements; + return isSimctl(tool ?? null) || (isXcrunOption(tool) && elements.some(isSimctl)); }; - visitAst(parseSync(file, source).program, (node) => { - if ( - !ARGV_OWNERS.has(file) && - node.type === 'ArrayExpression' && - isStringLiteral((node.elements as unknown[])[0], 'simctl') - ) { - report(node, ARGV_MESSAGE); - } - if (file.startsWith(APPLE_SRC) && file !== SIMCTL_OWNER && isStringLiteral(node, '--set')) { - report(node, ARGV_MESSAGE); - } - if ( - (node.type === 'TSAsExpression' || node.type === 'TSTypeAssertion') && - forgesBrand(file, node.typeAnnotation) - ) { - report(node, ARGV_MESSAGE); + const report = (node: AstNode, message: string) => + violations.push({ rule: RULE, file, line: lineAt(source, node.start), message }); + visitAst(program, (node) => { + if (node.type === 'ArrayExpression' && buildsSimctlArgv(node)) { + report(node, SIMCTL_ARGV_MESSAGE); } + const argv = xcrunArgv(node); if ( - !SET_SCOPE_OWNERS.has(file) && - node.type === 'Identifier' && - node.name === SET_SCOPE_BUILDER + argv?.type === 'ArrayExpression' && + !buildsSimctlArgv(argv) && + !namesNonSimctlTool(argv) ) { - report(node, SET_SCOPE_MESSAGE); + report(argv, HAND_BUILT_MESSAGE); } + if (!isMint && castsToBrand(node)) report(node, FORGED_MESSAGE); }); } return violations; } -function forgesBrand(file: string, typeAnnotation: unknown): boolean { - const names = referencedTypeNames(typeAnnotation); - return ( - (file !== SIMCTL_OWNER && names.has('SimulatorAddress')) || - (!ARGV_OWNERS.has(file) && names.has('ScopedSimctlArgs')) - ); +/** The argv of `f('xcrun', argv, …)` or of an `{ executable: 'xcrun', args }` spec. */ +function xcrunArgv(node: AstNode): AstNode | undefined { + if (node.type === 'CallExpression') { + const args = node.arguments as AstNode[]; + const index = args.findIndex((arg) => isLiteral(arg, 'xcrun')); + return index >= 0 ? args[index + 1] : undefined; + } + if (node.type !== 'ObjectExpression') return undefined; + const properties = node.properties as AstNode[]; + const valueOf = (key: string) => + properties.find((property) => propertyName(property.key) === key)?.value as AstNode | undefined; + return isLiteral(valueOf('executable'), 'xcrun') ? valueOf('args') : undefined; +} + +/** A spread passes an argv through like a variable; any other first element must name the tool. */ +function namesNonSimctlTool(argv: AstNode): boolean { + const tool = firstElement(argv); + if (tool?.type === 'SpreadElement') return true; + return tool?.type === 'Literal' && typeof tool.value === 'string' && tool.value !== 'simctl'; } -function referencedTypeNames(typeAnnotation: unknown): Set { - const names = new Set(); - visitAst(typeAnnotation, (node) => { - if (node.type !== 'TSTypeReference') return; - const typeName = node.typeName as AstNode | undefined; +/** A leading `-` literal is an xcrun option (`--sdk`, `--find`), so the tool name comes later. */ +function isXcrunOption(node: AstNode | null | undefined): boolean { + return node?.type === 'Literal' && typeof node.value === 'string' && node.value.startsWith('-'); +} + +function firstElement(array: AstNode): AstNode | undefined { + return (array.elements as (AstNode | null)[])[0] ?? undefined; +} + +/** `'simctl'` or `` `simctl` ``, the two spellings of the tool name as a constant. */ +function namesSimctl(node: AstNode | undefined): boolean { + if (isLiteral(node, 'simctl')) return true; + if (node?.type !== 'TemplateLiteral' || (node.expressions as unknown[]).length > 0) return false; + const [quasi] = node.quasis as AstNode[]; + return (quasi?.value as { cooked?: string } | undefined)?.cooked === 'simctl'; +} + +/** Names bound anywhere in the file to a constant spelling of `simctl`, whatever their scope. */ +function simctlNameBindings(program: unknown): Set { + const bindings = new Set(); + visitAst(program, (node) => { + const id = node.id as AstNode | undefined; + if ( + node.type === 'VariableDeclarator' && + id?.type === 'Identifier' && + namesSimctl(node.init as AstNode) + ) { + bindings.add(id.name); + } + }); + return bindings; +} + +function castsToBrand(node: AstNode): boolean { + if (node.type !== 'TSAsExpression' && node.type !== 'TSTypeAssertion') return false; + let names = false; + visitAst(node.typeAnnotation, (typeNode) => { + if (typeNode.type !== 'TSTypeReference') return; + const typeName = typeNode.typeName as AstNode; const name = - typeName?.type === 'TSQualifiedName' ? (typeName.right as AstNode).name : typeName?.name; - if (typeof name === 'string') names.add(name); + typeName.type === 'TSQualifiedName' ? (typeName.right as AstNode).name : typeName.name; + names ||= BRANDS.has(name as string); }); return names; } -function isStringLiteral(node: unknown, value: string): boolean { - return ( - node !== null && - typeof node === 'object' && - (node as AstNode).type === 'Literal' && - (node as AstNode).value === value - ); +function isLiteral(node: AstNode | undefined, value: string): boolean { + return node?.type === 'Literal' && node.value === value; } -function lineAt(source: string, offset: number): number { - return source.slice(0, offset).split('\n').length; +function lineAt(source: string, offset: unknown): number { + return source.slice(0, Number(offset ?? 0)).split('\n').length; } diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index bf09d3b43a..b3512b504f 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -40,9 +40,11 @@ // directly (R77) — the subtree sits in the eager closure of seven Apple façade entries the // eager-closure-budgets gate holds at a fixed size, so a direct host-kit edge grows all seven; // host-kit reaches the runner only through `runner/host.ts`, bound in `core/runner-host.ts`. -// - Over SIMCTL ARGV in production source: only `core/simctl.ts` (and the provider executors in -// `core/tool-provider.ts`) build, prefix or mint scoped simctl argv, and only the calls that -// name no device take set scope (R79), so a udid never runs outside the set that holds it. +// - Over SIMCTL ARGV in production source: `tsc` holds `runXcrun` and the Apple tool port to +// branded simctl argv. The plain executors take any string argv, so R79 refuses every array +// that names simctl first outside `core/simctl.ts` and `core/tool-provider.ts`, however it +// reaches an executor, holds an inline xcrun argv to a literal non-simctl tool name, and keeps +// brand casts inside those two modules, so a udid never runs outside the set that holds it. // - Over REQUEST-BOUND RUNTIME EXECUTION: facts remain the only admission authority and daemon // code cannot manufacture or repair a narrowed runtime proof (R66). // - Over CONTRACTS PRODUCTION SOURCE: contracts owns vocabulary only — host, process, and timer diff --git a/src/daemon/__tests__/request-platform-providers.test.ts b/src/daemon/__tests__/request-platform-providers.test.ts index 693a2b7acb..61cafbc5b4 100644 --- a/src/daemon/__tests__/request-platform-providers.test.ts +++ b/src/daemon/__tests__/request-platform-providers.test.ts @@ -14,6 +14,7 @@ import { } from '../../__tests__/test-utils/session-factories.ts'; import { withTestDeviceInventoryProvider as withTargetDeviceResolutionScope } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { createLocalAppleToolProvider, runXcrun } from '@agent-device/platform-apple/tool-provider'; +import { buildSimctlArgsForDevice } from '@agent-device/platform-apple/simctl'; import type { AndroidAdbExecutor } from '@agent-device/platform-android/mechanics'; import { resolveWebProvider, type WebProvider } from '@agent-device/platform-web'; import { @@ -67,7 +68,7 @@ test('request platform provider scope applies Apple tool provider only for Apple }, }, }, - async () => await runXcrun(['simctl', 'list', 'devices', '-j']), + async () => await runXcrun(buildSimctlArgsForDevice(IOS_SIMULATOR, ['list', 'devices', '-j'])), ); assert.equal(result.stdout, 'simctl-ok'); @@ -104,7 +105,10 @@ test('request platform provider scope follows explicit apps selector for existin }, }, }, - async () => await runXcrun(['simctl', 'listapps', OTHER_IOS_SIMULATOR.id]), + async () => + await runXcrun( + buildSimctlArgsForDevice(OTHER_IOS_SIMULATOR, ['listapps', OTHER_IOS_SIMULATOR.id]), + ), ), ); @@ -207,7 +211,8 @@ test('request platform provider scopes stay isolated across concurrent requests' }), }, }, - async () => (await runXcrun(['simctl', 'list', 'devices', '-j'])).stdout, + async () => + (await runXcrun(buildSimctlArgsForDevice(IOS_SIMULATOR, ['list', 'devices', '-j']))).stdout, ); assert.deepEqual(await Promise.all([androidTask, appleTask]), ['android-ok', 'apple-ok']); diff --git a/src/platform-runtime-apple-tool-host.test.ts b/src/platform-runtime-apple-tool-host.test.ts index 0f59de4daa..39daddf034 100644 --- a/src/platform-runtime-apple-tool-host.test.ts +++ b/src/platform-runtime-apple-tool-host.test.ts @@ -4,6 +4,7 @@ import { type AppleToolProvider, withAppleToolProvider, } from '@agent-device/platform-apple/tool-provider'; +import type { ScopedSimctlArgs } from '@agent-device/contracts/platform-runtime-host'; import { createAppleToolHost } from './platform-runtime-apple-tool-host.ts'; test('Apple tool host uses a full scoped provider when local xcrun is unavailable', async () => { @@ -42,6 +43,26 @@ test('Apple tool host uses a full scoped provider when local xcrun is unavailabl }); }); +test('Apple tool host hands a simctl request to the simctl provider with its scoped args intact', async () => { + const run = vi.fn(async () => ({ stdout: 'booted', stderr: '', exitCode: 0 })); + const runCommand = vi.fn(); + const provider = createLocalAppleToolProvider({ runCommand, simctl: { run } }); + const args = Object.freeze(['--set', '/tmp/set', 'boot', 'sim-1']) as ScopedSimctlArgs; + + await withAppleToolProvider(provider, async () => { + await expect( + createAppleToolHost().run({ tool: 'simctl', args, allowFailure: true }, undefined), + ).resolves.toEqual({ stdout: 'booted', stderr: '', exitCode: 0 }); + }); + + expect(run).toHaveBeenCalledWith(['--set', '/tmp/set', 'boot', 'sim-1'], { + allowFailure: true, + signal: undefined, + timeoutMs: undefined, + }); + expect(runCommand).not.toHaveBeenCalled(); +}); + test('Apple tool host rejects pre-aborted requests before invoking the provider', async () => { const reason = new Error('cancelled'); const controller = new AbortController(); diff --git a/src/platform-runtime-apple-tool-host.ts b/src/platform-runtime-apple-tool-host.ts index 5d013ef4b9..8acdaebd3c 100644 --- a/src/platform-runtime-apple-tool-host.ts +++ b/src/platform-runtime-apple-tool-host.ts @@ -15,17 +15,16 @@ export function createAppleToolHost(): AppleToolHost { return available; }, run: async (request, signal) => { - const { runXcrun } = await awaitPreservingAbortReason( + const { resolveAppleToolProvider, runXcrun } = await awaitPreservingAbortReason( async () => await import('@agent-device/platform-apple/tool-provider'), signal, ); + const options = { allowFailure: request.allowFailure, signal, timeoutMs: request.timeoutMs }; const result = await awaitPreservingAbortReason( async () => - await runXcrun([request.tool, ...request.args], { - allowFailure: request.allowFailure, - signal, - timeoutMs: request.timeoutMs, - }), + request.tool === 'simctl' + ? await resolveAppleToolProvider().simctl.run(request.args, options) + : await runXcrun([request.tool, ...request.args], options), signal, ); return { diff --git a/src/platform-runtime-device-inventory.test.ts b/src/platform-runtime-device-inventory.test.ts index 95404b0e7c..f096122288 100644 --- a/src/platform-runtime-device-inventory.test.ts +++ b/src/platform-runtime-device-inventory.test.ts @@ -22,7 +22,10 @@ const simctl = vi.hoisted(() => ({ calls: [] as string[], })); -vi.mock('../packages/platform-apple/src/core/tool-provider.ts', () => ({ +vi.mock('../packages/platform-apple/src/core/tool-provider.ts', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('../packages/platform-apple/src/core/tool-provider.ts') + >()), runXcrun: async (args: string[]) => { const idx = args.indexOf('listapps'); const deviceId = idx >= 0 ? args[idx + 1] : undefined;