Skip to content

fix(daemon): run Apple tools with the requesting client's DEVELOPER_DIR - #3109

Merged
thymikee merged 7 commits into
callstack:mainfrom
GenericJam:fix/forward-client-developer-dir
Oct 2, 2026
Merged

thymikee merged 7 commits into
callstack:mainfrom
GenericJam:fix/forward-client-developer-dir

Conversation

@GenericJam

@GenericJam GenericJam commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Originally authored by Claude for @GenericJam; follow-up fixes and validation by Codex.

Summary

Local clients now run Apple tools with their own DEVELOPER_DIR, independently of the daemon's startup environment. Unset values inherit the daemon environment; DEVELOPER_DIR="" selects the host's xcode-select toolchain. Remote requests discard the client host path.

Runner artifacts and durable leases carry the existing cache fingerprint, so retained-runner reuse and daemon adoption refuse a different toolchain even with a fixed AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH. External runner artifacts retain their existing reuse behavior. Missing xcrun tools produce actionable diagnostics.

35 files, including installation docs, wire compatibility metadata, regression tests, shared runner fixtures, and one test move to avoid growing an oversized test file.

Validation

Commit 015ad0872fb72f5e2b8edeb3aaaa6c6245f9c879:

  • AGENT_DEVICE_VITEST_MAX_WORKERS=2 pnpm check:affected --run: all runnable gates passed; 7,657 tests across 961 files. Earlier runs exposed a load-sensitive child-process cleanup race; the final run passed without test retries.
  • Regression tests failed before the fix and pass with it.
  • pnpm check:xctest-selection: passed.
  • Fresh iOS 26.2 simulator, fixed derived path: native runner build, forced tree snapshots, explicit/empty toolchain reuse, and graceful handoff/adoption passed with the same runner PID and cache key. Session, daemon, runner, and simulator cleaned up.

Only one Xcode is installed on this host; cross-Xcode invalidation is covered by regression tests. GitHub CI on the updated head remains to be verified.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 10 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/host-kit/src/internal/exec.ts Outdated
Comment thread packages/host-kit/src/internal/exec.ts Outdated
@thymikee

thymikee commented Oct 2, 2026

Copy link
Copy Markdown
Member

Reviewed 55de3f0. The per-request DEVELOPER_DIR idea works, but one shared memo still ignores it, so I can't call this ready to merge yet. CI is green: the one reported check passes. No conflicts.

requireRunnerToolchainFingerprint in https://github.com/callstack/agent-device/blob/55de3f0/packages/platform-apple/src/runner/runner-cache-metadata.ts#L295 memoizes the Xcode and SDK fingerprint in a process-wide createTtlMemo with no ttlMs, keyed only on sdkName. Client A (Xcode 27) fills it. Client B (Xcode 26.6) then gets A's fingerprint, so B's runner build key and derived-data path name Xcode 27 while its xcodebuild runs under 26.6. B can reuse A's Xcode 27 xctestrun, or file its own 26.6 build under the 27 key, and the failure reports name the wrong Xcode. Before this PR a daemon had one toolchain, so that key was enough. Two clients with different DEVELOPER_DIR on one daemon is exactly what this PR enables, so runner-backed commands like snapshot, click and type on a simulator can run another client's build. The rule to satisfy: every process-wide memo whose value depends on the selected toolchain is keyed on the effective developer dir of the request that reads it. Could you expose the effective DEVELOPER_DIR from host-kit (the requestCommandEnvScope value, falling back to process.env) and add it to this key? Please also grep platform-apple for createTtlMemo and for module-level single-flight promises that wrap xcrun or xcodebuild output, and apply the same key to each.

The reported failure has no regression test on its production route, in https://github.com/callstack/agent-device/blob/55de3f0/src/daemon/request-router.ts#L192. exec.test.ts covers only the host-kit primitive, and the http-server test stubs handleRequest. Reverting the request-router.ts hunk or the daemon-client.ts hunk leaves every added test green, and buildLocalHostEnvMeta (daemon-client.ts:139) has no test for local versus remote daemons. Could you add a router test that calls handleRequest with meta.developerDir and a handler that spawns process.execPath printing DEVELOPER_DIR, and asserts the value? Could you also add a daemon-client test that asserts developerDir is in meta for a local DaemonInfo and absent for a remote one?

The change crosses client meta, the kernel contract, the HTTP boundary, the router and a new host-kit AsyncLocalStorage seam, and each per-request toolchain then needs its memos keyed on it. Would it be simpler to treat the client's DEVELOPER_DIR as part of the daemon's launch identity, so a mismatch is refused or respawns the daemon, as daemon-launch-spec does for other drift? The daemon would keep one toolchain, and no memo key or wire field would change. The maintainers need to choose between one daemon serving several Xcodes and one daemon pinned to one. If per-request stays, the memo key above comes first. In that case the xcrun exit 72 classification also belongs in the platform-apple localAppleToolProvider (tool-provider.ts), which owns every xcrun simctl and devicectl call, not in host-kit.

Not blocking, take or leave: xcrunToolNotFoundDetails in host-kit exec.ts#L742 runs only in createExitError, so allowFailure results raised later through requireExecSuccess (for example the simctl pbpaste clipboard read in app-device-io.ts:15) get no reason or hint, and classifying in execFailureDetails would cover both paths. Also readXcodeSelectPath (exec.ts#L728) runs a spawnSync of xcode-select -p with a 2 s timeout on the error path and puts Apple knowledge into platform-neutral host-kit, so the hint could just say "the xcode-select developer dir (xcode-select -p)".

After the key fix, please run one daemon with two clients that use different DEVELOPER_DIR values against a booted simulator, on a runner-backed command such as snapshot, not only devices. The --debug runner.log or daemon log should show each client's xcodebuild running under its own Xcode and each runner derived-data path or build key naming that Xcode's build version. The current evidence covers only device listing, reported in the PR body without logs, on a host with no working simulators. I read the diff and did not run tests, and the memo problem is inferred from the memo key and derived-path code, not reproduced.

@GenericJam

Copy link
Copy Markdown
Contributor Author

@thymikee thanks for the careful review. (Same disclosure as the PR body: this reply and the follow-up commits were written by an AI coding agent working for @GenericJam. You're still welcome to close this and treat it only as a report.)

What changed (pushed, head 7d575048e)

  • a6b185969: requireRunnerToolchainFingerprint and memoizedRunnerXcodeVersion now key the memo by ${developerDir ?? ''}\0${sdkName}. developerDir is the request's DEVELOPER_DIR, else the daemon's, through a new commandDeveloperDir on the AppleRunnerHost port. The derived path hashes xcodeBuildVersion/SDK, so a runner built under another Xcode resolves to a different derived path and resolveReusableRunnerSession treats it as stale. New test in runner-cache-metadata.test.ts: two developer dirs give different fingerprints and derived paths, and switching back hits the memo without probing. It fails with the old key. A grep audit of createTtlMemo and Xcode/SDK probes found no other process-lifetime toolchain cache.
  • 7d575048e, the tests you asked for:
    • request-router-developer-dir.test.ts calls the real createRequestHandler. Inside its inventory callback it spawns process.execPath printing DEVELOPER_DIR. Two concurrent requests with meta.developerDir get their own values, and a request without it inherits the daemon's.
    • daemon-client-developer-dir.test.ts sends through sendToDaemon to a local socket daemon (developerDir present in meta) and to a remote baseUrl daemon (absent).
    • Reverting the request-router.ts or daemon-client.ts hunk to main fails the matching test. I checked both.
  • a6b185969 and 08d2e680f: the xcrun classification now lives in execFailureDetails, so requireExecSuccess paths such as pbpaste get it too. It matches only xcrun's own xcrun: error: unable to find utility "X" line, there is no spawnSync any more, and the hint names xcode-select -p without running it.

Why the classification isn't in localAppleToolProvider: the provider only returns results. Under allowFailure: true, it could only carry the classification by throwing, which breaks callers that consume the result: getSimulatorState returning null, runIosDevicectlJsonCommand's {ok:false}/tolerateOutput, and uninstall's tolerate. The other option is a new result field, which is a new seam. Most requireExecSuccess errors are built after the provider returns. xcrun is also spawned outside the provider: xcrun swiftc in swift-cache, xcrun --find in symbolication, the runner's runCmdSync toolchain probes, and the perf-xctrace background capture. What is left in host-kit is one regex and a hint string. Happy to move it if you see a cleaner seam.

Design question: your call. Pinning one toolchain per daemon (DEVELOPER_DIR in the launch identity, refuse or respawn on mismatch, as daemon-launch-spec does for other drift) is smaller and needs no memo keys or wire field. I can switch this PR to that design, or you can close it.

Live verification: not done yet. CoreSimulator on this host is currently wedged, so every simctl call hangs, and I couldn't run two clients against a booted simulator with snapshot. Once you've chosen a design and simulators work again, I'll run that scenario and post the --debug runner/daemon logs showing each xcodebuild's Xcode and derived-data path.

Checks on 7d575048e:

  • Pass: check:quick, layering, check:fallow --base origin/main, check:daemon-wire-compat (additive compatibleChanges entry for DaemonRequestMeta), and the new and touched test files.
  • check:affected --run shows 8 failures, all also failing on main on this host: runner-artifact-manifest (symlinked /tmp), runner-recovery-wiring and request-router-replay-scope (5 s timeouts).

Reviewed with a reviewer subagent (Codex CLI rate-limited until Oct 7); verdict MERGE on 7d575048e.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 9 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts">

<violation number="1" location="packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts:782">
P2: This assertion fails whenever `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is set, because `resolveRunnerDerivedPath` then returns the same override for both metadata values. Save and unset the override for this test, then restore it afterward.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/platform-apple/src/runner/runner-cache-metadata.ts
assert.equal(underB.xcodeBuildVersion, '18A100');
// The derived path is what a retained runner is reused by, so a differing one makes it stale.
assert.notEqual(
resolveRunnerDerivedPath(IOS_SIMULATOR, underA),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This assertion fails whenever AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH is set, because resolveRunnerDerivedPath then returns the same override for both metadata values. Save and unset the override for this test, then restore it afterward.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts, line 782:

<comment>This assertion fails whenever `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is set, because `resolveRunnerDerivedPath` then returns the same override for both metadata values. Save and unset the override for this test, then restore it afterward.</comment>

<file context>
@@ -751,3 +756,38 @@ test('an unreadable or setting-less build log fails the check', () => {
+  assert.equal(underB.xcodeBuildVersion, '18A100');
+  // The derived path is what a retained runner is reused by, so a differing one makes it stale.
+  assert.notEqual(
+    resolveRunnerDerivedPath(IOS_SIMULATOR, underA),
+    resolveRunnerDerivedPath(IOS_SIMULATOR, underB),
+  );
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3142915. The test now calls the existing withoutRunnerDerivedPathEnv() fixture (it unsets the variable and restores it in onTestFinished), so the whole test runs without the override. The file passes with AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH=/tmp/pinned set.

Comment thread packages/host-kit/src/internal/exec.ts Outdated
@GenericJam

Copy link
Copy Markdown
Contributor Author

(Same disclosure as before: this comment and commit 3142915 were written by an AI coding agent working for @GenericJam. Feel free to close the PR and fix this your own way.)

cubic's three findings on 7d575048e: fixed in 3142915a5. Details are in each thread:

  • The toolchain fingerprint memo now expires after 10 minutes without use. Each hit renews the entry, so idle DEVELOPER_DIR keys are dropped while a toolchain in use is never re-probed during a request.
  • commandDeveloperDir() follows resolveSpawnEnv: a request that sets DEVELOPER_DIR, even to '' or undefined, overrides the daemon's value.
  • The derived-path test calls the existing withoutRunnerDerivedPathEnv().

Live verification you asked for (one daemon, two clients, runner-backed command, booted simulator). CoreSimulator works again on this host, so I ran it on the PR build (pnpm build, 0.21.18 + this PR):

  • one isolated daemon (--state-dir /private/tmp/ad-devdir-state, pid 42157 throughout)
  • iPhone 17e simulator, iOS 26.5
  • xcode-select → Xcode-27.0
  • three clients in sequence, each running open com.apple.Preferences --debug, then snapshot -i --debug, then close
client DEVELOPER_DIR runner xcodebuild (runner.log) derived path (runner_xctestrun_cache) cache identity
A Xcode-26.6 (17F113) /Applications/Xcode-26.6.app/Contents/Developer/usr/bin/xcodebuild test-without-building … …/ios-simulator/cache-93d49290d079ee5c (…iphonesimulator26.5-arm64.xctestrun) xctestrun names iphonesimulator26.5, which is Xcode 26.6's SDK (23F81a)
B Xcode-27.0 (27A266a) /Applications/Xcode-27.0.app/Contents/Developer/usr/bin/xcodebuild test-without-building … …/ios-simulator/cache-4b12827ab1e1c781 (…iphonesimulator27.0-arm64.xctestrun) .agent-device-runner-cache.json: xcodeVersion 27.0, xcodeBuildVersion 27A266a, sdkVersion 27.0, sdkBuildVersion 24A430
A again Xcode-26.6 /Applications/Xcode-26.6.app/…/xcodebuild … cache-93d49290d079ee5c, apple_runner_prepare cache:"exact" same as A

Every snapshot succeeded (19–20 nodes from Settings).

The retained runner from the other Xcode was refused, not reused. In both directions the request log shows ios_runner_session_artifact_stale, followed by ios_runner_startup_stop_stale_artifact_session:

clientB27  {"phase":"ios_runner_session_artifact_stale", "currentDerived":".../cache-93d49290d079ee5c", "expectedDerived":".../cache-4b12827ab1e1c781"}
clientA26again {"phase":"ios_runner_session_artifact_stale", "currentDerived":".../cache-4b12827ab1e1c781", "expectedDerived":".../cache-93d49290d079ee5c"}

Both caches already existed from earlier runs on this host, so both legs were cache reuses (reuse_ready), not fresh builds. A cold build under each Xcode isn't shown here.

Checks on 3142915a5:

  • Pass: check:quick, check:layering, check:fallow --base origin/main, check:daemon-wire-compat.
  • Pass: exec.test.ts, runner-cache-metadata.test.ts (also with AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH set) and runner-device-set.test.ts.
  • Against 7d575048e's production code, both new tests fail.

Reviewed by a reviewer subagent (Codex CLI rate-limited until Oct 7): SHIP (its three P3 notes are applied in this commit).

The design question (per-request vs. one toolchain pinned per daemon) is still yours to decide. Happy to rework or close.

@thymikee

thymikee commented Oct 2, 2026

Copy link
Copy Markdown
Member

Reviewed 3142915. This is ready for maintainer review: at this commit the code in the earlier review (55de3f0) is fixed, and I have no code changes to ask for.

Not blocking, take or leave: the xcrun "tool not found" classification in https://github.com/callstack/agent-device/blob/3142915/packages/host-kit/src/internal/exec.ts#L718 now keys on the stderr line alone, so it also applies to non-72 exits, and the repository rules ask us to key on typed reasons, so you could gate it on exit code 72 and use the regex only to read the tool name; the router drops an empty meta.developerDir, so the empty-value branch of commandDeveloperDir cannot fire on the real route, and the PR body could say the classification keys on stderr and mention the one idle re-probe after 10 minutes for single-toolchain daemons.

The checks are green, with 1 check and none failing. I did not run any tests, and I judged the revert-failure cases by reading the tests against the old code. I judged the live run from the log excerpts in your comment, not from raw logs. Both legs reused existing runner builds, so a cold build under each Xcode is not shown, and the idle expiry is covered only by the fake-timer test. Both live clients set DEVELOPER_DIR explicitly, so the path where a client has none and inherits the daemon's env was not run live.

On the earlier question about pinning the toolchain in the daemon launch identity instead, I am not raising it again. Before merge, a maintainer needs to choose between per-request DEVELOPER_DIR (this PR) and one pinned toolchain per daemon.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 2, 2026
@thymikee
thymikee merged commit e4716e6 into callstack:main Oct 2, 2026
16 checks passed
thymikee added a commit to hassantsyed/agent-device that referenced this pull request Oct 3, 2026
…lugin

* origin/main: (628 commits)
  fix(ios): pin runner build roots under derived data (callstack#3158)
  feat: add managed provider plugin infrastructure (callstack#3121)
  fix(ios): report keyboard focus from the AX bridge's is-editing trait (callstack#3163)
  feat(devices): report model and osVersion (callstack#3119)
  fix: guard alert deadline before native tap synthesis (callstack#3113)
  feat(install-source): accept archive URLs from any public host (callstack#3110)
  test: keep uptime responsive behind busy runner work (callstack#3114)
  fix(apple): read the launch confirmation whenever the open cannot see the app (callstack#3115)
  fix(host-kit): keep extracted directories owner-accessible (callstack#3111)
  fix(daemon): run Apple tools with the requesting client's DEVELOPER_DIR (callstack#3109)
  fix(daemon): fence daemon.json removal to its owning process (callstack#3102)
  fix(snapshot): stop sibling-sized chrome containers from covering their own region (callstack#2996) (callstack#3097)
  fix(ios): stop reading windows past the one the runner resolved (callstack#3103)
  refactor(daemon): apply one dispatch-disclosure rule to returned and thrown failures (callstack#3099)
  chore: drop unused production exports and suppress dynamic consumers (callstack#3100)
  docs(help): document wait readiness and restart exhaustion (callstack#3098)
  docs: simplify Host to fresh Simlock devices and lease recovery (callstack#3095)
  feat(capture): report the display rotation a screenshot was rendered in (callstack#3088)
  refactor(snapshot): preserve normalized node attributes through presentation (callstack#3092)
  refactor(help): colocate fold guidance and extract workflows (callstack#3093)
  ...

# Conflicts:
#	README.md
#	package.json
#	packages/kernel/src/snapshot.ts
#	src/__tests__/eager-closure-budgets.ts
#	src/cli/commands/connection-presentation.ts
#	src/commands/schema/cli-help.ts
#	src/commands/schema/command-overrides.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants