Refuse the XCTestDevices redirect when an Xcode xcrun shim (simctl or devicectl) has an armed first-launch hook: scoped simulator set deletion
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 5/5
- Tempo stimato
- Più di una settimana
- Idoneità per principianti
- 35/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Attiva
- Stack tecnologico
- ios, typescript
- Ambito
- mobile-dev, testing, tooling
Direzione di ricerca
Start with packages/platform-apple/src/runner/runner-device-set.ts and the shared tool definitions in packages/platform-apple/src/core/tool-provider.ts, then follow the host.ts delegates and runner error classification files named in the issue. Run the mirrored probe and runner-device-set tests; done means both simctl and devicectl hooks are evaluated fail-closed, refusal occurs before xcodebuild or runner launch, and the typed error details and classification are covered.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
Purpose
Bug class: agent-device points ~/Library/Developer/XCTestDevices at a user-owned simulator set while Apple's first-launch cleanup can run. That cleanup deletes every device in the user's set (data loss).
Found while validating #2917 (see the #2878 evidence, "Live: scoped simulator set"). Reproduced on origin/main dbc08a417a, re-checked on 2db7c42a8b and 6428c54853. The code has not changed.
What happens (watcher log 2878b-watch.log, request log 2878b-state-main/sessions/s2878bm/requests/*.ndjson):
- A cold
open com.apple.Preferences --ios-simulator-device-set <set> --udid <udid>finds no cached runner (runner_xctestrun_cachereason: missing_xctestrun). It starts the runnerxcodebuild build-for-testinginsidewithXcodebuildSimulatorSetRedirect(packages/platform-apple/src/runner/runner-artifact.ts:475), which callsacquireXcodebuildSimulatorSetRedirect(runner-device-set.ts:68). That call swapsXCTestDevicesfor a symlink to the user's set (runner-device-set.ts:116-119,installDeviceSetRedirectat:164). - At the same time,
openrunsxcrun simctl --set <set> launch <udid> ….xcrun simctlresolves to the Xcode shim$DEVELOPER_DIR/usr/bin/simctl, a bash script. The shim compares its hard-codedEXPECTED_VERSION(Xcode 26.2:1051.17.7) with the installed CoreSimulatorCFBundleVersion(this host:1155.4). When they differ, the shim runsxcodebuild -runFirstLaunchbefore every simctl call. xcodebuild -runFirstLaunchrunssimctl --set ~/Library/Developer/XCTestDevices delete all. The watcher caught this at 16:44:50 as pid 67557. The cleanup list inDVTSystemPrerequisitesnames~/Library/Developer/XCTestDevices. Because of the symlink, this deletes the user's scoped set. The device directory disappears about 4 s after the redirect.- The launch fails with
Invalid device: <udid>(xcrun exit 148). The build's-destination platform=iOS Simulator,id=<udid>now names a device that no longer exists, so the build never producesBuild/Products. Every cold open repeats steps 1-4. The run lost 3 throwaway devices across 3 runs (UDIDs 83D4E483…, 7D7DBA07…, 88F78C99…).
simctl is not the only shim with this hook. On the same host, $DEVELOPER_DIR/usr/bin/devicectl is a zsh script with the same pre-exec check: EXPECTED_VERSION="506.6" against the CFBundleVersion of /Library/Developer/PrivateFrameworks/CoreDevice.framework/Versions/A/Resources/Info.plist (this host: 629.3), then xcodebuild -runFirstLaunch when they differ. agent-device calls devicectl for scoped simulators during a runner session, and nothing gates that call on the scoped set:
- every simulator screenshot:
captureSimulatorScreenshotWithFallback→resolveCaptureDisplay(core/screenshot.ts:141) →queryAppleDisplayInventory→devicectl device info displays --device <udid>(core/display-inventory.ts:101); - every simulator recording:
buildAppleSimulatorRecordVideoArgs→resolveAppleCaptureDisplay(simctl-facade.ts:24-26), the same devicectl call.
So a host whose CoreSimulator version matches its Xcode but whose CoreDevice version does not wipes the user's set on the first screenshot of a runner session. A gate that reads only the simctl shim would call that host safe.
Why the redirect exists: #353 / PR #354 (48aa32921d). xcodebuild resolves an id=<udid> destination only through the default set and XCTestDevices. It has no --set option. Two phases pass id=<udid> for a simulator today:
build-for-testing:resolveRunnerBuildDestination(apple-runner-platform.ts:228-237) returnsplatform=<P> Simulator,id=<udid>. Whether the build needs a device at all is a separate proposal (see "Dependencies / related"); this issue does not change the build.test-without-building:resolveRunnerDestination(apple-runner-platform.ts:217-226) must name the booted device.runner-session.ts:286-290acquires the redirect beforelaunchRunnerProcessand holds it for the whole runner lifetime. It is released at:319on a failed start and at session close. Handoff is refused while it is held (runner-session.ts:793-795, reasonsimulator_set_redirect), and adoption is refused for scoped sets (runner-adoption.ts:172-173). On an armed host, any call through an armed shim during that lifetime wipes the user's set: the AX-bridgesimctl spawn(#2784), app launches, and the screenshot and recording devicectl calls above.
What lists the sites:
- Redirect installs.
git grep -n "acquireXcodebuildSimulatorSetRedirect\|withXcodebuildSimulatorSetRedirect" -- packages src ':!**/__tests__/**'. Two production callers reachacquireXcodebuildSimulatorSetRedirect: the build, throughwithXcodebuildSimulatorSetRedirect(runner-artifact.ts:475→runner-device-set.ts:68), and the session start (runner-session.ts:289). The session imports it through the re-exportrunner-xctestrun.ts:23(runner-session.ts:23-33). That re-export is the production import path, and the session tests mock that module. Both callers pass through the one function, so a gate inside it covers both. - Shims that can run first-launch. The xcrun tools agent-device names are declared by the argv types in
core/tool-provider.ts:ScopedSimctlCommand(simctl) andXcrunToolArgs(devicectl | xcdevice | xctrace,:141).AppleToolProviderroutessimctlanddevicectl(:100-106). On Xcode 26.2,simctlanddevicectlare the only files in$DEVELOPER_DIR/usr/binthat contain-runFirstLaunch.xcdeviceandxctraceare Mach-O binaries with no such text.xcodebuildis run directly, not through a shim.
Required behavior
1. The redirect is refused when any xcrun shim has an armed first-launch hook (fail closed)
Rule: acquireXcodebuildSimulatorSetRedirect installs the redirect only when no Xcode shim that agent-device calls through xcrun has an armed first-launch hook. Both production callers go through this function, so the rule gates the build redirect and the session redirect alike.
Where it runs. Inside acquireXcodebuildSimulatorSetRedirect, after the lock is held and reconcileXcodebuildSimulatorSetRedirect has run, and only when needsRedirect is true (runner-device-set.ts:116). Default-set simulators return before the lock (:80-83) and a scoped set that already is XCTestDevices has needsRedirect === false. Neither pays for the probe. When the probe reports an armed shim, nothing is renamed or symlinked. The lock is given back through the existing handBackDeviceSet, and the refusal is thrown as is. It is not wrapped by redirectFailure ("Failed to redirect XCTest device set path"), because nothing failed to redirect.
Timing: the refusal comes before any xcodebuild phase. On a cold start the build path reaches the gate first (runner-artifact.ts:475 acquires before runCmdStreaming spawns build-for-testing). The refusal leaves withXcodebuildSimulatorSetRedirect before its task runs, so the build catch (runner-artifact.ts:517-537) does not rewrap it as xcodebuild build-for-testing failed. On a warm start, the session acquire (runner-session.ts:286-290) refuses before launchRunnerProcess spawns test-without-building. An armed host therefore never spends a runner build it cannot use. The progress line "Building Apple runner..." (runner-artifact.ts:251-255) is emitted before the gate and may still appear.
The shim list comes from one declaration. Add a const tuple in core/tool-provider.ts, for example XCRUN_TOOL_NAMES = ['simctl', 'devicectl', 'xcdevice', 'xctrace'] as const. Derive the XcrunToolArgs tool union from it, so a tool that runXcrun learns to run is also probed. Minimum required coverage: simctl and devicectl, the two AppleToolProvider routes (:100-106). A tool whose resolved file is not a text script, or whose text has no -runFirstLaunch, has no hook.
Per-tool probe. For each tool:
- Locate the shim with
runAppleToolCommand('xcrun', ['--find', <tool>], { allowFailure: true, timeoutMs })(runnerhost.ts:184).runXcrun(host.ts:185) cannot run this: its argv type isScopedSimctlCommand | XcrunToolArgs, and['--find', …]is neither. - Read the shim text from the local filesystem. The redirect itself is a local-filesystem change on this same host (
runner-device-set.tsusesnode:fs). - When the text contains
-runFirstLaunch, read both values from the shim text. Do not hard-code them.EXPECTED_VERSION="<v>"gives the expected version. The quoted path that ends inInfo.pliston theCURRENT_VERSION=line gives the framework Info.plist (CoreSimulator forsimctl, CoreDevice fordevicectlon Xcode 26.2). - Read the installed
CFBundleVersionwithreadApplePlistJson(<that path>)(runnerhost.ts:186).
The hook is armed when the two versions differ, or when any of them cannot be read: shim path not found, text unreadable, EXPECTED_VERSION missing, no Info.plist path on the CURRENT_VERSION line, plist unreadable, or CFBundleVersion missing. A shim that contains -runFirstLaunch but cannot be evaluated fails closed. The whole probe shares one budget of 2 s or less. A probe that times out counts as armed.
xcodebuild -checkFirstLaunchStatus is not a valid probe. It exits 0 on the reproducing host while both shims run first-launch on every call.
Data shape (a per-tool list; the gate refuses when any entry is armed):
type XcrunToolName = (typeof XCRUN_TOOL_NAMES)[number];
type XcrunShimFirstLaunchHook =
| { tool: XcrunToolName; shimPath: string; hook: 'none' } // not a script, or no -runFirstLaunch
| {
tool: XcrunToolName;
shimPath: string;
hook: 'disarmed'; // versions equal
expectedVersion: string;
frameworkInfoPlistPath: string;
installedVersion: string;
}
| {
tool: XcrunToolName;
shimPath: string | null; // null: xcrun --find failed or timed out
hook: 'armed';
expectedVersion: string | null; // null: unparsable
frameworkInfoPlistPath: string | null; // null: unparsable
installedVersion: string | null; // null: unreadable
};
type XctestDeviceSetCleanupArming = readonly XcrunShimFirstLaunchHook[];
The probe lives in packages/platform-apple/src/core/ beside tool-provider.ts (a module named for its question, for example xcrun-shim-first-launch.ts, with a mirrored test). The runner reaches it through a new host.ts delegate, as it reaches runXcrun and readApplePlistJson today.
Refusal error. AppError('COMMAND_FAILED', …) with details.reason: 'xctest_device_set_cleanup_armed', a hint, and details.xcrunShims: XctestDeviceSetCleanupArming (every entry, so the reader sees which shim is armed and which are not). The message names each armed tool with its expected and installed versions, for example Xcode's devicectl expects CoreDevice 506.6; installed 629.3.
Reason and hint come from one declaration. Follow runner-dev-tools-security.ts:35-44. Add a row to RUNNER_ERROR_RULES (runner-error-classification.ts) that matches code: 'COMMAND_FAILED' and a typed details predicate on xcrunShims, and add the reason to RUNNER_STARTUP_FAILURE_REASONS (:154-162). The refusal builder constructs the observed error with the typed detail and calls classifyRunnerStartupFailure. It then throws with the reason and hint that the call returns, and never names either itself. The row's hint is static text. The runtime versions travel in details.xcrunShims and in the message, never in the hint. So the row and the hint stay one declaration, and the versions are still reported. The hint says two things: while the selected Xcode does not match the installed CoreSimulator or CoreDevice framework, every call through that Xcode's shim runs xcodebuild -runFirstLaunch, which deletes all devices in ~/Library/Developer/XCTestDevices; and the fix is to select the Xcode that installed those frameworks (xcode-select -s or DEVELOPER_DIR), with details.xcrunShims naming each version. Callers branch on details.reason, never on message text.
Injection for tests. Tests must not touch the host. Replace the xcrun --find step with an injected per-tool map, for example xcrunShimPaths?: Readonly<Partial<Record<XcrunToolName, string>>> beside the existing xctestDeviceSetPath, backupPath and lockDirPath options. A tool that is missing from the map is treated as "not found" (armed), so a test cannot pass by leaving a tool out. The plist path comes from the fake shim text, as in production. Tests read it through a scoped AppleToolProvider (withAppleToolProvider) whose plist.readJson is a fake, so no test needs plutil.
2. Help / docs
Update the --ios-simulator-device-set help (src/commands/schema/cli-help.ts) and website/docs/docs/commands.md. State two things, one sentence each. First: runner-backed commands refuse a scoped set with xctest_device_set_cleanup_armed when the selected Xcode does not match the installed CoreSimulator or CoreDevice framework. Second: a scoped set is not protected from a first-launch cleanup that starts outside agent-device during a live runner session (non-goal below).
Completion conditions
Unit (each must fail on origin/main):
- Per-tool arming refusal (
runner-device-set.test.ts, with injected shim paths and a fake plist reader). Every refused case also asserts thatXCTestDevicesis still the original directory (not a symlink), that no backup exists, and that the lock dir is released:- simctl
EXPECTED_VERSION="1051.17.7"+-runFirstLaunchwith plist1155.4, devicectl without a hook → throwsdetails.reason === 'xctest_device_set_cleanup_armed' - simctl versions equal, devicectl
EXPECTED_VERSION="506.6"+-runFirstLaunchwith plist629.3→ refused, anddetails.xcrunShimshas simctldisarmedand devicectlarmed - both shims with equal versions → redirect installed as today
- either shim without
-runFirstLaunch(run once per tool, with the other shim at equal versions) → redirect installed -runFirstLaunchpresent butEXPECTED_VERSIONmissing, or no Info.plist path on theCURRENT_VERSIONline, or the plist unreadable → refused (fail closed), once per tool- a tool that
xcrun --findcannot locate (absent from the injected map) → refused - the plist path is read from the shim text: a fake shim naming
<tmp>/Other.framework/Info.plistreads that path (assert the fake reader's argument) - default-set simulator and a scoped set equal to
XCTestDevices→ probe never called (spy)
- simctl
- Refusal before the build. An
ensureXctestrunArtifacttest for a scoped-set simulator on a cache miss with an armed shim. It throwsdetails.reason === 'xctest_device_set_cleanup_armed'and the mockedrunCmdStreamingis never called (nobuild-for-testing). It fails on origin/main because origin/main redirects and builds. - Refusal before the runner launch. A session start with a reused artifact and an armed shim. It throws the reason, and
launchRunnerProcessis never called (notest-without-building). - Reason and hint pair.
HINT_FOR_REASONinrunner-startup-failure-reasons.test.ts:56(Record<RunnerStartupFailureReason, string>) gains the reason, and the type system forces it. Assert that the thrownhintequals the row's hint for two different version pairs (the hint does not change with versions) and that the message contains both versions. - Shim list from the declaration. A test asserts that the probe visits every
XCRUN_TOOL_NAMESentry, so a tool added to the union is probed without a second edit.
Live (throwaway scoped set; never the default set, never reserved CI devices). Prepare: xcrun simctl --set <tmp-set> create … + boot, plus a fresh AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH to force missing_xctestrun. Run a watcher that logs readlink ~/Library/Developer/XCTestDevices, ls <tmp-set> and pgrep -fl "xcodebuild|simctl|devicectl" every 0.5 s. Record the probe output for both shims first: xcrun --find simctl, xcrun --find devicectl, the EXPECTED_VERSION line and the CURRENT_VERSION Info.plist path from each shim, and CFBundleVersion of each named plist.
- Leg A: armed host (the maintainer host with Xcode 26.2 qualifies: simctl 1051.17.7 vs CoreSimulator 1155.4, devicectl 506.6 vs CoreDevice 629.3):
- Pre-fix (origin/main): the device directory disappears during
open, andInvalid deviceis reported. This reproduces the bug. - Post-fix: run cold
open→snapshot→screenshot→close. The runner start that the run triggers ends withdetails.reason: xctest_device_set_cleanup_armed, the static hint, anddetails.xcrunShimslisting both shims asarmedwith the versions above. The request log shows the refusal beforebuild-for-testingstarts: the watcher never shows anxcodebuild build-for-testingprocess for the fresh derived path, and that path has noBuild/Products. The watcher never showsXCTestDevicesas a symlink. Afterclose, the device directory is still present andsimctl --set <tmp-set> liststill shows the device. Thescreenshotstep runsdevicectl device info displaysthrough the armed shim; the device must survive it.openitself may succeed, because itssimctl launchis not runner-backed. Ifsnapshothas a non-runner backend available (AX bridge), it succeeds.
- Pre-fix (origin/main): the device directory disappears during
- Leg B: non-armed host (every shim
noneordisarmed, for example a Mac whose selected Xcode installed the current CoreSimulator and CoreDevice, or the macOS CI image): coldopen+snapshot+screenshot+closeon a scoped set succeed. The symlink appears only while anxcodebuildphase holds the redirect, as today, and the device survives. If the implementer's host is armed, post the probe output and Leg A, and hand Leg B to the maintainer as the one remaining merge condition. - Attach the probe output, both watcher logs and the request logs to the PR.
Gates: pnpm check:affected (or the gate docs/agents/testing.md selects for packages/platform-apple/src/runner and src/core), fallow production-unused exports, and the layering scan with the new files staged. No change to package entry points, so no eager-closure change.
Non-goals
- Changing the runner build destination or build argv. Building with
generic/platform=<P> Simulatorso thatbuild-for-testingneeds no redirect is a separate proposal with its own risks. See "Dependencies / related". This issue closes the reported repro without it, because the build's own acquire refuses beforebuild-for-testing. - Protecting a scoped set from a first-launch cleanup that something outside agent-device starts while a runner session holds the redirect (a user opening a newly installed Xcode, or running
xcodebuild -runFirstLaunchby hand). The redirect is required bytest-without-building, andxcodebuildhas no other way to find a device in a custom set. Closing this needs a spike with two questions. Does a runner started through the redirect keep working afterXCTestDevicesis restored at readiness? After the restore, does CoreSimulatorService still map theXCTestDevicespath to the user's devices in memory? If it does, restoring gives no protection. File that spike separately if this lands. Do not fold it in. - Noticing a change of Xcode, CoreSimulator or CoreDevice during a live session. Each acquire probes once, and the result holds until that redirect is released. A session that holds the redirect does not probe again. Selecting another Xcode or installing new frameworks mid-session is the external-trigger case above.
- Bypassing the Xcode shims (running CoreSimulator's
simctlor CoreDevice'sdevicectldirectly) to stop agent-device from triggering first-launch. This is an unapproved compatibility change to every simctl and devicectl call, and it would still leave external triggers open. - A shadow set with per-device symlinks, or clones in an agent-device-owned set. How CoreSimulator deletes a symlinked device is unverified, and a clone has a different UDID and different app state.
- A scoped set that is
XCTestDevices(sameResolvedPath→ no redirect). Apple's own cleanup already owns that set by design. - Xcode tools that agent-device runs outside
XCRUN_TOOL_NAMES(xcrun --sdk … --show-sdk-versioninrunner-cache-metadata.ts:286-288,xcrun --findincore/debug-symbols/symbolication.ts:105). They are not shims with a hook on Xcode 26.2. A future Xcode that adds the hook to another tool that agent-device calls through xcrun must add that tool to the declaration. - Splitting
runner-session.ts(1,353 lines, over the 1,000-line debt line). This change adds no behavior there. The only new thing that reaches its existing startup catch is the refusal error.
Constraints
- Changes stay in
packages/platform-apple/src/runner/*,packages/platform-apple/src/core/*(the tool-name declaration and the probe) and help/docs. No new package subpath, so there is no ADR 0019 eager-closure or ADR 0027 budget effect. No cross-package import, so R9 layering is unchanged. The runner reaches the core probe only throughrunner/host.ts. ADR 0005 (runner lifecycle) is unchanged: the session redirect lifetime, the handoff refusal (simulator_set_redirect) and the adoption refusal stay as they are. - Error behavior is keyed on
details.reason(AGENTS.md: typed reasons, never message sniffing). The reason and the static hint come from the one rule row. - The probe is a required precondition, not an optional probe: a timeout or an unreadable value refuses, and never lets the redirect through.
Estimated cost
One PR, about 1 agent-day plus the live legs. Gross diff about 300-400 lines, about 65 % of it tests. Net production about +90 to +120 lines:
- tool-name declaration + per-tool probe + host delegate: +60-80
- gate and refusal builder in
runner-device-set.ts: +15-20 - rule row + reason: +10
- help/docs: +4
This fits the 1,000-line budget in one PR. No split is needed.
Dependencies / related
- Found during #2917 validation (brand the simctl builders;
runXcrunrequires the brand). Recorded in the #2878 evidence ("Live: scoped simulator set"). Pre-existing: not a #2878, #2898 or #2917 regression. - Separate proposal, filed on its own: build the simulator runner with its family destination (
generic/platform=<P> Simulatorplus a pinnedARCHS), sobuild-for-testingneeds no redirect (draftdesign-runner-build-generic-destination.md). Its reasons do not depend on this issue: the build argv would agree with the cache key (runner-cache-metadata.ts:238), the host-wide redirect lock would no longer be held through a multi-minute build while other scoped-set starts give up after 30 s (runner-device-set.ts:18), and the redirect window gets shorter. It also changes every cold simulator build, including default-set users. It stays out of this issue for that reason. That proposal is blocked by this issue: once the build no longer callsacquireXcodebuildSimulatorSetRedirect, it must keep the pre-build refusal that completion condition 2 pins. - #2878 / #2898 / #2917: simctl set scope is enforced for every agent-device simctl argv. This issue is about the one place where agent-device changes the global XCTest set outside that scope.
- #2784 (AX bridge scoped set via
simctl spawn) and #2871 (devicectl display inventory for scoped sets): the in-session simctl and devicectl calls that trigger the cleanup on an armed host. #2871'sdevicectl device info displaysis why the gate reads the devicectl shim as well as the simctl shim. - #353 / #354: origin of the redirect. #1672 and #2598: redirect heal and lock ownership (unchanged here). #2803: audit umbrella.
- Blocked by: nothing.
- Lingua principale
- TypeScript
- Stelle
- 4.7k
- Fork
- 304
- Merge medio
- 11h 29m
- PR unite (30g)
- 514
Guida per i contributori
Apri la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di callstack/agent-device
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
callstack/agent-device#1869 ·
-
needs-triage
Difficoltà 5/5 Più di una settimana Idoneità per principianti 32/100
callstack/agent-device#2948 ·
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 55/100
callstack/agent-device#2946 ·
-
needs-triage
Difficoltà 4/5 3-5 giorni Idoneità per principianti 45/100
callstack/agent-device#2940 ·
-
needs-triage
Difficoltà 5/5 Più di una settimana Idoneità per principianti 38/100
callstack/agent-device#2906 ·
Tutte le issue di callstack/agent-device
Issue simili
-
Difficoltà 1/5 1-3 ore Idoneità per principianti 88/100
motiondivision/motion#3849 ·
-
Add: S Play Event HD Apertacheck:passed streams:add
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
-
LiteLLM proxy response_cost (x-litellm-response-cost) is never applied to ChatModelOutput.cost Apertabug
Difficoltà 2/5 1-3 ore Idoneità per principianti 84/100
i-am-bee/beeai-framework#1697 · 1 reazione ·
-
Support bun dedupe Apertaenhancement
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
antfu/node-modules-inspector#214 ·