Repository navigation
Enforce simulator-set scope for simctl: device scope for any udid, set scope only for calls that name no device #2878
Description
Purpose
A simctl call that addresses a simulator in a scoped device set (--ios-simulator-device-set) must carry --set <path>. When it does not, simctl looks in the default CoreSimulator set and fails with Invalid device, or the call targets a different simulator.
This has shipped twice already:
- fix(ios): pass the scoped simulator set to the AX bridge and fold
simctl spawncalls #2784, fixed by fix(ios): spawn the AX bridge and fold helper inside the scoped simulator set #2818: the AX snapshot bridge (snapshot-source/host.ts) and the fold HID helper (foldable/simulator-hid.ts) built['simctl', 'spawn', udid, ...]by hand from a bareudid. Both lost the set. Live repro from fix(ios): spawn the AX bridge and fold helper inside the scoped simulator set #2818:simctl --set <set> spawn <udid> fold half-openexits 0, and the same call without--setfails withInvalid device. - refactor(ios): one simctl --set builder and one simulator state parser #2824 (closes refactor(platform-apple): one simctl --set builder and one simulator state parser #2794) moved every current call site onto the builders in
core/simctl.ts. It checked this by hand withgit grep "'--set'". Nothing keeps it true.
The rule this issue enforces
- A simctl argv that names a udid takes its set from the record that supplied the udid. That record is a
DeviceInfo(through the*ForDevicebuilders) or aSimulatorAddressminted from aDeviceInfo. A caller never passes the set as a separate value next to a udid. - A simctl argv that names no device takes set scope. Today there are two: inventory's
list devices -j(simulator-inventory.ts:30), which runs before a device exists, and doctor'shelp(logs/doctor.ts:28-32). Only these may call the set-scope builder.
A required simulatorSetPath key alone does not give rule 1. A new helper can write scopeSimctlArgs(['spawn', udid, bin], { simulatorSetPath: undefined }). That compiles, and it is the #2818 failure with the set written out as undefined. So this issue limits the set-scope builder to its two owners and moves the two udid-naming callers off it.
State at origin/main 6debef0
The premise was checked against current code:
- Every production argv that starts with
'simctl'is built inpackages/platform-apple/src/core/simctl.ts:30,34or in the provider executors incore/tool-provider.ts:43,78. The only'--set'literal is atcore/simctl.ts:17. Today there are no literal bypasses. Nothing stops the next one. - Three callers use the set-scope builders (
scopeSimctlArgs/buildSimctlArgs,core/simctl.ts:11-31), and two of them violate rule 1:simulator-inventory.ts:30:list devices -j. Names no device. Correct.logs/log-predicate.ts:30:spawn <udid> log stream, with the set passed as the optionalparams.simulatorSetPath. Names a device. The callerlogs/start.ts:160-168already holds theDeviceInfo.snapshot-source/host.ts:73:spawn <udid> <bridge> serve, with the set read fromSnapshotSourceTarget.simulatorSetPath?(snapshot-source/types.ts:26), next to a separateudidfield. Names a device. The target is built insnapshot-target.ts:113-121from aDeviceInfo.
- Both set-scope builders default to the default set (
options = {}), so leaving the set out compiles. - simctl argv reaches five executors, and no one of them sees every call:
runXcrun(args: string[])(core/tool-provider.ts:117).runCmdBackground('xcrun', …)(snapshot-source/host.ts:71).- The app-log
executable: 'xcrun'spec (logs/start.ts:160). - The host port.
AppleToolRequest.argsisreadonly string[](packages/contracts/src/platform-runtime-host.ts:58-63), sohost.appleTools.run({ tool: 'simctl', args: ['boot', device.id] })compiles.packages/contracts/src/network-runtime.test.ts:24asserts that raw simctl argv compiles. Twelve production sites use this path:deployment/runtime.ts:150,174,209,logs/doctor.ts:29,logs/start.ts:180,network/runtime.ts:110,readiness/runtime.ts:101,134,199,shutdown/runtime.ts:40,simulator-inventory.ts:66,simulator-state.ts:13. Every one except doctor scopes throughscopeSimctlArgs*. Doctor sends['help']and names no device. AppleToolProvider.simctl.run(args: string[])(core/tool-provider.ts:32,121, type incore/tool-provider-types.ts:11-20). It takes plain argv after the tool name. It is reachable from root through the@agent-device/platform-apple/tool-providersubpath (src/platform-runtime-apple-tool-host.ts:6-10).resolveAppleToolProvider().simctl.run(['spawn', udid, bin])has no'simctl'element, no'--set'literal and no cast, so a literal-matching guard cannot see it.
Bug class: a simctl call against a simulator that runs in the default set, because the caller built argv by hand, dropped the set, or took the set from somewhere other than the udid's record. This issue makes it a type error on the host port and on simctl.run, and a CI failure on every other path.
Required behavior
1. Scoped simctl argv is a distinct type (contracts)
In packages/contracts/src/platform-runtime-host.ts:
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: 'devicectl' | 'xctrace'; args: readonly string[] }>
);The contract change is type-only and adds no runtime module. src/platform-runtime-apple-tool-host.ts still spreads [request.tool, ...request.args] into runXcrun. The brand stays readonly, so no caller can splice the --set prefix away after scoping. Regenerate scripts/layering/contracts-exports.snapshot.json with the existing generator.
2. Set scope, device scope and the simulator address (packages/platform-apple/src/core/simctl.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 }>;
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;
};
export function simulatorAddressFor(device: DeviceInfo): SimulatorAddress;
export function scopeSimctlArgs(args: readonly string[], scope: SimulatorSetScope): ScopedSimctlArgs;
export function scopeSimctlArgsForAddress(address: SimulatorAddress, args: readonly string[]): ScopedSimctlArgs;
export function scopeSimctlArgsForDevice(device: DeviceInfo, args: readonly string[]): ScopedSimctlArgs;
export function buildSimctlArgsForAddress(address: SimulatorAddress, args: readonly string[]): string[];
export function buildSimctlArgsForDevice(device: DeviceInfo, args: readonly string[]): string[]; // unchanged
export function runSimctlForDevice(...): Promise<ExecResult>; // unchangedsimulatorAddressForkeeps today's device-scope semantics:simulatorSetPathisdevice.simulatorSetPathwhenisIosFamily(device) && device.kind === 'simulator', elseundefined.scopeSimctlArgsForDevice(device, args)becomesscopeSimctlArgsForAddress(simulatorAddressFor(device), args), so device scope has one implementation.scopeSimctlArgsloses itsoptions = {}default. ThesimulatorSetPathkey is required. Rule 2 (section 4, check 4) limits who may call it.- Delete
buildSimctlArgs. After section 3 it has no production caller. Its test (core/__tests__/simctl.test.ts:21-33) moves tobuildSimctlArgsForAddresswith the same expected array. core/simctl.tsmintsSimulatorAddress(one cast insimulatorAddressFor) andScopedSimctlArgs(one cast inscopeSimctlArgs). ImportScopedSimctlArgswithimport type, so the eager import closure ofcore/simctl.tsstays the same.- The
--settrimming (resolveIosSimulatorDeviceSetPath) and the argv output do not change.
3. The provider executor takes scoped argv (core/tool-provider-types.ts, core/tool-provider.ts)
- Add
AppleSimctlToolProvider = { run: (args: ScopedSimctlArgs, options?: ExecOptions) => Promise<ExecResult> }, and typeAppleToolProvider.simctlwith it.devicectlandmacosHelperkeepAppleXcrunToolProvider. runXcrunis the one place intool-provider.tsthat mints the brand:provider.simctl.run(toolArgs as unknown as ScopedSimctlArgs, options).runXcruncannot import a mint function fromcore/simctl.ts, becausecore/simctl.tsalready importsrunXcrunfrom it. The argv that reachesrunXcrunis covered by check 1 (literal'simctl'arrays) and by the host-port brand (src/platform-runtime-apple-tool-host.ts).- Make
coerceRungeneric over its argument type so thatnormalizeAppleToolProviderkeeps coercingsimctl.run. - Injected providers: executors that are typed by context (
run: async (args, options) => …) still compile. Executors that declare(args: string[])do not, because a readonly array is not assignable tostring[]. Widen them toreadonly string[], or type the slot asAppleToolProvider['simctl']['run']:snapshot-target.test.ts:21,snapshot-route.test.ts:492,558,732, andtest/integration/provider-scenarios/providers.ts:15,218(simctl?handler slot andsimctlDeviceLifecycleHandler). No new export is needed for the integration harness.@agent-device/platform-appleis"private": true, so this type change has no external compatibility cost.
4. Call-site changes (no argv changes)
logs/doctor.ts:28-32:args: scopeSimctlArgs(['help'], { simulatorSetPath: undefined }). It names no device, so it takes set scope, and its argv stays['help']. Do not usescopeSimctlArgsForDevicehere. It would add--set <set>for a scoped-set simulator, andxcrun simctl --set <missing path> helpexits 1, sochecks.simctlAvailablewould then depend on whether the set exists.test/integration/provider-scenarios/apple-app-log-runtime-provider.test.tsalready asserts['help'].simulator-inventory.ts:29-31:buildSimctlListArgsreturnsScopedSimctlArgs. It already passes{ simulatorSetPath }explicitly.logs/log-predicate.ts:24-44:buildIosSimulatorLogStreamArgs(device: DeviceInfo, params: { appBundleId; executableName? })builds withbuildSimctlArgsForDevice(device, ['spawn', device.id, 'log', 'stream', …]).logs/start.ts:162passes itsdevice.log-predicate.test.ts:19-40changes its call shape only. The expected array stays the same.- Snapshot target: replace
udidandsimulatorSetPath?withsimulator: SimulatorAddressinSnapshotSourceTarget(snapshot-source/types.ts:18-27), inSimulatorSnapshotTarget(snapshot-target.ts:21-29) and in the host'sstartparameter (snapshot-source/types.ts:87,host.ts:63). The resolver setssimulator: simulatorAddressFor(device)(snapshot-target.ts:113-121). The udid then has one source. Readers oftarget.udidreadtarget.simulator.udid:snapshot-source/adapter.ts:124,snapshot-source/lifecycle.ts:58,67,130,140,147,snapshot-route.ts:280,286.host.ts:71-86becomesrunCmdBackground('xcrun', buildSimctlArgsForAddress(target.simulator, ['spawn', target.simulator.udid, bridgePath, 'serve', …]), …). Test fixtures (targetForTestand the snapshot-target and snapshot-route tests) mint the address withsimulatorAddressFor(<fixture device>). network/runtime.ts:92-108: build the whole tail (spawn … log show … --start|--last) first, then scope it once. Today it callsargs.pushafter scoping, which a readonly branded array does not allow.deployment/runtime.ts:231:runAppleTooltakesAppleToolRequest(or a distributive omit), notOmit<AppleToolRequest, 'allowFailure'>. A plainOmitcollapses the union and lets raw simctl argv back in.
5. Layering rule R79 apple-simulator-scope
Add scripts/layering/apple-simulator-scope-policy.ts and its test. Register it in scripts/layering/check.ts next to apple-runner-host-port (rule-name list around line 458, runner map around line 511). Use the header convention of the neighbouring policies: Catches / Evidence (#2784, #2818) / Cost / Kill criterion. The rule reads context.allTypeScriptSources and keeps production files under packages/*/src/ and src/ (it skips *.test.ts, __tests__/, *.fixtures.ts and scripts/). It parses them with oxc-parser and reports:
- An
ArrayExpressionwhose first element is the string literal'simctl', outsidepackages/platform-apple/src/core/simctl.tsandpackages/platform-apple/src/core/tool-provider.ts. This catches the fix(ios): spawn the AX bridge and fold helper inside the scoped simulator set #2818 form onrunXcrun,runCmd,runCmdBackgroundand anexecutable: 'xcrun'spec. - The string literal
'--set'inpackages/platform-apple/src/outsidecore/simctl.ts. This catches a hand-rolled prefix and turns refactor(ios): one simctl --set builder and one simulator state parser #2824's manualgit grepinto a gate. - A type assertion (
TSAsExpression/TSTypeAssertion) whose target type namesSimulatorAddressoutsidecore/simctl.ts, or namesScopedSimctlArgsoutsidecore/simctl.tsandcore/tool-provider.ts. This catches a forged brand, which includessimctl.run(x as unknown as ScopedSimctlArgs). - Any reference to the identifier
scopeSimctlArgs(import specifier, call or member access) outsidecore/simctl.ts,simulator-inventory.tsandlogs/doctor.ts. These are the owners whose calls name no device. Matching the identifier, not only a call, also catches an aliased import. This enforces rule 1 against the explicit-undefinedform, which the type system accepts.
Messages:
- checks 1-3:
builds or forges simctl argv outside core/simctl.ts; use scopeSimctlArgsForDevice/runSimctlForDevice or a SimulatorAddress from simulatorAddressFor(device). - check 4:
set-scope simctl builder outside its owners; a call that names a udid takes its set from the device (scopeSimctlArgsForDevice) or its SimulatorAddress.
This is not R9 (the type-cycle ratchet) and not R13 (ambient host authority). It is a separate owner rule.
Completion conditions
- Type proofs (each directive must be an error on the branch; the ones on existing APIs compile on origin/main, so they fail there):
packages/contracts/src/network-runtime.test.ts:24:// @ts-expect-error Raw simctl argv cannot cross the Apple tool port; scope it in platform-apple.packages/platform-apple/src/core/__tests__/simctl.test.ts: onscopeSimctlArgs(['list'])(no scope), and on aSimulatorAddressobject literal{ udid: 'sim-1', simulatorSetPath: undefined }.- A snapshot-source test: on a
SnapshotSourceTargetliteral that hasudidand nosimulator. packages/platform-apple/src/core/__tests__/tool-provider.test.ts: onresolveAppleToolProvider().simctl.run(['spawn', 'sim-1', 'bridge']).
scripts/layering/apple-simulator-scope-policy.test.tscovers these cases with inline sources:- flagged:
runXcrun(['simctl', 'spawn', udid, binary])underfoldable/andrunCmdBackground('xcrun', ['simctl', 'spawn', udid, bridge])undersnapshot-source/(the two fix(ios): spawn the AX bridge and fold helper inside the scoped simulator set #2818 forms). - flagged:
scopeSimctlArgs(['spawn', udid, bin], { simulatorSetPath: undefined })underfoldable/(the explicit-undefinedfix(ios): spawn the AX bridge and fold helper inside the scoped simulator set #2818 form), andimport { scopeSimctlArgs as scope } from '../core/simctl.ts'undersnapshot-source/. - flagged:
resolveAppleToolProvider().simctl.run(['spawn', udid, bin] as unknown as ScopedSimctlArgs)undersrc/, and{ udid, simulatorSetPath } as SimulatorAddressunderpackages/platform-apple/src/foldable/. - flagged:
['--set', path, ...args]outside the owner. - not flagged: the same sources at
core/simctl.ts; therunXcruncast atcore/tool-provider.ts;scopeSimctlArgsinsimulator-inventory.tsandlogs/doctor.ts; test files;{ tool: 'devicectl', args: [...] }.
- flagged:
pnpm check:layeringpasses on the branch with zero R79 violations. The PR description records two manual mutations, each reverted before commit: (a) put the pre-fix(ios): spawn the AX bridge and fold helper inside the scoped simulator set #2818runXcrun(['simctl', 'spawn', device.id, binary, payload], …)back intofoldable/simulator-hid.ts, andpnpm check:layeringfails with R79; (b) changehost.tstoscopeSimctlArgs([...], { simulatorSetPath: undefined }), and it fails with R79 check 4.- Existing argv tests keep their expected arrays. Only call shapes change (sections 3 and 4).
- Gates:
pnpm typecheck,pnpm check:layering,pnpm check:affected --run,pnpm check:fallow,pnpm check:production-exports,pnpm format. - No live device is needed, because no argv changes. This holds because doctor keeps set scope (
helpwith no--set), and the log-stream and bridge-spawn callers take the same set from the sameDeviceInfoas before, only through a typed path. This matches the refactor(ios): one simctl --set builder and one simulator state parser #2824 validation.
Non-goals
- Checking that the udid written inside
argsequals the address or device that scopes the call (['spawn', otherUdid]). All*ForDevicecallers writedevice.idtoday. A builder that inserts the udid itself would change every call shape and is a separate change. - A variable-built
'simctl'element (const t = 'simctl'; runXcrun([t, …])). Check 1 matches literals only, like the manual refactor(ios): one simctl --set builder and one simulator state parser #2824 grep it replaces. devicectlagainst a scoped-set simulator (fold: a scoped simulator set fails at the devicectl display inventory before HID dispatch #2871). devicectl has no--setequivalent. fix(ios): spawn the AX bridge and fold helper inside the scoped simulator set #2818's live run shows it cannot see a simulator outside the default set ("The specified device was not found"). There is nothing to scope, so a scope builder cannot fix it. The correct fix is the admission refusal atappleFoldFact(foldable/runtime.ts:28), or a set-aware source, as fold: a scoped simulator set fails at the devicectl display inventory before HID dispatch #2871 specifies, with its own live evidence. This issue does not close fold: a scoped simulator set fails at the devicectl display inventory before HID dispatch #2871. Only two devicectl sites reach simulators (core/display-inventory.ts:101,core/hinge-angle.ts:32), and both are foldable-only.- Converting
runXcrun(buildSimctlArgsForDevice(...))callers (core/simulator.ts,runner/runner-session.ts,runner/runner-disposal.ts,src/platform-runtime-runtime-hints.ts, ...) torunSimctlForDevice. They already take device scope. - Routing
snapshot-source/host.ts'srunCmdBackground('xcrun', …)through the Apple tool provider. fix(ios): spawn the AX bridge and fold helper inside the scoped simulator set #2818 rejected this as out of scope. Its argv takes device scope throughSimulatorAddress, and check 1 covers a literal bypass. - Dev scripts under
scripts/(ios-snapshot-benchmark,ios-ax-bridge-spike). They drive the default set on purpose and are not shipped. xcodebuilddestinations and xctestrun preparation for scoped sets (runner/runner-device-set.ts), which already have their own owner.- Treating failures from scoped calls as errors. Tolerated-failure probes (
allowFailure) keep their current outcomes.
Dependencies / related
- Builds on refactor(ios): one simctl --set builder and one simulator state parser #2824 (merged; central builders, closes refactor(platform-apple): one simctl --set builder and one simulator state parser #2794) and fix(ios): spawn the AX bridge and fold helper inside the scoped simulator set #2818 (merged; closes fix(ios): pass the scoped simulator set to the AX bridge and fold
simctl spawncalls #2784). No blockers. - fold: a scoped simulator set fails at the devicectl display inventory before HID dispatch #2871 (fold on a scoped set fails at devicectl): linked, not closed. See Non-goals.
- ADR 0019 (request-bound platform runtime): the host port stays a contract port. Platform-apple owns argv, and the root host only executes it. No new facade, subpath or runtime import.
- Eager-closure budgets (
scripts/__tests__/eager-closure-budgets.ts): unaffected. The contract change is type-only.core/simctl.ts,core/tool-provider.tsandcore/tool-provider-types.tsgain onlyimport type.simctl-facade.tskeeps its closure. - ADR 0027: not touched (no command-registry change).
- Fallow:
ScopedSimctlArgs,SimulatorSetScope,SimulatorAddressandAppleSimctlToolProviderare consumed across files, so no unused-export baseline entries are needed.buildSimctlArgsis deleted with its test. No file renames, so no baseline moves. Thesnapshot-source-facade.tsentry in.fallowrc.jsonkeeps its export list; only the shape ofSnapshotSourceTargetchanges.
Size and cost
- One PR, estimated gross diff 420-560 lines.
- Production: contracts about +12;
core/simctl.tsabout +20/-12 (address type, mint,*ForAddress,buildSimctlArgsremoved); provider types andrunXcrunabout +10/-4; call sites about 45 changed lines (doctor, log-predicate and start, network, deployment, and the snapshot target move, which is mostly.udidto.simulator.udid). Net production lines: about +35 to +55. The earlier estimate of "20-80 lines removed" does not hold, because refactor(ios): one simctl --set builder and one simulator state parser #2824 already made the removals. - Guard: about 100 lines of rule plus about 150 lines of test, plus about 4 lines of wiring.
- Tests: call-shape and fixture updates of about 60-100 lines (log-predicate, snapshot-source fixtures, the four explicitly typed
simctl.runexecutors), the type proofs, and the regenerated contracts-exports snapshot. - Effort: about one day for a Sonnet-class agent. The risk is low. No argv changes, and the new failures happen at compile time and layering time only.
Activity
refactor(ios): make unscoped simctl argv a type error at the Apple to…
refactor(ios): brand the simctl builders and require the brand where …