diff --git a/packages/contracts/src/network-runtime.test.ts b/packages/contracts/src/network-runtime.test.ts index f7f020a194..afd7e9a53c 100644 --- a/packages/contracts/src/network-runtime.test.ts +++ b/packages/contracts/src/network-runtime.test.ts @@ -21,6 +21,7 @@ function compileTimeNetworkProjectionProof( void compileTimeNetworkProjectionProof; function compileTimeCanonicalHostProof(host: NetworkRuntimeHost): void { + // @ts-expect-error Raw simctl argv cannot cross the Apple tool port; scope it in platform-apple. void host.appleTools.run({ tool: 'simctl', args: ['spawn', 'sim-1', 'log', 'show'] }); void host.appleTools.run({ tool: 'simctl', diff --git a/packages/contracts/src/platform-runtime-host.ts b/packages/contracts/src/platform-runtime-host.ts index ea81f0a762..e5f9450b64 100644 --- a/packages/contracts/src/platform-runtime-host.ts +++ b/packages/contracts/src/platform-runtime-host.ts @@ -55,12 +55,15 @@ export type HostCommandRunner = Readonly<{ export type AppleXcrunTool = 'simctl' | 'devicectl' | 'xctrace'; -export type AppleToolRequest = Readonly<{ - tool: AppleXcrunTool; - args: readonly string[]; - timeoutMs?: number; - allowFailure?: boolean; -}>; +declare const scopedSimctlArgs: unique symbol; +/** simctl argv (after the tool name) already scoped to its simulator set; minted only by platform-apple. */ +export type ScopedSimctlArgs = readonly string[] & { readonly [scopedSimctlArgs]: true }; + +export type AppleToolRequest = Readonly<{ timeoutMs?: number; allowFailure?: boolean }> & + ( + | Readonly<{ tool: 'simctl'; args: ScopedSimctlArgs }> + | Readonly<{ tool: Exclude; args: readonly string[] }> + ); /** Request-bound foreground Apple tooling backed by the selected scoped provider. */ export type AppleToolHost = Readonly<{ diff --git a/packages/platform-apple/src/core/__tests__/app-launch-device-open.test.ts b/packages/platform-apple/src/core/__tests__/app-launch-device-open.test.ts index 3af9ddbc09..de2e6dac11 100644 --- a/packages/platform-apple/src/core/__tests__/app-launch-device-open.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-launch-device-open.test.ts @@ -64,7 +64,7 @@ test('openIosDevice leaves the macOS desktop target alone', async () => { const provider = createLocalAppleToolProvider({ simctl: { run: async (args) => { - simctlCalls.push(args); + simctlCalls.push([...args]); return { exitCode: 0, stdout: '', stderr: '' }; }, }, diff --git a/packages/platform-apple/src/core/__tests__/screenshot-density.test.ts b/packages/platform-apple/src/core/__tests__/screenshot-density.test.ts index ccda003702..b1b8dfdd1a 100644 --- a/packages/platform-apple/src/core/__tests__/screenshot-density.test.ts +++ b/packages/platform-apple/src/core/__tests__/screenshot-density.test.ts @@ -8,7 +8,7 @@ import { screenshotIos } from '../screenshot.ts'; import { createLocalAppleToolProvider, withAppleToolProvider } from '../tool-provider.ts'; import { mkdtempForTest } from '../../__tests__/tmp-dir.ts'; -function startsWithArgs(args: string[], expected: string[]): boolean { +function startsWithArgs(args: readonly string[], expected: string[]): boolean { return expected.every((value, index) => args[index] === value); } @@ -29,7 +29,7 @@ test('screenshotIos caches simulator screen scale per device', async () => { const provider = createLocalAppleToolProvider({ simctl: { run: async (args) => { - calls.push(args); + calls.push([...args]); if (startsWithArgs(args, ['getenv', device.id, 'SIMULATOR_MAINSCREEN_SCALE'])) { return { exitCode: 0, stdout: '3\n', stderr: '' }; } diff --git a/packages/platform-apple/src/core/__tests__/simctl.test.ts b/packages/platform-apple/src/core/__tests__/simctl.test.ts index 1845a402c0..d24f1ce197 100644 --- a/packages/platform-apple/src/core/__tests__/simctl.test.ts +++ b/packages/platform-apple/src/core/__tests__/simctl.test.ts @@ -1,12 +1,14 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { - buildSimctlArgs, + buildSimctlArgsForAddress, buildSimctlArgsForDevice, readSimctlDevicesByRuntime, readSimctlDeviceState, scopeSimctlArgs, scopeSimctlArgsForDevice, + simulatorAddressFor, + type SimulatorAddress, } from '../simctl.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -18,10 +20,11 @@ const IOS_SIMULATOR: DeviceInfo = { target: 'mobile', }; -test('buildSimctlArgs uses --set when simulator set path is provided', () => { - const args = buildSimctlArgs(['list', 'devices', '-j'], { - simulatorSetPath: '/tmp/tenant-a/simulator-set', - }); +test('buildSimctlArgsForAddress uses --set when the address names a simulator set', () => { + const args = buildSimctlArgsForAddress( + simulatorAddressFor({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/simulator-set' }), + ['list', 'devices', '-j'], + ); assert.deepEqual(args, [ 'simctl', '--set', @@ -85,6 +88,31 @@ test('scopeSimctlArgsForDevice scopes simulators only', () => { ]); }); +test('simulatorAddressFor carries the set of iOS-family simulators only', () => { + const scoped = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-d/simulator-set' }; + assert.deepEqual(simulatorAddressFor(scoped), { + udid: 'sim-1', + simulatorSetPath: '/tmp/tenant-d/simulator-set', + }); + assert.deepEqual(simulatorAddressFor({ ...scoped, kind: 'device' }), { + udid: 'sim-1', + simulatorSetPath: undefined, + }); + assert.deepEqual(simulatorAddressFor({ ...scoped, platform: 'android', kind: 'emulator' }), { + udid: 'sim-1', + simulatorSetPath: undefined, + }); +}); + +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; +} +void compileTimeSimulatorScopeProof; + const LISTING = JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ 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 70e21fa415..43584b19f9 100644 --- a/packages/platform-apple/src/core/__tests__/tool-provider.test.ts +++ b/packages/platform-apple/src/core/__tests__/tool-provider.test.ts @@ -3,11 +3,18 @@ import { test } from 'vitest'; import { createLocalAppleToolProvider, readApplePlistJson, + resolveAppleToolProvider, runAppleToolCommand, runXcrun, withAppleToolProvider, } from '../tool-provider.ts'; +function compileTimeScopedSimctlProof(): 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']); +} +void compileTimeScopedSimctlProof; + test('scoped Apple tool provider handles xcrun execution', async () => { const calls: Array<[string, string[]]> = []; const provider = createLocalAppleToolProvider({ @@ -35,7 +42,7 @@ test('scoped Apple tool provider prefers semantic simctl and devicectl hooks', a }, simctl: { run: async (args) => { - calls.push(['simctl', args]); + calls.push(['simctl', [...args]]); return { exitCode: 0, stdout: 'simctl', stderr: '' }; }, }, diff --git a/packages/platform-apple/src/core/simctl.ts b/packages/platform-apple/src/core/simctl.ts index b47011bcdd..eddb2d7933 100644 --- a/packages/platform-apple/src/core/simctl.ts +++ b/packages/platform-apple/src/core/simctl.ts @@ -1,33 +1,55 @@ 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'; -type SimctlArgsOptions = { - simulatorSetPath?: string; +/** The set of a simctl call that names no device; `undefined` names the default set on purpose. */ +export type SimulatorSetScope = Readonly<{ simulatorSetPath: string | undefined }>; + +declare const simulatorAddress: unique symbol; +/** A simulator udid with the set that holds it; minted only from a DeviceInfo. */ +export type SimulatorAddress = Readonly<{ udid: string; simulatorSetPath: string | undefined }> & { + readonly [simulatorAddress]: true; }; -/** Arguments that follow the `simctl` tool name, scoped to the simulator set when one is given. */ +export function simulatorAddressFor(device: DeviceInfo): SimulatorAddress { + const simulatorSetPath = + isIosFamily(device) && device.kind === 'simulator' ? device.simulatorSetPath : undefined; + 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( args: readonly string[], - options: SimctlArgsOptions = {}, -): string[] { - const simulatorSetPath = resolveIosSimulatorDeviceSetPath(options.simulatorSetPath); - if (!simulatorSetPath) return [...args]; - return ['--set', simulatorSetPath, ...args]; + scope: SimulatorSetScope, +): ScopedSimctlArgs { + const simulatorSetPath = resolveIosSimulatorDeviceSetPath(scope.simulatorSetPath); + const scoped = simulatorSetPath ? ['--set', simulatorSetPath, ...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 }); } /** Arguments that follow the `simctl` tool name, scoped to the simulator set holding the device. */ -export function scopeSimctlArgsForDevice(device: DeviceInfo, args: readonly string[]): string[] { - if (!isIosFamily(device) || device.kind !== 'simulator') return [...args]; - return scopeSimctlArgs(args, { simulatorSetPath: device.simulatorSetPath }); +export function scopeSimctlArgsForDevice( + device: DeviceInfo, + args: readonly string[], +): ScopedSimctlArgs { + return scopeSimctlArgsForAddress(simulatorAddressFor(device), args); } -export function buildSimctlArgs( +export function buildSimctlArgsForAddress( + address: SimulatorAddress, args: readonly string[], - options: SimctlArgsOptions = {}, ): string[] { - return ['simctl', ...scopeSimctlArgs(args, options)]; + return ['simctl', ...scopeSimctlArgsForAddress(address, args)]; } export function buildSimctlArgsForDevice(device: DeviceInfo, args: readonly string[]): string[] { diff --git a/packages/platform-apple/src/core/tool-provider-types.ts b/packages/platform-apple/src/core/tool-provider-types.ts index 51892efa5e..c7458d3edc 100644 --- a/packages/platform-apple/src/core/tool-provider-types.ts +++ b/packages/platform-apple/src/core/tool-provider-types.ts @@ -1,4 +1,5 @@ import type { AppsFilter } from '@agent-device/contracts/device'; +import type { ScopedSimctlArgs } from '@agent-device/contracts/platform-runtime-host'; import { type ExecOptions, type ExecResult } from '@agent-device/host-kit/command'; import type { IosAppInfo } from './app-info.ts'; @@ -19,6 +20,10 @@ export type AppleXcrunToolProvider = { run: AppleToolSubcommandExecutor; }; +export type AppleSimctlToolProvider = { + run: (args: ScopedSimctlArgs, options?: ExecOptions) => Promise; +}; + export type AppleMacOsHelperProvider = { run: AppleToolSubcommandExecutor; }; diff --git a/packages/platform-apple/src/core/tool-provider.ts b/packages/platform-apple/src/core/tool-provider.ts index 02d3190073..1850f4abef 100644 --- a/packages/platform-apple/src/core/tool-provider.ts +++ b/packages/platform-apple/src/core/tool-provider.ts @@ -5,15 +5,16 @@ import { type ExecOptions, type ExecResult, } from '@agent-device/host-kit/command'; +import type { ScopedSimctlArgs } from '@agent-device/contracts/platform-runtime-host'; import { createScopedProvider } from '@agent-device/kernel/scoped-provider'; import { createLocalAppleMacOsHostProvider } from '../os/macos/host-provider.ts'; import type { AppleMacOsHelperProvider, AppleMacOsHostProvider, ApplePlistProvider, + AppleSimctlToolProvider, AppleToolAvailabilityChecker, AppleToolCommandExecutor, - AppleToolSubcommandExecutor, AppleXcrunToolProvider, } from './tool-provider-types.ts'; @@ -29,7 +30,7 @@ export type { export type AppleToolProvider = { runCommand: AppleToolCommandExecutor; - simctl: AppleXcrunToolProvider; + simctl: AppleSimctlToolProvider; devicectl: AppleXcrunToolProvider; macosHelper?: AppleMacOsHelperProvider; macosHost?: AppleMacOsHostProvider; @@ -118,7 +119,7 @@ export async function runXcrun(args: string[], options?: ExecOptions): Promise coerceExecResult(await run(cmd, args, options)); } -function coerceRun(run: AppleToolSubcommandExecutor): AppleToolSubcommandExecutor { +function coerceRun( + run: (args: Args, options?: ExecOptions) => Promise, +): (args: Args, options?: ExecOptions) => Promise { return async (args, options) => coerceExecResult(await run(args, options)); } diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index 65f9d27973..22dd945e4a 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -228,7 +228,7 @@ async function pushAppleNotification( */ async function runAppleTool( host: PlatformRuntimeHost, - request: Omit, + request: AppleToolRequest, signal: AbortSignal, message: string, options?: Readonly<{ tolerate?: (result: HostCommandResult) => boolean }>, diff --git a/packages/platform-apple/src/logs/doctor.test.ts b/packages/platform-apple/src/logs/doctor.test.ts index e07a0ebf8d..f9747bc00b 100644 --- a/packages/platform-apple/src/logs/doctor.test.ts +++ b/packages/platform-apple/src/logs/doctor.test.ts @@ -21,6 +21,21 @@ 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 }); + + await doctorAppleAppLogs( + fixture.host, + appleDevice({ simulatorSetPath: '/tmp/scoped-set' }), + 'com.example.app', + ); + expect(appleToolRun).toHaveBeenCalledWith( + { tool: 'simctl', args: ['help'], allowFailure: true }, + undefined, + ); +}); + test('CoreDevice doctor routes version discovery through appleTools', async () => { const appleToolRun = vi.fn(async (request) => ({ stdout: request.args.includes('--help') diff --git a/packages/platform-apple/src/logs/doctor.ts b/packages/platform-apple/src/logs/doctor.ts index e163e607e8..2414566351 100644 --- a/packages/platform-apple/src/logs/doctor.ts +++ b/packages/platform-apple/src/logs/doctor.ts @@ -1,6 +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 { APPLE_XCTEST_LOGS_HINT, backendForAppleDevice } from './backend.ts'; import { checkCoreDeviceConsoleCaptureSupport, @@ -27,7 +28,7 @@ export async function doctorAppleAppLogs( await host.appleTools.run( { tool: 'simctl', - args: ['help'], + args: scopeSimctlArgs(['help'], { simulatorSetPath: undefined }), allowFailure: true, }, signal, diff --git a/packages/platform-apple/src/logs/log-predicate.test.ts b/packages/platform-apple/src/logs/log-predicate.test.ts index 8c3d607326..3633066f03 100644 --- a/packages/platform-apple/src/logs/log-predicate.test.ts +++ b/packages/platform-apple/src/logs/log-predicate.test.ts @@ -18,12 +18,17 @@ test('Apple app-log predicate covers bundle and executable provenance', () => { test('Apple app-log arguments preserve simulator-set and CoreDevice launch semantics', () => { assert.deepEqual( - buildIosSimulatorLogStreamArgs({ - deviceId: 'sim-1', - appBundleId: 'com.example.app', - executableName: 'ExampleExec', - simulatorSetPath: '/tmp/tenant-a/simulators', - }), + buildIosSimulatorLogStreamArgs( + { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17', + kind: 'simulator', + target: 'mobile', + simulatorSetPath: '/tmp/tenant-a/simulators', + }, + { appBundleId: 'com.example.app', executableName: 'ExampleExec' }, + ), [ 'simctl', '--set', diff --git a/packages/platform-apple/src/logs/log-predicate.ts b/packages/platform-apple/src/logs/log-predicate.ts index 4fa3e598de..15893d2f2b 100644 --- a/packages/platform-apple/src/logs/log-predicate.ts +++ b/packages/platform-apple/src/logs/log-predicate.ts @@ -1,4 +1,5 @@ -import { buildSimctlArgs } from '../core/simctl.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { buildSimctlArgsForDevice } from '../core/simctl.ts'; export function buildAppleLogPredicate(appBundleId: string, executableName?: string): string { const escapedBundleId = escapePredicateString(appBundleId); @@ -21,27 +22,22 @@ export function buildAppleLogPredicate(appBundleId: string, executableName?: str return clauses.join(' OR '); } -export function buildIosSimulatorLogStreamArgs(params: { - deviceId: string; - appBundleId: string; - executableName?: string; - simulatorSetPath?: string; -}): string[] { - return buildSimctlArgs( - [ - 'spawn', - params.deviceId, - 'log', - 'stream', - '--style', - 'compact', - '--level', - 'info', - '--predicate', - buildAppleLogPredicate(params.appBundleId, params.executableName), - ], - { simulatorSetPath: params.simulatorSetPath }, - ); +export function buildIosSimulatorLogStreamArgs( + device: DeviceInfo, + params: { appBundleId: string; executableName?: string }, +): string[] { + return buildSimctlArgsForDevice(device, [ + 'spawn', + device.id, + 'log', + 'stream', + '--style', + 'compact', + '--level', + 'info', + '--predicate', + buildAppleLogPredicate(params.appBundleId, params.executableName), + ]); } export function buildIosDeviceConsoleLaunchArgs(deviceId: string, appBundleId: string): string[] { diff --git a/packages/platform-apple/src/logs/start.test.ts b/packages/platform-apple/src/logs/start.test.ts index 78b4f8ad4f..835cf866a7 100644 --- a/packages/platform-apple/src/logs/start.test.ts +++ b/packages/platform-apple/src/logs/start.test.ts @@ -55,3 +55,39 @@ test('simulator start resolves its app container through appleTools and keeps pl 'log', ]); }); + +test('simulator log stream takes the set of the device it streams from', async () => { + const appleToolRun = vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 1 })); + const fixture = hostFixture({ appleToolRun, failProcessStart: true }); + + await expect( + startAppleAppLogs( + fixture.host, + appleDevice({ simulatorSetPath: '/tmp/scoped-set' }), + { + sessionId: 'session', + appBundleId: 'com.example.app', + outputPath: '/tmp/app.log', + fence: { token: 'fence', generation: 1 }, + }, + localRuntimeOwner('apple'), + ), + ).rejects.toThrow('start failed'); + + expect(appleToolRun).toHaveBeenCalledWith( + expect.objectContaining({ + args: ['--set', '/tmp/scoped-set', 'get_app_container', 'apple-1', 'com.example.app', 'app'], + }), + undefined, + ); + expect(fixture.backgroundCommands[0]?.slice(0, 8)).toEqual([ + 'xcrun', + 'simctl', + '--set', + '/tmp/scoped-set', + 'spawn', + 'apple-1', + 'log', + 'stream', + ]); +}); diff --git a/packages/platform-apple/src/logs/start.ts b/packages/platform-apple/src/logs/start.ts index a25b3a67e8..4c41375636 100644 --- a/packages/platform-apple/src/logs/start.ts +++ b/packages/platform-apple/src/logs/start.ts @@ -159,12 +159,7 @@ async function commandForAppleAppLogs( const executableName = await resolveSimulatorExecutable(host, device, appBundleId, signal); return { executable: 'xcrun', - args: buildIosSimulatorLogStreamArgs({ - deviceId: device.id, - appBundleId, - executableName, - simulatorSetPath: device.simulatorSetPath, - }), + args: buildIosSimulatorLogStreamArgs(device, { appBundleId, executableName }), allowFailure: true, } as const; } diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts index 2a88f22125..176e943715 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -48,6 +48,45 @@ test('recovers an empty iOS simulator dump from bounded simctl log history', asy ); }); +test.each([ + ['a session start', { state: 'active', startedAt: 1_000 } as const, ['--start', '@1']], + ['no session start', undefined, ['--last', '5m']], +])( + 'scopes the whole log-show argv of a scoped-set simulator after %s', + async (_label, snapshot, window) => { + const runSimctl = vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })); + await dumpAppleNetworkTraffic( + host({ runSimctl }), + { ...simulator, simulatorSetPath: '/tmp/scoped-set' }, + input(snapshot ? { appLogSnapshot: snapshot } : {}), + new AbortController().signal, + ); + + expect(runSimctl).toHaveBeenCalledWith( + { + tool: 'simctl', + args: [ + '--set', + '/tmp/scoped-set', + 'spawn', + 'sim-1', + 'log', + 'show', + '--style', + 'compact', + '--info', + '--predicate', + expect.any(String), + ...window, + ], + allowFailure: true, + timeoutMs: 4_000, + }, + expect.any(AbortSignal), + ); + }, +); + test('preserves the simulator no-HTTP explanation after recovery returns app lines', async () => { const result = await dumpAppleNetworkTraffic( host({ diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index cc9b021c89..0cb43d331c 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -89,6 +89,7 @@ async function recoverSimulatorTraffic( appLogPath: string, signal: AbortSignal, ): Promise<{ scan: NetworkScan; lineCount: number } | undefined> { + const startedAt = input.appLogSnapshot?.startedAt; const args = scopeSimctlArgsForDevice(device, [ 'spawn', device.id, @@ -99,13 +100,10 @@ async function recoverSimulatorTraffic( '--info', '--predicate', buildPredicate(input.appBundleId as string), - ]); - const startedAt = input.appLogSnapshot?.startedAt; - args.push( ...(typeof startedAt === 'number' && Number.isFinite(startedAt) && startedAt > 0 ? ['--start', `@${Math.floor(startedAt / 1000)}`] : ['--last', '5m']), - ); + ]); const result = await host.appleTools.run( { tool: 'simctl', args, allowFailure: true, timeoutMs: 4_000 }, signal, diff --git a/packages/platform-apple/src/simulator-inventory.ts b/packages/platform-apple/src/simulator-inventory.ts index 05d316a207..dcdc03147e 100644 --- a/packages/platform-apple/src/simulator-inventory.ts +++ b/packages/platform-apple/src/simulator-inventory.ts @@ -2,6 +2,7 @@ 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'; @@ -26,7 +27,7 @@ type SimctlListDevicesPayload = { const BOOTED_SIMULATOR_PROBE_TIMEOUT_MS = 3_000; -export function buildSimctlListArgs(simulatorSetPath: string | undefined): string[] { +export function buildSimctlListArgs(simulatorSetPath: string | undefined): ScopedSimctlArgs { return scopeSimctlArgs(['list', 'devices', '-j'], { simulatorSetPath }); } diff --git a/packages/platform-apple/src/snapshot-observability.test.ts b/packages/platform-apple/src/snapshot-observability.test.ts index d107e33ce5..3459948d7a 100644 --- a/packages/platform-apple/src/snapshot-observability.test.ts +++ b/packages/platform-apple/src/snapshot-observability.test.ts @@ -5,6 +5,7 @@ import { } from '@agent-device/host-kit/diagnostics'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { simulatorAddressFor } from './core/simctl.ts'; import { createLaunchObservationProbe } from './snapshot-observability.ts'; import type { SnapshotSourceFailure, SnapshotSourceOutcome } from './snapshot-source-facade.ts'; import type { SimulatorSnapshotTarget } from './snapshot-target.ts'; @@ -20,7 +21,7 @@ const simulator = { } as const satisfies DeviceInfo; const target = { - udid: simulator.id, + simulator: simulatorAddressFor(simulator), runtime: 'iOS 26.0', pid: 42, generation: '42:launch-a', diff --git a/packages/platform-apple/src/snapshot-route.test.ts b/packages/platform-apple/src/snapshot-route.test.ts index 2f5a9b6647..d1be189ac7 100644 --- a/packages/platform-apple/src/snapshot-route.test.ts +++ b/packages/platform-apple/src/snapshot-route.test.ts @@ -9,6 +9,7 @@ vi.mock('./system-surface-presence.ts', () => ({ })); import { areIosSnapshotComparisonIdentitiesEqual } from '@agent-device/capture-kit/ios-snapshot-planning'; import { IOS_SYSTEM_SURFACE_HOSTS } from '@agent-device/contracts/ios-system-surface'; +import { simulatorAddressFor } from './core/simctl.ts'; import { createLocalAppleToolProvider, withAppleToolProvider } from './core/tool-provider.ts'; import { platformRuntimeHostFixture } from './runtime.fixtures.ts'; import { createAppleSnapshotRoute } from './snapshot-route.ts'; @@ -26,7 +27,7 @@ const ios = { } as const satisfies DeviceInfo; const target = { - udid: ios.id, + simulator: simulatorAddressFor(ios), runtime: 'iOS 26.0', pid: 42, generation: '42:launch-a', @@ -489,7 +490,7 @@ test('a slow app discovery yields to a live runner within its wait slice, then s const released = new Promise((resolve) => { release = resolve; }); - const run = vi.fn(async (args: string[]) => { + const run = vi.fn(async (args: readonly string[]) => { if (args[0] === 'spawn') await released; return { stdout: @@ -555,7 +556,7 @@ test('an open waits out a slow app discovery, so the first capture after it star const released = new Promise((resolve) => { release = resolve; }); - const run = vi.fn(async (args: string[]) => { + const run = vi.fn(async (args: readonly string[]) => { if (args[0] === 'spawn') await released; return { stdout: @@ -729,7 +730,7 @@ test('a slow app discovery keeps observation on the bridge while no runner can a const released = new Promise((resolve) => { release = resolve; }); - const run = vi.fn(async (args: string[]) => { + const run = vi.fn(async (args: readonly string[]) => { if (args[0] === 'spawn') await released; return { stdout: diff --git a/packages/platform-apple/src/snapshot-route.ts b/packages/platform-apple/src/snapshot-route.ts index 1961abdca5..9bed7d9eb6 100644 --- a/packages/platform-apple/src/snapshot-route.ts +++ b/packages/platform-apple/src/snapshot-route.ts @@ -277,13 +277,13 @@ async function fallbackAfterFailure( if (opensGenerationCircuit(failure)) disabledGenerations.add(generationKey(failedTarget)); emitRouteDiagnostic( failure.code, - { id: failedTarget.udid }, + { id: failedTarget.simulator.udid }, failedTarget.generation, cause, failure.details, ); return await runFallback( - failedTarget.udid, + failedTarget.simulator.udid, input, fallback, identity.lineage, diff --git a/packages/platform-apple/src/snapshot-source/adapter.test.ts b/packages/platform-apple/src/snapshot-source/adapter.test.ts index d419def596..008872b399 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.test.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.test.ts @@ -10,6 +10,7 @@ import { } from '@agent-device/capture-kit/ios-snapshot-planning'; import { createSnapshotSourceHost } from './host.ts'; import { createSimulatorSnapshotSource } from './adapter.ts'; +import { simulatorAddressFor } from '../core/simctl.ts'; import { DEPTH_HINT_PROBE_BACK_AFTER_USES } from './depth-hints.ts'; import { encodeSnapshotBridgeFrame, @@ -47,7 +48,11 @@ test('the Simulator AX source returns raw acquisition facts and discloses unsupp try { const result = await source.acquire({ - target: { ...sourceTarget, targetId: 'target-1', simulatorSetPath: '/tmp/scoped-set' }, + target: { + ...targetForTest('/tmp/scoped-set'), + generation: 'generation-1', + targetId: 'target-1', + }, hint, }); assert.deepEqual( @@ -472,9 +477,16 @@ type AdapterFixture = { startedTargets: Array[0]>; }; -function targetForTest() { +function targetForTest(simulatorSetPath?: string) { return { - udid: 'simulator-1', + simulator: simulatorAddressFor({ + platform: 'apple', + id: 'simulator-1', + name: 'iPhone 17', + kind: 'simulator', + target: 'mobile', + ...(simulatorSetPath ? { simulatorSetPath } : {}), + }), runtime: 'iOS 26.2', pid: 321, }; diff --git a/packages/platform-apple/src/snapshot-source/adapter.ts b/packages/platform-apple/src/snapshot-source/adapter.ts index f82c771124..4ffd715553 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.ts @@ -121,7 +121,7 @@ export function createSimulatorSnapshotSource( // fallow-ignore-next-line complexity function validateRequest(request: SnapshotSourceRequest): void { if ( - !request.target.udid.trim() || + !request.target.simulator.udid.trim() || !request.target.runtime.trim() || !request.target.generation.trim() || !Number.isSafeInteger(request.target.pid) || diff --git a/packages/platform-apple/src/snapshot-source/host.test.ts b/packages/platform-apple/src/snapshot-source/host.test.ts index 893677b787..989e0feaef 100644 --- a/packages/platform-apple/src/snapshot-source/host.test.ts +++ b/packages/platform-apple/src/snapshot-source/host.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { test, vi } from 'vitest'; import { runCmdBackground } from '@agent-device/host-kit/command'; +import { simulatorAddressFor } from '../core/simctl.ts'; import { createSnapshotSourceHost, snapshotSourceSocketPath } from './host.ts'; vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ @@ -40,7 +41,14 @@ test.each([ }); const started = createSnapshotSourceHost().start( - { udid: 'simulator-1', ...(simulatorSetPath ? { simulatorSetPath } : {}) }, + simulatorAddressFor({ + platform: 'apple', + id: 'simulator-1', + name: 'iPhone 17', + kind: 'simulator', + target: 'mobile', + ...(simulatorSetPath ? { simulatorSetPath } : {}), + }), '/tmp/bridge', '/tmp/bridge.sock', ); diff --git a/packages/platform-apple/src/snapshot-source/host.ts b/packages/platform-apple/src/snapshot-source/host.ts index b80421d1fe..387a0701ae 100644 --- a/packages/platform-apple/src/snapshot-source/host.ts +++ b/packages/platform-apple/src/snapshot-source/host.ts @@ -23,14 +23,9 @@ import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diag import { findProjectRoot } from '@agent-device/host-kit/version'; import { SnapshotSourceError, snapshotSourceError } from './errors.ts'; import { remainingSnapshotSourceMs } from './deadline.ts'; -import type { - SnapshotSourceHost, - SnapshotSourceProcess, - SnapshotSourceSocket, - SnapshotSourceTarget, -} from './types.ts'; +import type { SnapshotSourceHost, SnapshotSourceProcess, SnapshotSourceSocket } from './types.ts'; import { readSnapshotTargetProcessStartTime } from '../snapshot-process.ts'; -import { buildSimctlArgs } from '../core/simctl.ts'; +import { buildSimctlArgsForAddress, type SimulatorAddress } from '../core/simctl.ts'; const BRIDGE_IDLE_TIMEOUT_SECONDS = 60; const MAX_PROCESS_LOG_BYTES = 64 * 1024; @@ -60,7 +55,7 @@ export function createSnapshotSourceHost(): SnapshotSourceHost { } function startSnapshotBridge( - target: Pick, + simulator: SimulatorAddress, bridgePath: string, socketPath: string, options: { signal?: AbortSignal } = {}, @@ -70,20 +65,17 @@ function startSnapshotBridge( } const started = runCmdBackground( 'xcrun', - buildSimctlArgs( - [ - 'spawn', - target.udid, - bridgePath, - 'serve', - socketPath, - '--idle-timeout', - String(BRIDGE_IDLE_TIMEOUT_SECONDS), - '--exit-on-disconnect', - 'false', - ], - { simulatorSetPath: target.simulatorSetPath }, - ), + buildSimctlArgsForAddress(simulator, [ + 'spawn', + simulator.udid, + bridgePath, + 'serve', + socketPath, + '--idle-timeout', + String(BRIDGE_IDLE_TIMEOUT_SECONDS), + '--exit-on-disconnect', + 'false', + ]), { allowFailure: true, captureOutput: false, diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts index c2da49247f..f817182837 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts @@ -10,11 +10,13 @@ import { SNAPSHOT_SOURCE_VERSION, } from './protocol.ts'; import { SnapshotBridgeManager } from './lifecycle.ts'; +import { simulatorAddressFor } from '../core/simctl.ts'; import type { SnapshotSourceHost, SnapshotSourceLimits, SnapshotSourceProcess, SnapshotSourceSocket, + SnapshotSourceTarget, } from './types.ts'; const limits: SnapshotSourceLimits = { @@ -25,8 +27,16 @@ const limits: SnapshotSourceLimits = { maxDurationMs: 100, }; +const simulatorDevice = { + platform: 'apple', + id: 'simulator-1', + name: 'iPhone 17', + kind: 'simulator', + target: 'mobile', +} as const; + const target = { - udid: 'simulator-1', + simulator: simulatorAddressFor(simulatorDevice), runtime: 'iOS 26.2', pid: 123, generation: 'generation-1', @@ -40,6 +50,12 @@ const bridge = { sourceVersion: SNAPSHOT_SOURCE_VERSION, }; +function compileTimeSnapshotTargetProof(): SnapshotSourceTarget { + // @ts-expect-error A target names its simulator through an address minted from its DeviceInfo. + return { udid: 'simulator-1', runtime: 'iOS 26.2', pid: 123, generation: 'generation-1' }; +} +void compileTimeSnapshotTargetProof; + test('the bridge manager reuses a healthy per-device helper and stops it exactly once', async () => { const fixture = createLifecycleFixture(); const manager = new SnapshotBridgeManager(fixture.host); @@ -56,7 +72,10 @@ test('the bridge manager reuses a healthy per-device helper and stops it exactly test('the helper starts inside the simulator set that owns the target', async () => { const fixture = createLifecycleFixture(); const manager = new SnapshotBridgeManager(fixture.host); - const scopedTarget = { ...target, simulatorSetPath: '/tmp/scoped-set' }; + const scopedTarget = { + ...target, + simulator: simulatorAddressFor({ ...simulatorDevice, simulatorSetPath: '/tmp/scoped-set' }), + }; await manager.request({ target: scopedTarget, @@ -66,7 +85,9 @@ test('the helper starts inside the simulator set that owns the target', async () deadline: deadline(), }); - assert.deepEqual(fixture.startedTargets, [scopedTarget]); + assert.deepEqual(fixture.startedTargets, [ + { udid: 'simulator-1', simulatorSetPath: '/tmp/scoped-set' }, + ]); await manager.close(); }); diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.ts b/packages/platform-apple/src/snapshot-source/lifecycle.ts index d89abcc129..4c48a37a60 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.ts @@ -55,7 +55,7 @@ export class SnapshotBridgeManager { async request(input: SnapshotBridgeRequest): Promise { if (this.closed) throw snapshotSourceError('unsupported', 'source-closed'); - return await this.withSimulatorLock(input.target.udid, input.deadline, () => + return await this.withSimulatorLock(input.target.simulator.udid, input.deadline, () => this.requestInSimulator(input), ); } @@ -64,7 +64,7 @@ export class SnapshotBridgeManager { if (this.closed) throw snapshotSourceError('unsupported', 'source-closed'); const deadline = input.deadline; remainingSnapshotSourceMs(deadline, 'bridge-request-deadline'); - const previousSession = this.sessions.get(input.target.udid); + const previousSession = this.sessions.get(input.target.simulator.udid); const session = await this.ensureSession(input, deadline); try { const targetStartTime = await this.readTargetStartTime(input.target, deadline); @@ -127,7 +127,7 @@ export class SnapshotBridgeManager { input: SnapshotBridgeRequest, deadline: SnapshotSourceDeadline, ): Promise { - const key = input.target.udid; + const key = input.target.simulator.udid; const existing = this.sessions.get(key); if (existing && existing.bridgePath === input.bridge.path && existing.process.isAlive()) { if (!existing.socket || existing.socket.destroyed) { @@ -137,14 +137,18 @@ export class SnapshotBridgeManager { } if (existing) await this.removeSession(existing, true); - const socketPath = snapshotSourceSocketPath(this.host, input.target.udid, this.ownerId); + const socketPath = snapshotSourceSocketPath( + this.host, + input.target.simulator.udid, + this.ownerId, + ); await this.host.ensureDirectory(path.dirname(socketPath)); await this.host.remove(socketPath); - const bridgeProcess = this.host.start(input.target, input.bridge.path, socketPath, { + const bridgeProcess = this.host.start(input.target.simulator, input.bridge.path, socketPath, { signal: deadline.signal, }); const session: BridgeSession = { - udid: input.target.udid, + udid: input.target.simulator.udid, bridgePath: input.bridge.path, socketPath, process: bridgeProcess, diff --git a/packages/platform-apple/src/snapshot-source/types.ts b/packages/platform-apple/src/snapshot-source/types.ts index 2cec940121..9d532b2d0b 100644 --- a/packages/platform-apple/src/snapshot-source/types.ts +++ b/packages/platform-apple/src/snapshot-source/types.ts @@ -5,6 +5,7 @@ import type { IosViewportEvidence, } from '@agent-device/contracts/ios-snapshot'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; +import type { SimulatorAddress } from '../core/simctl.ts'; import type { SnapshotSourceDeadline } from './deadline.ts'; export type SnapshotSourceLimits = Readonly<{ @@ -16,14 +17,12 @@ export type SnapshotSourceLimits = Readonly<{ }>; export type SnapshotSourceTarget = Readonly<{ - udid: string; + simulator: SimulatorAddress; runtime: string; pid: number; generation: string; targetId?: string; processStartTime?: string; - /** Device set that owns `udid`; absent for the default CoreSimulator set. */ - simulatorSetPath?: string; }>; export type SnapshotSourceRequest = Readonly<{ @@ -84,7 +83,7 @@ export type SnapshotSourceHost = Readonly<{ homeDirectory(): string; run(command: string, args: string[], options?: ExecOptions): Promise; start( - target: Pick, + simulator: SimulatorAddress, bridgePath: string, socketPath: string, options?: { signal?: AbortSignal }, diff --git a/packages/platform-apple/src/snapshot-target.test.ts b/packages/platform-apple/src/snapshot-target.test.ts index 08e830ca80..09093a9c23 100644 --- a/packages/platform-apple/src/snapshot-target.test.ts +++ b/packages/platform-apple/src/snapshot-target.test.ts @@ -18,7 +18,7 @@ const signal = () => new AbortController().signal; function targetFixture() { const state = { pid: 42, launch: 'launch-a', start: 'start-a' as string | null }; - const run = vi.fn(async (args: string[], _options?: { timeoutMs?: number }) => ({ + const run = vi.fn(async (args: readonly string[], _options?: { timeoutMs?: number }) => ({ stdout: args.includes('spawn') ? `90\t0\tUIKitApplication:com.example.app.beta[wrong][rb-legacy]\n${state.pid}\t0\tUIKitApplication:${app}[${state.launch}][rb-legacy]` : JSON.stringify({ @@ -53,7 +53,7 @@ test('an unchanged OS process reuses its exact app target without another simctl const second = await fixture.resolve(ios, app, signal()); expect(second).toBe(first); expect(first).toEqual({ - udid: ios.id, + simulator: { udid: ios.id, simulatorSetPath: undefined }, runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0', pid: 42, generation: `42:UIKitApplication:${app}[launch-a][rb-legacy]:start-a`, @@ -73,7 +73,7 @@ test('a target in a scoped simulator set carries that set to the bridge', async app, signal(), ); - expect(target.simulatorSetPath).toBe('/tmp/scoped-set'); + expect(target.simulator.simulatorSetPath).toBe('/tmp/scoped-set'); expect(fixture.run.mock.calls.map(([args]) => args.slice(0, 2))).toEqual([ ['--set', '/tmp/scoped-set'], ['--set', '/tmp/scoped-set'], @@ -167,7 +167,7 @@ function deferredSpawn(fixture: ReturnType) { release = resolve; }); const respond = fixture.run.getMockImplementation()!; - fixture.run.mockImplementation(async (args: string[]) => + fixture.run.mockImplementation(async (args: readonly string[]) => args[0] === 'spawn' ? await released.then(() => respond(args)) : await respond(args), ); return release; @@ -255,7 +255,7 @@ test('a failed runtime probe does not release the slot while the launch-job prob const fixture = targetFixture(); const release = deferredSpawn(fixture); const respond = fixture.run.getMockImplementation()!; - fixture.run.mockImplementation(async (args: string[], options) => + fixture.run.mockImplementation(async (args: readonly string[], options) => args[0] === 'list' ? { stdout: '', stderr: 'simctl list failed', exitCode: 1 } : await respond(args, options), diff --git a/packages/platform-apple/src/snapshot-target.ts b/packages/platform-apple/src/snapshot-target.ts index b24f7178d5..dfca5792c5 100644 --- a/packages/platform-apple/src/snapshot-target.ts +++ b/packages/platform-apple/src/snapshot-target.ts @@ -1,7 +1,12 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { createDetachedAttempts, waitForDetachedAttempt } from './detached-attempt.ts'; -import { readSimctlDevicesByRuntime, runSimctlForDevice } from './core/simctl.ts'; +import { + readSimctlDevicesByRuntime, + runSimctlForDevice, + simulatorAddressFor, + type SimulatorAddress, +} from './core/simctl.ts'; import { readSnapshotTargetProcessStartTime } from './snapshot-process.ts'; /** Identity re-check of a cached target: one local `ps`, never CoreSimulator IPC. */ @@ -19,13 +24,12 @@ const TARGET_DISCOVERY_TIMEOUT_MS = 15_000; const TARGET_DISCOVERY_PENDING = 'simulator-target-discovery-pending'; export type SimulatorSnapshotTarget = Readonly<{ - udid: string; + simulator: SimulatorAddress; runtime: string; pid: number; generation: string; targetId: string; processStartTime: string; - simulatorSetPath?: string; }>; export type SimulatorSnapshotTargetResolver = ( @@ -111,13 +115,12 @@ async function resolveSimulatorSnapshotTarget( throw targetError('simulator-target-identity-unavailable', device, appBundleId); } return Object.freeze({ - udid: device.id, + simulator: simulatorAddressFor(device), runtime, pid: job.pid, generation: `${job.pid}:${job.label}:${processStartTime}`, targetId: `${device.id}:${appBundleId}`, processStartTime, - ...(device.simulatorSetPath ? { simulatorSetPath: device.simulatorSetPath } : {}), }); } diff --git a/scripts/layering/apple-simulator-scope-policy.test.ts b/scripts/layering/apple-simulator-scope-policy.test.ts new file mode 100644 index 0000000000..652633518d --- /dev/null +++ b/scripts/layering/apple-simulator-scope-policy.test.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + appleSimulatorScopeViolations, + isPolicedSimulatorScopeFile, +} 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$/; + +function violationsFor(file: string, source: string) { + return appleSimulatorScopeViolations(new Map([[file, source]])); +} + +function assertFlagged(file: string, source: string, message: RegExp): void { + const violations = violationsFor(file, source); + assert.equal(violations.length, 1, `${file}: ${source}`); + assert.equal(violations[0]!.rule, 'R79 apple-simulator-scope'); + assert.equal(violations[0]!.file, file); + 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 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 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); + assertFlagged( + `${APPLE_SRC}foldable/simulator-hid.ts`, + 'const address = { udid, simulatorSetPath };\n', + ARGV_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 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); + } + assert.deepEqual( + violationsFor( + `${APPLE_SRC}core/tool-provider.ts`, + "provider.simctl.run(toolArgs as unknown as ScopedSimctlArgs, options);\nrunCmd('xcrun', ['simctl', ...args]);\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`, + `${APPLE_SRC}core/__tests__/simctl.test.ts`, + `${APPLE_SRC}runtime.fixtures.ts`, + 'scripts/ios-snapshot-benchmark/lifecycle.ts', + 'test/integration/provider-scenarios/providers.ts', + ]) { + assert.equal(isPolicedSimulatorScopeFile(file), false, file); + assert.deepEqual(violationsFor(file, HAND_BUILT_SPAWN + EXPLICIT_DEFAULT_SET), [], 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', () => { + const [violation] = violationsFor( + `${APPLE_SRC}foldable/simulator-hid.ts`, + `const a = 1;\n\n${HAND_BUILT_SPAWN}`, + ); + assert.equal(violation!.line, 3); +}); diff --git a/scripts/layering/apple-simulator-scope-policy.ts b/scripts/layering/apple-simulator-scope-policy.ts new file mode 100644 index 0000000000..b83f4dd67a --- /dev/null +++ b/scripts/layering/apple-simulator-scope-policy.ts @@ -0,0 +1,128 @@ +// 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. +// 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). +// 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. + +import { parseSync } from 'oxc-parser'; +import { 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`, +]); +const SET_SCOPE_BUILDER = 'scopeSimctlArgs'; + +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'; + +/** Production TypeScript under `packages/*\/src/` and `src/`; tests and fixtures are exempt. */ +export function isPolicedSimulatorScopeFile(file: string): boolean { + if (!/^packages\/[^/]+\/src\//.test(file) && !file.startsWith('src/')) return false; + return !( + file.endsWith('.test.ts') || + file.includes('/__tests__/') || + file.endsWith('.fixtures.ts') + ); +} + +export function appleSimulatorScopeViolations( + sources: ReadonlyMap, +): LayeringViolation[] { + 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 }); + }; + 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); + } + if ( + !SET_SCOPE_OWNERS.has(file) && + node.type === 'Identifier' && + node.name === SET_SCOPE_BUILDER + ) { + report(node, SET_SCOPE_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')) + ); +} + +function referencedTypeNames(typeAnnotation: unknown): Set { + const names = new Set(); + visitAst(typeAnnotation, (node) => { + if (node.type !== 'TSTypeReference') return; + const typeName = node.typeName as AstNode | undefined; + const name = + typeName?.type === 'TSQualifiedName' ? (typeName.right as AstNode).name : typeName?.name; + if (typeof name === 'string') names.add(name); + }); + 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 lineAt(source: string, offset: number): number { + return source.slice(0, offset).split('\n').length; +} diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 2d135cf4fb..bf09d3b43a 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -40,6 +40,9 @@ // 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 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 @@ -99,6 +102,7 @@ import { platformPackagePolicySummary, } from './platform-package-policy.ts'; import { appleRunnerHostPortViolations } from './apple-runner-host-port-policy.ts'; +import { appleSimulatorScopeViolations } from './apple-simulator-scope-policy.ts'; import { listUntrackedProductionTypeScriptFiles, readTrackedPlatformPackageDeclarations, @@ -456,6 +460,7 @@ export const LAYERING_RULE_IDS = [ 'package-boundaries', 'platform-package-policy', 'apple-runner-host-port', + 'apple-simulator-scope', 'retired-platforms-zone', 'src-utils-retirement', 'replay-ownership', @@ -510,6 +515,7 @@ export const LAYERING_RULES: Readonly> = { ), 'apple-runner-host-port': (context) => appleRunnerHostPortViolations(context.allTypeScriptSources), + 'apple-simulator-scope': (context) => appleSimulatorScopeViolations(context.allTypeScriptSources), 'retired-platforms-zone': () => checkRetiredPlatformsZone(listTrackedPlatformZoneFiles(repoRoot)), 'src-utils-retirement': (context) => retiredPathRuleViolations('R14', context.trackedSrcUtilsFiles), diff --git a/src/daemon/__tests__/request-platform-providers.test.ts b/src/daemon/__tests__/request-platform-providers.test.ts index 13209ba53c..693a2b7acb 100644 --- a/src/daemon/__tests__/request-platform-providers.test.ts +++ b/src/daemon/__tests__/request-platform-providers.test.ts @@ -56,7 +56,7 @@ test('request platform provider scope applies Apple tool provider only for Apple }, simctl: { run: async (args) => { - calls.push(args); + calls.push([...args]); return { exitCode: 0, stdout: 'simctl-ok', stderr: '' }; }, }, diff --git a/test/integration/provider-scenarios/providers.ts b/test/integration/provider-scenarios/providers.ts index c39a5f56f0..b9166cef34 100644 --- a/test/integration/provider-scenarios/providers.ts +++ b/test/integration/provider-scenarios/providers.ts @@ -6,13 +6,13 @@ import type { AppleToolProvider, AppleToolSubcommandExecutor, } from '@agent-device/platform-apple/tool-provider'; -import { type ExecResult } from '@agent-device/host-kit/command'; +import { type ExecOptions, type ExecResult } from '@agent-device/host-kit/command'; import type { ProviderScenarioTranscript } from './transcript.ts'; export type FlatToolCall = [string, ...string[]]; type RecordingAppleToolHandlers = { - simctl?: AppleToolSubcommandExecutor; + simctl?: AppleToolProvider['simctl']['run']; devicectl?: AppleToolSubcommandExecutor; macosHelper?: AppleToolSubcommandExecutor; macosHost?: AppleMacOsHostProvider; @@ -215,7 +215,7 @@ function simctlListDevicesJson( export function simctlDeviceLifecycleHandler( runtime: string, devices: Array<{ name: string; udid: string; state?: string; isAvailable?: boolean }>, -): AppleToolSubcommandExecutor { +): (args: readonly string[], options?: ExecOptions) => Promise { return async (args) => { const result = simctlListDevicesResult(args, runtime, devices); if (result) return result; @@ -237,7 +237,7 @@ export function unexpectedProviderCall(platform: string, command: readonly strin } export function simctlListDevicesResult( - args: string[], + args: readonly string[], runtime: string, devices: Array<{ name: string; udid: string; state?: string; isAvailable?: boolean }>, ): ExecResult | undefined {