You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
A simctl argv that names a udid takes its set from the record that supplied the udid. That record is a DeviceInfo (through the *ForDevice builders) or a SimulatorAddress minted from a DeviceInfo. 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's help (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.
Every production argv that starts with 'simctl' is built in packages/platform-apple/src/core/simctl.ts:30,34 or in the provider executors in core/tool-provider.ts:43,78. The only '--set' literal is at core/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 optional params.simulatorSetPath. Names a device. The caller logs/start.ts:160-168 already holds the DeviceInfo.
snapshot-source/host.ts:73: spawn <udid> <bridge> serve, with the set read from SnapshotSourceTarget.simulatorSetPath? (snapshot-source/types.ts:26), next to a separate udid field. Names a device. The target is built in snapshot-target.ts:113-121 from a DeviceInfo.
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:
The app-log executable: 'xcrun' spec (logs/start.ts:160).
The host port. AppleToolRequest.args is readonly string[] (packages/contracts/src/platform-runtime-host.ts:58-63), so host.appleTools.run({ tool: 'simctl', args: ['boot', device.id] }) compiles. packages/contracts/src/network-runtime.test.ts:24 asserts 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 through scopeSimctlArgs*. Doctor sends ['help'] and names no device.
AppleToolProvider.simctl.run(args: string[]) (core/tool-provider.ts:32,121, type in core/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-provider subpath (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:
declareconstscopedSimctlArgs: unique symbol;/** simctl argv (after the tool name) already scoped to its simulator set; minted only by platform-apple. */exporttypeScopedSimctlArgs=readonlystring[]&{readonly[scopedSimctlArgs]: true};exporttypeAppleToolRequest=Readonly<{timeoutMs?: number;allowFailure?: boolean}>&(|Readonly<{tool: 'simctl';args: ScopedSimctlArgs}>|Readonly<{tool: 'devicectl'|'xctrace';args: readonlystring[]}>);
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. */exporttypeSimulatorSetScope=Readonly<{simulatorSetPath: string|undefined}>;declareconstsimulatorAddress: unique symbol;/** A simulator udid with the set that holds it; minted only from a DeviceInfo. */exporttypeSimulatorAddress=Readonly<{udid: string;simulatorSetPath: string|undefined}>&{readonly[simulatorAddress]: true;};exportfunctionsimulatorAddressFor(device: DeviceInfo): SimulatorAddress;exportfunctionscopeSimctlArgs(args: readonlystring[],scope: SimulatorSetScope): ScopedSimctlArgs;exportfunctionscopeSimctlArgsForAddress(address: SimulatorAddress,args: readonlystring[]): ScopedSimctlArgs;exportfunctionscopeSimctlArgsForDevice(device: DeviceInfo,args: readonlystring[]): ScopedSimctlArgs;exportfunctionbuildSimctlArgsForAddress(address: SimulatorAddress,args: readonlystring[]): string[];exportfunctionbuildSimctlArgsForDevice(device: DeviceInfo,args: readonlystring[]): string[];// unchangedexportfunctionrunSimctlForDevice(...): Promise<ExecResult>;// unchanged
simulatorAddressFor keeps today's device-scope semantics: simulatorSetPath is device.simulatorSetPath when isIosFamily(device) && device.kind === 'simulator', else undefined. scopeSimctlArgsForDevice(device, args) becomes scopeSimctlArgsForAddress(simulatorAddressFor(device), args), so device scope has one implementation.
scopeSimctlArgs loses its options = {} default. The simulatorSetPath key 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 to buildSimctlArgsForAddress with the same expected array.
core/simctl.ts mints SimulatorAddress (one cast in simulatorAddressFor) and ScopedSimctlArgs (one cast in scopeSimctlArgs). Import ScopedSimctlArgs with import type, so the eager import closure of core/simctl.ts stays the same.
The --set trimming (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 type AppleToolProvider.simctl with it. devicectl and macosHelper keep AppleXcrunToolProvider.
runXcrun is the one place in tool-provider.ts that mints the brand: provider.simctl.run(toolArgs as unknown as ScopedSimctlArgs, options). runXcrun cannot import a mint function from core/simctl.ts, because core/simctl.ts already imports runXcrun from it. The argv that reaches runXcrun is covered by check 1 (literal 'simctl' arrays) and by the host-port brand (src/platform-runtime-apple-tool-host.ts).
Make coerceRun generic over its argument type so that normalizeAppleToolProvider keeps coercing simctl.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 to string[]. Widen them to readonly string[], or type the slot as AppleToolProvider['simctl']['run']: snapshot-target.test.ts:21, snapshot-route.test.ts:492,558,732, and test/integration/provider-scenarios/providers.ts:15,218 (simctl? handler slot and simctlDeviceLifecycleHandler). No new export is needed for the integration harness. @agent-device/platform-apple is "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 use scopeSimctlArgsForDevice here. It would add --set <set> for a scoped-set simulator, and xcrun simctl --set <missing path> help exits 1, so checks.simctlAvailable would then depend on whether the set exists. test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts already asserts ['help'].
logs/log-predicate.ts:24-44: buildIosSimulatorLogStreamArgs(device: DeviceInfo, params: { appBundleId; executableName? }) builds with buildSimctlArgsForDevice(device, ['spawn', device.id, 'log', 'stream', …]). logs/start.ts:162 passes its device. log-predicate.test.ts:19-40 changes its call shape only. The expected array stays the same.
Snapshot target: replace udid and simulatorSetPath? with simulator: SimulatorAddress in SnapshotSourceTarget (snapshot-source/types.ts:18-27), in SimulatorSnapshotTarget (snapshot-target.ts:21-29) and in the host's start parameter (snapshot-source/types.ts:87, host.ts:63). The resolver sets simulator: simulatorAddressFor(device) (snapshot-target.ts:113-121). The udid then has one source. Readers of target.udid read target.simulator.udid: snapshot-source/adapter.ts:124, snapshot-source/lifecycle.ts:58,67,130,140,147, snapshot-route.ts:280,286. host.ts:71-86 becomes runCmdBackground('xcrun', buildSimctlArgsForAddress(target.simulator, ['spawn', target.simulator.udid, bridgePath, 'serve', …]), …). Test fixtures (targetForTest and the snapshot-target and snapshot-route tests) mint the address with simulatorAddressFor(<fixture device>).
network/runtime.ts:92-108: build the whole tail (spawn … log show … --start|--last) first, then scope it once. Today it calls args.push after scoping, which a readonly branded array does not allow.
deployment/runtime.ts:231: runAppleTool takes AppleToolRequest (or a distributive omit), not Omit<AppleToolRequest, 'allowFailure'>. A plain Omit collapses 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 ArrayExpression whose first element is the string literal 'simctl', outside packages/platform-apple/src/core/simctl.ts and packages/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 on runXcrun, runCmd, runCmdBackground and an executable: 'xcrun' spec.
A type assertion (TSAsExpression / TSTypeAssertion) whose target type names SimulatorAddress outside core/simctl.ts, or names ScopedSimctlArgs outside core/simctl.ts and core/tool-provider.ts. This catches a forged brand, which includes simctl.run(x as unknown as ScopedSimctlArgs).
Any reference to the identifier scopeSimctlArgs (import specifier, call or member access) outside core/simctl.ts, simulator-inventory.ts and logs/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-undefined form, 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: on scopeSimctlArgs(['list']) (no scope), and on a SimulatorAddress object literal { udid: 'sim-1', simulatorSetPath: undefined }.
A snapshot-source test: on a SnapshotSourceTarget literal that has udid and no simulator.
packages/platform-apple/src/core/__tests__/tool-provider.test.ts: on resolveAppleToolProvider().simctl.run(['spawn', 'sim-1', 'bridge']).
scripts/layering/apple-simulator-scope-policy.test.ts covers these cases with inline sources:
flagged: resolveAppleToolProvider().simctl.run(['spawn', udid, bin] as unknown as ScopedSimctlArgs) under src/, and { udid, simulatorSetPath } as SimulatorAddress under packages/platform-apple/src/foldable/.
flagged: ['--set', path, ...args] outside the owner.
not flagged: the same sources at core/simctl.ts; the runXcrun cast at core/tool-provider.ts; scopeSimctlArgs in simulator-inventory.ts and logs/doctor.ts; test files; { tool: 'devicectl', args: [...] }.
pnpm check:layering passes 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 into foldable/simulator-hid.ts, and pnpm check:layering fails with R79; (b) change host.ts to scopeSimctlArgs([...], { simulatorSetPath: undefined }), and it fails with R79 check 4.
Existing argv tests keep their expected arrays. Only call shapes change (sections 3 and 4).
No live device is needed, because no argv changes. This holds because doctor keeps set scope (help with no --set), and the log-stream and bridge-spawn callers take the same set from the same DeviceInfo as 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 args equals the address or device that scopes the call (['spawn', otherUdid]). All *ForDevice callers write device.id today. A builder that inserts the udid itself would change every call shape and is a separate change.
Converting runXcrun(buildSimctlArgsForDevice(...)) callers (core/simulator.ts, runner/runner-session.ts, runner/runner-disposal.ts, src/platform-runtime-runtime-hints.ts, ...) to runSimctlForDevice. They already take device scope.
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.ts and core/tool-provider-types.ts gain only import type. simctl-facade.ts keeps its closure.
ADR 0027: not touched (no command-registry change).
Fallow: ScopedSimctlArgs, SimulatorSetScope, SimulatorAddress and AppleSimctlToolProvider are consumed across files, so no unused-export baseline entries are needed. buildSimctlArgs is deleted with its test. No file renames, so no baseline moves. The snapshot-source-facade.ts entry in .fallowrc.json keeps its export list; only the shape of SnapshotSourceTarget changes.
Size and cost
One PR, estimated gross diff 420-560 lines.
Production: contracts about +12; core/simctl.ts about +20/-12 (address type, mint, *ForAddress, buildSimctlArgs removed); provider types and runXcrun about +10/-4; call sites about 45 changed lines (doctor, log-predicate and start, network, deployment, and the snapshot target move, which is mostly .udid to .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.run executors), 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.
Purpose
A
simctlcall 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 withInvalid device, or the call targets a different simulator.This has shipped twice already:
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.core/simctl.ts. It checked this by hand withgit grep "'--set'". Nothing keeps it true.The rule this issue enforces
DeviceInfo(through the*ForDevicebuilders) or aSimulatorAddressminted from aDeviceInfo. A caller never passes the set as a separate value next to a udid.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
simulatorSetPathkey alone does not give rule 1. A new helper can writescopeSimctlArgs(['spawn', udid, bin], { simulatorSetPath: undefined }). That compiles, and it is the #2818 failure with the set written out asundefined. 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:
'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.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.options = {}), so leaving the set out compiles.runXcrun(args: string[])(core/tool-provider.ts:117).runCmdBackground('xcrun', …)(snapshot-source/host.ts:71).executable: 'xcrun'spec (logs/start.ts:160).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:The contract change is type-only and adds no runtime module.
src/platform-runtime-apple-tool-host.tsstill spreads[request.tool, ...request.args]intorunXcrun. The brand staysreadonly, so no caller cansplicethe--setprefix away after scoping. Regeneratescripts/layering/contracts-exports.snapshot.jsonwith the existing generator.2. Set scope, device scope and the simulator address (
packages/platform-apple/src/core/simctl.ts)simulatorAddressForkeeps 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.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.--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)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).coerceRungeneric over its argument type so thatnormalizeAppleToolProviderkeeps coercingsimctl.run.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.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-scopeAdd
scripts/layering/apple-simulator-scope-policy.tsand its test. Register it inscripts/layering/check.tsnext toapple-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 readscontext.allTypeScriptSourcesand keeps production files underpackages/*/src/andsrc/(it skips*.test.ts,__tests__/,*.fixtures.tsandscripts/). It parses them withoxc-parserand reports: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.'--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.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).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:
builds or forges simctl argv outside core/simctl.ts; use scopeSimctlArgsForDevice/runSimctlForDevice or a SimulatorAddress from simulatorAddressFor(device).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
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 }.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: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).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/.resolveAppleToolProvider().simctl.run(['spawn', udid, bin] as unknown as ScopedSimctlArgs)undersrc/, and{ udid, simulatorSetPath } as SimulatorAddressunderpackages/platform-apple/src/foldable/.['--set', path, ...args]outside the owner.core/simctl.ts; therunXcruncast atcore/tool-provider.ts;scopeSimctlArgsinsimulator-inventory.tsandlogs/doctor.ts; test files;{ tool: 'devicectl', args: [...] }.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.pnpm typecheck,pnpm check:layering,pnpm check:affected --run,pnpm check:fallow,pnpm check:production-exports,pnpm format.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
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.'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.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.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.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.allowFailure) keep their current outcomes.Dependencies / related
simctl spawncalls #2784). No blockers.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.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
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.simctl.runexecutors), the type proofs, and the regenerated contracts-exports snapshot.