From 9eb8e4d490448e1385f48814553d21e110b5d826 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 6 Oct 2026 00:18:28 -0400 Subject: [PATCH 1/8] fix(apple-runner): trim build scratch from a runner cache key after its build --- .../__tests__/runner-cache-trim.test.ts | 177 ++++++++++++++++++ .../src/runner/runner-artifact.ts | 14 ++ .../src/runner/runner-cache-trim.ts | 65 +++++++ .../platform-apple/src/runner/runner-cache.ts | 3 +- 4 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts create mode 100644 packages/platform-apple/src/runner/runner-cache-trim.ts diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts new file mode 100644 index 0000000000..7e33143c0b --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, test, vi } from 'vitest'; +import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; +import { appleRunnerTestHost } from '../test-host.ts'; +import { resolveRunnerCacheMetadataPath } from '../runner-cache.ts'; +import { trimRunnerBuildScratch } from '../runner-cache-trim.ts'; +import { ensureXctestrunArtifact } from '../runner-xctestrun.ts'; +import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; +import { seedRunnerProductBundle } from './runner-xctestrun.fixtures.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; + +const KEY = 'cache-0123456789abcdef'; + +let base: string; +let previousDerivedOverride: string | undefined; + +beforeEach(() => { + base = mkdtempForTestSync('agent-device-runner-trim-'); + previousDerivedOverride = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; + delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; + delete process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM; +}); + +afterEach(() => { + if (previousDerivedOverride === undefined) + delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; + else process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = previousDerivedOverride; + delete process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM; +}); + +function seedBuiltKey(derived: string): string[] { + const products = path.join(derived, 'Build', 'Products'); + const app = path.join(products, 'Debug-iphonesimulator', 'Runner.app'); + const xctestrun = path.join(products, 'Runner.xctestrun'); + fs.mkdirSync(app, { recursive: true }); + fs.writeFileSync(path.join(app, 'Runner'), 'binary'); + fs.writeFileSync(xctestrun, ''); + fs.writeFileSync(resolveRunnerCacheMetadataPath(derived), '{}'); + for (const scratch of [ + 'Build/Intermediates.noindex/Runner.build/obj.o', + 'Build/Products/Debug-iphonesimulator/Runner.app.dSYM', + 'SDKExplicitPrecompiledModules/Foundation.pcm', + 'ModuleCache.noindex/session.timestamp', + 'Logs/Build/build.xcactivitylog', + ]) { + fs.mkdirSync(path.dirname(path.join(derived, scratch)), { recursive: true }); + fs.writeFileSync(path.join(derived, scratch), 'scratch'); + } + return [xctestrun, app]; +} + +function tree(directory: string, prefix = ''): string[] { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const relative = path.join(prefix, entry.name); + return entry.isDirectory() + ? [relative, ...tree(path.join(directory, entry.name), relative)] + : [relative]; + }); +} + +test('trimming keeps the products and the metadata file and removes the rest', async () => { + const derived = path.join(base, KEY); + const protectedPaths = seedBuiltKey(derived); + + const removed = await trimRunnerBuildScratch(derived, protectedPaths); + + assert.deepEqual(removed.sort(), [ + 'Build/Intermediates.noindex', + 'Logs', + 'ModuleCache.noindex', + 'SDKExplicitPrecompiledModules', + ]); + assert.deepEqual( + tree(derived).sort(), + [ + '.agent-device-runner-cache.json', + 'Build', + 'Build/Products', + 'Build/Products/Debug-iphonesimulator', + 'Build/Products/Debug-iphonesimulator/Runner.app', + 'Build/Products/Debug-iphonesimulator/Runner.app.dSYM', + 'Build/Products/Debug-iphonesimulator/Runner.app/Runner', + 'Build/Products/Runner.xctestrun', + ].map((entry) => entry.replaceAll('/', path.sep)), + ); +}); + +test('trimming is skipped when disabled, under a derived path override, or outside a keyed cache', async () => { + const derived = path.join(base, KEY); + const protectedPaths = seedBuiltKey(derived); + const before = tree(derived); + + assert.deepEqual( + await trimRunnerBuildScratch(derived, protectedPaths, { + AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM: '0', + }), + [], + ); + assert.deepEqual( + await trimRunnerBuildScratch(derived, protectedPaths, { + AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: derived, + }), + [], + ); + const unkeyed = path.join(base, 'derived'); + const unkeyedProducts = seedBuiltKey(unkeyed); + assert.deepEqual(await trimRunnerBuildScratch(unkeyed, unkeyedProducts, {}), []); + + assert.deepEqual(tree(derived), before); +}); + +test('a product outside the cache root leaves the tree untouched', async () => { + const derived = path.join(base, KEY); + seedBuiltKey(derived); + const before = tree(derived); + + assert.deepEqual( + await trimRunnerBuildScratch(derived, [path.join(base, 'elsewhere.app')], {}), + [], + ); + + assert.deepEqual(tree(derived), before); +}); + +test('a runner build trims its key, and the next start reuses the products without rebuilding', async () => { + resetAllProcessMemosForTests(); + const projectRoot = mkdtempForTestSync('agent-device-runner-trim-root-'); + fs.mkdirSync( + path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner', 'AgentDeviceRunner.xcodeproj'), + { recursive: true }, + ); + const runCmdStreaming = vi.fn().mockImplementation(async (_command: string, args: string[]) => { + const symroot = args.find((arg) => arg.startsWith('SYMROOT='))!.slice('SYMROOT='.length); + const derived = path.dirname(path.dirname(symroot)); + await seedRunnerProductBundle(path.join(symroot, 'Debug-iphonesimulator', 'Runner.app')); + fs.writeFileSync( + path.join(symroot, 'Runner_iphonesimulator27.0-arm64.xctestrun'), + ` +ProductPaths +__TESTROOT__/Debug-iphonesimulator/Runner.app +`, + ); + for (const scratch of [ + 'Build/Intermediates.noindex/obj.o', + 'Logs/build.log', + 'ModuleCache.noindex/m', + ]) { + fs.mkdirSync(path.dirname(path.join(derived, scratch)), { recursive: true }); + fs.writeFileSync(path.join(derived, scratch), 'scratch'); + } + return { exitCode: 0, stdout: '', stderr: '' }; + }); + appleRunnerTestHost.update({ + runCmdSync: vi.fn().mockImplementation(appleToolchainProbeResult), + runCmdStreaming, + findProjectRoot: () => projectRoot, + readVersion: () => '0.0.0-test', + }); + + const built = await ensureXctestrunArtifact(IOS_SIMULATOR, {}); + + assert.equal(built.artifact, 'rebuilt'); + assert.deepEqual(fs.readdirSync(built.derived).sort(), [ + '.agent-device-runner-cache.json', + 'Build', + ]); + assert.deepEqual(fs.readdirSync(path.join(built.derived, 'Build')), ['Products']); + + const reused = await ensureXctestrunArtifact(IOS_SIMULATOR, {}); + + assert.equal(reused.artifact, 'valid'); + assert.equal(reused.xctestrunPath, built.xctestrunPath); + assert.equal(runCmdStreaming.mock.calls.length, 1); +}); diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index defe03ce6c..1fba7f11b1 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -365,6 +365,7 @@ async function buildXctestrunArtifact(params: { ), derived, ); + await trimRunnerBuildScratchBestEffort(derived, [built, ...builtProductPaths]); emitRunnerXctestrunDecision('build', 'built_new', { derived, xctestrunPath: built, @@ -381,6 +382,19 @@ async function buildXctestrunArtifact(params: { }; } +/** Runs under the cache lock, so no rebuild or reuse of this key sees the tree mid-trim. */ +async function trimRunnerBuildScratchBestEffort( + derived: string, + protectedPaths: readonly string[], +): Promise { + try { + const { trimRunnerBuildScratch } = await import('./runner-cache-trim.ts'); + const removed = await trimRunnerBuildScratch(derived, protectedPaths); + if (removed.length > 0) + emitRunnerXctestrunDecision('clean', 'build_scratch_trimmed', { derived }); + } catch {} +} + async function tryReuseExistingXctestrun( device: DeviceInfo, derived: string, diff --git a/packages/platform-apple/src/runner/runner-cache-trim.ts b/packages/platform-apple/src/runner/runner-cache-trim.ts new file mode 100644 index 0000000000..229e8f8d66 --- /dev/null +++ b/packages/platform-apple/src/runner/runner-cache-trim.ts @@ -0,0 +1,65 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { RUNNER_CACHE_METADATA_FILE } from './runner-cache-metadata.ts'; + +const CACHE_KEY_DIRECTORY = /^cache-[0-9a-f]{16}$/; + +/** + * Removes the build scratch from a keyed runner cache after a successful build: intermediates, + * precompiled and module caches, logs. Reuse and launch read only the products the manifest + * certifies and the metadata file, and a key that fails certification is deleted and rebuilt from + * scratch, so the scratch is never read again; a runner source or Xcode change mints a new key + * instead of building into this one. + * + * Returns the removed entries relative to `derived`. Nothing is removed when + * `AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM=0`, when `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` overrides the + * layout (a fixed path is a development loop that rebuilds into the same tree incrementally), or + * when a product lies outside `derived`. + */ +export async function trimRunnerBuildScratch( + derived: string, + protectedPaths: readonly string[], + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (env.AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM?.trim() === '0') return []; + if (env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim()) return []; + if (!CACHE_KEY_DIRECTORY.test(path.basename(derived))) return []; + const kept = resolveKeptPaths(derived, protectedPaths); + if (!kept) return []; + return await trimDirectory(derived, derived, kept); +} + +/** `Build/` is the unit kept under `Build`, so the products survive and their siblings do not. */ +function resolveKeptPaths(derived: string, protectedPaths: readonly string[]): Set | null { + const kept = new Set([RUNNER_CACHE_METADATA_FILE]); + for (const protectedPath of protectedPaths) { + const relative = path.relative(derived, protectedPath); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null; + const segments = relative.split(path.sep); + kept.add(segments.slice(0, segments[0] === 'Build' ? 2 : 1).join(path.sep)); + } + return kept; +} + +async function trimDirectory( + root: string, + directory: string, + kept: ReadonlySet, +): Promise { + const removed: string[] = []; + for (const entry of await fs.promises.readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + const relative = path.relative(root, entryPath); + if (kept.has(relative)) continue; + if ( + entry.isDirectory() && + [...kept].some((keep) => keep.startsWith(`${relative}${path.sep}`)) + ) { + removed.push(...(await trimDirectory(root, entryPath, kept))); + continue; + } + await fs.promises.rm(entryPath, { recursive: true, force: true }); + removed.push(relative); + } + return removed; +} diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts index 71499098ea..1c013eec87 100644 --- a/packages/platform-apple/src/runner/runner-cache.ts +++ b/packages/platform-apple/src/runner/runner-cache.ts @@ -380,7 +380,8 @@ export function emitRunnerXctestrunDecision( | 'built_new' | 'external_xctestrun' | 'external_bad_artifact' - | 'uncertifiable_products', + | 'uncertifiable_products' + | 'build_scratch_trimmed', data: Record, ): void { emitDiagnostic({ From 7b01787f2ac267a91aa0880ece4a792da4eaabdf Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 6 Oct 2026 00:23:50 -0400 Subject: [PATCH 2/8] fix(package): build the Apple runners for packaging outside the shared runner cache --- CONTRIBUTING.md | 3 +- package.json | 3 +- .../__tests__/build-package-xcuitest.test.ts | 50 +++++++++++++++++++ scripts/build-package-xcuitest.mjs | 50 +++++++++++++++++++ src/__tests__/npm-package-scripts.test.ts | 5 +- vitest.config.ts | 1 + 6 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 scripts/__tests__/build-package-xcuitest.test.ts create mode 100644 scripts/build-package-xcuitest.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43dddfb0c9..4a51e44582 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,7 +70,8 @@ Use `pnpm build:macos-helper:clean` if a Swift cache was created in another work `pnpm publish` and package-manager pack commands run `prepack`, which first checks synchronized MCP metadata and then runs `pnpm package:npm`. This is the one completeness-oriented aggregate: it -builds the TypeScript distribution and all four Apple runner targets, clean-builds the macOS helper, +builds the TypeScript distribution and all four Apple runner targets (into a scratch directory under +`.tmp/` that it removes afterwards, not into `~/.agent-device`), clean-builds the macOS helper, packages the Apple runner source, and rebuilds both Android helper APKs. Any failed build stops packaging. It deliberately does not stop the worktree's development daemon; use `pnpm rebuild:cli` when a running daemon needs to pick up a new TypeScript build. diff --git a/package.json b/package.json index 4e74748d53..ede99680ba 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "build:xcuitest:tvos:clean": "pnpm clean:xcuitest:tvos && pnpm build:xcuitest:tvos", "build:xcuitest:visionos": "AGENT_DEVICE_XCUITEST_PLATFORM=visionos sh ./scripts/build-xcuitest-apple.sh", "build:xcuitest:visionos:clean": "pnpm clean:xcuitest:visionos && pnpm build:xcuitest:visionos", + "build:xcuitest:package": "node scripts/build-package-xcuitest.mjs", "build:android-snapshot-helper": "AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/build-android-helper.sh $(node -p \"require('./package.json').version\") .tmp/android-snapshot-helper", "package:android-snapshot-helper": "AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") v$(node -p \"require('./package.json').version\") .tmp/android-snapshot-helper", "package:android-snapshot-helper:npm": "rm -rf android/snapshot-helper/dist && AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") v$(node -p \"require('./package.json').version\") android/snapshot-helper/dist", @@ -114,7 +115,7 @@ "test:macos-helper": "node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts swift test --package-path apple/macos-helper", "check:macos-helper": "pnpm build:macos-helper && pnpm test:macos-helper", "prepare:publish-assets": "node scripts/prepare-publish-assets.mjs", - "build:package": "pnpm build && pnpm build:xcuitest:ios && pnpm build:xcuitest:macos && pnpm build:xcuitest:tvos && pnpm build:xcuitest:visionos && pnpm build:macos-helper:clean && pnpm prepare:publish-assets", + "build:package": "pnpm build && pnpm build:xcuitest:package && pnpm build:macos-helper:clean && pnpm prepare:publish-assets", "package:npm": "pnpm build:package && pnpm check:package", "release:prepare": "node scripts/release-mark-dev.mjs --check-release-version && rm -rf .tmp/release && pnpm check:mcp-metadata && pnpm build:package && pnpm check:package -- --pack-destination .tmp/release", "release:publish": "pnpm release:prepare && npm publish --ignore-scripts .tmp/release/*.tgz && pnpm release:mark-dev", diff --git a/scripts/__tests__/build-package-xcuitest.test.ts b/scripts/__tests__/build-package-xcuitest.test.ts new file mode 100644 index 0000000000..73bb5ba54f --- /dev/null +++ b/scripts/__tests__/build-package-xcuitest.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { mkdtempForTestSync } from '../../src/__tests__/test-utils/tmp-dir.ts'; +import { buildPackageXcuitest } from '../build-package-xcuitest.mjs'; + +type Build = { root: string; platform: string; derivedPath: string }; + +test('builds every platform into a scratch directory under the repo and removes it', () => { + const root = mkdtempForTestSync('agent-device-package-xcuitest-'); + const seen: Build[] = []; + + buildPackageXcuitest({ + root, + build: (build: Build) => { + seen.push(build); + fs.mkdirSync(path.join(build.derivedPath, 'Build', 'Products'), { recursive: true }); + }, + }); + + assert.deepEqual( + seen.map(({ platform }) => platform), + ['ios', 'macos', 'tvos', 'visionos'], + ); + for (const { derivedPath } of seen) { + assert.equal(path.dirname(path.dirname(derivedPath)), path.join(root, '.tmp')); + assert.equal(fs.existsSync(derivedPath), false); + } + assert.deepEqual(fs.readdirSync(path.join(root, '.tmp')), []); +}); + +test('removes the scratch directory when a platform build fails', () => { + const root = mkdtempForTestSync('agent-device-package-xcuitest-'); + + assert.throws( + () => + buildPackageXcuitest({ + root, + platforms: ['ios', 'macos'], + build: ({ platform, derivedPath }: Build) => { + fs.mkdirSync(derivedPath, { recursive: true }); + if (platform === 'macos') throw new Error('xcodebuild failed'); + }, + }), + /xcodebuild failed/, + ); + + assert.deepEqual(fs.readdirSync(path.join(root, '.tmp')), []); +}); diff --git a/scripts/build-package-xcuitest.mjs b/scripts/build-package-xcuitest.mjs new file mode 100644 index 0000000000..766e8b6153 --- /dev/null +++ b/scripts/build-package-xcuitest.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PLATFORMS = ['ios', 'macos', 'tvos', 'visionos']; + +/** + * Compiles the Apple runner for every platform the package supports, into a scratch directory + * under `/.tmp` that is removed afterwards. The package ships the runner source and the + * daemon builds into its own keyed cache, so nothing reads these products; building them at the + * default `~/.agent-device/apple-runner/derived` location only left about 1 GB there per machine. + */ +export function buildPackageXcuitest(options = {}) { + const root = path.resolve(options.root ?? process.cwd()); + const platforms = options.platforms ?? PLATFORMS; + const build = options.build ?? runBuildScript; + const scratchParent = path.join(root, '.tmp'); + fs.mkdirSync(scratchParent, { recursive: true }); + const scratch = fs.mkdtempSync(path.join(scratchParent, 'package-xcuitest-')); + try { + for (const platform of platforms) { + build({ root, platform, derivedPath: path.join(scratch, platform) }); + } + } finally { + fs.rmSync(scratch, { recursive: true, force: true }); + } +} + +function runBuildScript({ root, platform, derivedPath }) { + execFileSync('sh', [path.join('scripts', 'build-xcuitest-apple.sh')], { + cwd: root, + env: { + ...process.env, + AGENT_DEVICE_XCUITEST_PLATFORM: platform, + AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: derivedPath, + }, + stdio: 'inherit', + }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + buildPackageXcuitest(); + } catch (error) { + process.exitCode = typeof error?.status === 'number' ? error.status : 1; + if (typeof error?.status !== 'number') console.error(error); + } +} diff --git a/src/__tests__/npm-package-scripts.test.ts b/src/__tests__/npm-package-scripts.test.ts index 4e1aa228a9..74dc9bdb84 100644 --- a/src/__tests__/npm-package-scripts.test.ts +++ b/src/__tests__/npm-package-scripts.test.ts @@ -54,10 +54,7 @@ test('Fallow exposes one changed-code gate and an explicit full-tree audit', () test('the npm package build covers every package-owned output before verification', () => { assert.deepEqual(script('build:package').split(' && '), [ 'pnpm build', - 'pnpm build:xcuitest:ios', - 'pnpm build:xcuitest:macos', - 'pnpm build:xcuitest:tvos', - 'pnpm build:xcuitest:visionos', + 'pnpm build:xcuitest:package', 'pnpm build:macos-helper:clean', 'pnpm prepare:publish-assets', ]); diff --git a/vitest.config.ts b/vitest.config.ts index 1bcd155b27..b7b676fa96 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -153,6 +153,7 @@ export default defineConfig({ // Publish preparation spawns only fixture-owned scripts and proves both Android // helper families are rebuilt through the shared release/size-report owner. 'scripts/__tests__/prepare-publish-assets.test.ts', + 'scripts/__tests__/build-package-xcuitest.test.ts', // The packager's Swift comment scanner: pure string transform, and the only place a // literal that looks like a comment (a URL, a raw or multi-line literal) is proven // to survive packaging before the npm package ships unbuildable Swift. From 4652d10246020ddf576632e554a6825795955f3f Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 6 Oct 2026 00:23:50 -0400 Subject: [PATCH 3/8] docs: describe runner cache trimming and the legacy ios-runner directory --- website/docs/docs/commands.md | 2 ++ website/docs/docs/configuration.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 0c8b66c295..9ac07c207e 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -280,6 +280,8 @@ agent-device prepare ios-runner --platform ios --timeout 240000 - If health checking exposes a bad restored runner artifact, Agent Device marks that artifact bad and rebuilds once. - If a fresh runner launch gets stuck before accepting connections, Agent Device invalidates that runner session and launches it once more without forcing a rebuild. - CI may cache `~/.agent-device/apple-runner/derived` when the cache key includes the exact Agent Device package contents and selected Xcode version. +- After a successful build, Agent Device removes the build scratch from the new cache key (intermediates, precompiled and module caches, logs) and keeps `Build/Products` and the metadata file, which is all reuse and launch read; a key goes from about 160-230 MB to about 5 MB. A key that fails certification is rebuilt from scratch either way, and a runner source or Xcode change mints a new key, so the scratch is never used again. `AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM=0` keeps it. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never trimmed, because a fixed path rebuilds into the same tree incrementally. +- Agent Device versions from before the Apple runner rename kept their cache in `~/.agent-device/ios-runner`. Current versions never read it, and it is not removed automatically because an older version installed on the same machine may still use it. Delete it with `rm -rf ~/.agent-device/ios-runner` once no old version runs. - Runner reuse is authorized only by the cache metadata's content manifest: a restored tree whose files no longer match the recorded digests, modes, or symlink targets is discarded and rebuilt. A cache key must stay exact — the runtime never falls back to a broader cache. - Certification is fail-closed: when a product tree cannot be certified at all — a product escaping the derived-data root, an unreadable subtree, a file over 128 MB, or a non-regular entry such as a socket — the build fails with `runner_cache_uncertifiable` naming the path instead of launching uncertified bytes. Point `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` at a plain directory the current user owns; replacing the tree (the error's hint says how) clears a refusal. - Runner build/start output is written to the session's `runner.log`. The top-level `daemon.log` is reserved for daemon lifecycle/startup issues. diff --git a/website/docs/docs/configuration.md b/website/docs/docs/configuration.md index 9e524a5da8..5e4722e9e1 100644 --- a/website/docs/docs/configuration.md +++ b/website/docs/docs/configuration.md @@ -130,7 +130,7 @@ These env vars are the supported user-facing configuration surface. Other `AGENT | Local daemon storage | `AGENT_DEVICE_STATE_DIR` | Public | | Metro and install helpers | `AGENT_DEVICE_METRO_BEARER_TOKEN`, `AGENT_DEVICE_BUNDLETOOL_JAR` | Public | | App hooks and logs | `AGENT_DEVICE_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_APP_LOG_MAX_BYTES`, `AGENT_DEVICE_APP_LOG_MAX_FILES`, `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS`, `AGENT_DEVICE_EVENT_LOG_MAX_BYTES` | Public. Byte caps take whole integers (`5242880`), not `5MB`. | -| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. | +| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED`, `AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. `AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM=0` keeps the build scratch of a runner cache key; by default it is removed after a successful build. | | Install/update and platform helpers | `AGENT_DEVICE_NO_UPDATE_NOTIFIER`, `AGENT_DEVICE_MACOS_HELPER_BIN`, `AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION` | Public operator controls | | macOS app backend | `AGENT_DEVICE_MACOS_APP_BACKEND`, `AGENT_DEVICE_MACOS_GHOST_CURSOR` | Public operator controls, read by the daemon. `native` drives macOS app sessions through the macOS helper instead of XCTest; see [Commands](/docs/commands). Unset or `xctest` keeps the runner. The drawn agent pointer adds about 0.3 s to each native click, fill, type, and scroll; `AGENT_DEVICE_MACOS_GHOST_CURSOR=0` turns it off. Restart the daemon after changing either value. | From a790173eaee3b770f2faabee611f824150bb643a Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 6 Oct 2026 00:57:59 -0400 Subject: [PATCH 4/8] fix: address review of the trim and packaging changes --- package.json | 4 +-- .../__tests__/runner-cache-trim.test.ts | 17 ++++++++++ .../src/runner/runner-cache-trim.ts | 31 +++++++++++++++---- .../__tests__/build-package-xcuitest.test.ts | 19 +++++++++++- scripts/build-package-xcuitest.mjs | 13 ++++---- src/__tests__/npm-package-scripts.test.ts | 2 +- 6 files changed, 70 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index ede99680ba..12327b060f 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "build:xcuitest:tvos:clean": "pnpm clean:xcuitest:tvos && pnpm build:xcuitest:tvos", "build:xcuitest:visionos": "AGENT_DEVICE_XCUITEST_PLATFORM=visionos sh ./scripts/build-xcuitest-apple.sh", "build:xcuitest:visionos:clean": "pnpm clean:xcuitest:visionos && pnpm build:xcuitest:visionos", - "build:xcuitest:package": "node scripts/build-package-xcuitest.mjs", + "package:xcuitest": "node scripts/build-package-xcuitest.mjs", "build:android-snapshot-helper": "AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/build-android-helper.sh $(node -p \"require('./package.json').version\") .tmp/android-snapshot-helper", "package:android-snapshot-helper": "AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") v$(node -p \"require('./package.json').version\") .tmp/android-snapshot-helper", "package:android-snapshot-helper:npm": "rm -rf android/snapshot-helper/dist && AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") v$(node -p \"require('./package.json').version\") android/snapshot-helper/dist", @@ -115,7 +115,7 @@ "test:macos-helper": "node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts swift test --package-path apple/macos-helper", "check:macos-helper": "pnpm build:macos-helper && pnpm test:macos-helper", "prepare:publish-assets": "node scripts/prepare-publish-assets.mjs", - "build:package": "pnpm build && pnpm build:xcuitest:package && pnpm build:macos-helper:clean && pnpm prepare:publish-assets", + "build:package": "pnpm build && pnpm package:xcuitest && pnpm build:macos-helper:clean && pnpm prepare:publish-assets", "package:npm": "pnpm build:package && pnpm check:package", "release:prepare": "node scripts/release-mark-dev.mjs --check-release-version && rm -rf .tmp/release && pnpm check:mcp-metadata && pnpm build:package && pnpm check:package -- --pack-destination .tmp/release", "release:publish": "pnpm release:prepare && npm publish --ignore-scripts .tmp/release/*.tgz && pnpm release:mark-dev", diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts index 7e33143c0b..b5fc8f3662 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts @@ -125,6 +125,23 @@ test('a product outside the cache root leaves the tree untouched', async () => { assert.deepEqual(tree(derived), before); }); +test('a product that is a symlink keeps the unit it points into', async () => { + const derived = path.join(base, KEY); + const [xctestrun] = seedBuiltKey(derived); + const target = path.join(derived, 'Build', 'Intermediates.noindex', 'Runner.build'); + const link = path.join(derived, 'Build', 'Products', 'Linked.app'); + fs.symlinkSync(target, link); + + const removed = await trimRunnerBuildScratch(derived, [xctestrun!, link], {}); + + assert.deepEqual(removed.sort(), [ + 'Logs', + 'ModuleCache.noindex', + 'SDKExplicitPrecompiledModules', + ]); + assert.equal(fs.existsSync(path.join(target, 'obj.o')), true); +}); + test('a runner build trims its key, and the next start reuses the products without rebuilding', async () => { resetAllProcessMemosForTests(); const projectRoot = mkdtempForTestSync('agent-device-runner-trim-root-'); diff --git a/packages/platform-apple/src/runner/runner-cache-trim.ts b/packages/platform-apple/src/runner/runner-cache-trim.ts index 229e8f8d66..dea98a79a4 100644 --- a/packages/platform-apple/src/runner/runner-cache-trim.ts +++ b/packages/platform-apple/src/runner/runner-cache-trim.ts @@ -29,18 +29,37 @@ export async function trimRunnerBuildScratch( return await trimDirectory(derived, derived, kept); } -/** `Build/` is the unit kept under `Build`, so the products survive and their siblings do not. */ +/** + * `Build/` is the unit kept under `Build`, so the products survive and their siblings do not. + * A product that is missing or lies outside the key makes the trim a no-op. A product that is a + * symlink keeps its target's unit as well. + */ function resolveKeptPaths(derived: string, protectedPaths: readonly string[]): Set | null { const kept = new Set([RUNNER_CACHE_METADATA_FILE]); - for (const protectedPath of protectedPaths) { - const relative = path.relative(derived, protectedPath); - if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null; - const segments = relative.split(path.sep); - kept.add(segments.slice(0, segments[0] === 'Build' ? 2 : 1).join(path.sep)); + try { + const realDerived = fs.realpathSync(derived); + for (const protectedPath of protectedPaths) { + const lexical = path.relative(derived, protectedPath); + const real = path.relative(realDerived, fs.realpathSync(protectedPath)); + if (isOutside(lexical) || isOutside(real)) return null; + kept.add(keptUnit(lexical)); + kept.add(keptUnit(real)); + } + } catch { + return null; } return kept; } +function keptUnit(relative: string): string { + const segments = relative.split(path.sep); + return segments.slice(0, segments[0] === 'Build' ? 2 : 1).join(path.sep); +} + +function isOutside(relative: string): boolean { + return !relative || relative.startsWith('..') || path.isAbsolute(relative); +} + async function trimDirectory( root: string, directory: string, diff --git a/scripts/__tests__/build-package-xcuitest.test.ts b/scripts/__tests__/build-package-xcuitest.test.ts index 73bb5ba54f..eb3ad072ed 100644 --- a/scripts/__tests__/build-package-xcuitest.test.ts +++ b/scripts/__tests__/build-package-xcuitest.test.ts @@ -24,7 +24,7 @@ test('builds every platform into a scratch directory under the repo and removes ['ios', 'macos', 'tvos', 'visionos'], ); for (const { derivedPath } of seen) { - assert.equal(path.dirname(path.dirname(derivedPath)), path.join(root, '.tmp')); + assert.equal(path.dirname(derivedPath), path.join(root, '.tmp', 'package-xcuitest')); assert.equal(fs.existsSync(derivedPath), false); } assert.deepEqual(fs.readdirSync(path.join(root, '.tmp')), []); @@ -48,3 +48,20 @@ test('removes the scratch directory when a platform build fails', () => { assert.deepEqual(fs.readdirSync(path.join(root, '.tmp')), []); }); + +test('starts from an empty scratch directory when an interrupted run left one behind', () => { + const root = mkdtempForTestSync('agent-device-package-xcuitest-'); + const leftover = path.join(root, '.tmp', 'package-xcuitest', 'ios', 'Build'); + fs.mkdirSync(leftover, { recursive: true }); + let leftoverSeen = true; + + buildPackageXcuitest({ + root, + platforms: ['ios'], + build: ({ derivedPath }: Build) => { + leftoverSeen = fs.existsSync(derivedPath); + }, + }); + + assert.equal(leftoverSeen, false); +}); diff --git a/scripts/build-package-xcuitest.mjs b/scripts/build-package-xcuitest.mjs index 766e8b6153..0bba87fce9 100644 --- a/scripts/build-package-xcuitest.mjs +++ b/scripts/build-package-xcuitest.mjs @@ -8,17 +8,18 @@ const PLATFORMS = ['ios', 'macos', 'tvos', 'visionos']; /** * Compiles the Apple runner for every platform the package supports, into a scratch directory - * under `/.tmp` that is removed afterwards. The package ships the runner source and the - * daemon builds into its own keyed cache, so nothing reads these products; building them at the - * default `~/.agent-device/apple-runner/derived` location only left about 1 GB there per machine. + * under `/.tmp` that is removed before and after the builds, so an interrupted run leaves + * nothing past the next one. The package ships the runner source and the daemon builds into its + * own keyed cache, so nothing reads these products; building them at the default + * `~/.agent-device/apple-runner/derived` location only left about 1 GB there per machine. */ export function buildPackageXcuitest(options = {}) { const root = path.resolve(options.root ?? process.cwd()); const platforms = options.platforms ?? PLATFORMS; const build = options.build ?? runBuildScript; - const scratchParent = path.join(root, '.tmp'); - fs.mkdirSync(scratchParent, { recursive: true }); - const scratch = fs.mkdtempSync(path.join(scratchParent, 'package-xcuitest-')); + const scratch = path.join(root, '.tmp', 'package-xcuitest'); + fs.rmSync(scratch, { recursive: true, force: true }); + fs.mkdirSync(scratch, { recursive: true }); try { for (const platform of platforms) { build({ root, platform, derivedPath: path.join(scratch, platform) }); diff --git a/src/__tests__/npm-package-scripts.test.ts b/src/__tests__/npm-package-scripts.test.ts index 74dc9bdb84..69cec1dcda 100644 --- a/src/__tests__/npm-package-scripts.test.ts +++ b/src/__tests__/npm-package-scripts.test.ts @@ -54,7 +54,7 @@ test('Fallow exposes one changed-code gate and an explicit full-tree audit', () test('the npm package build covers every package-owned output before verification', () => { assert.deepEqual(script('build:package').split(' && '), [ 'pnpm build', - 'pnpm build:xcuitest:package', + 'pnpm package:xcuitest', 'pnpm build:macos-helper:clean', 'pnpm prepare:publish-assets', ]); From e88a58f4733cd852093a85d3bb697bb79a8ac174 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 6 Oct 2026 09:00:48 -0400 Subject: [PATCH 5/8] refactor(apple-runner): gate the trim on the cache key, report trim failures, drop the trim opt-out --- .../__tests__/runner-cache-trim.test.ts | 52 +++++++------------ .../src/runner/runner-artifact.ts | 14 ++++- .../src/runner/runner-cache-trim.ts | 12 +---- website/docs/docs/commands.md | 2 +- website/docs/docs/configuration.md | 2 +- 5 files changed, 36 insertions(+), 46 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts index b5fc8f3662..6584d7ef55 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts @@ -21,14 +21,12 @@ beforeEach(() => { base = mkdtempForTestSync('agent-device-runner-trim-'); previousDerivedOverride = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; - delete process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM; }); afterEach(() => { if (previousDerivedOverride === undefined) delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; else process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = previousDerivedOverride; - delete process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM; }); function seedBuiltKey(derived: string): string[] { @@ -88,39 +86,12 @@ test('trimming keeps the products and the metadata file and removes the rest', a ); }); -test('trimming is skipped when disabled, under a derived path override, or outside a keyed cache', async () => { - const derived = path.join(base, KEY); - const protectedPaths = seedBuiltKey(derived); - const before = tree(derived); - - assert.deepEqual( - await trimRunnerBuildScratch(derived, protectedPaths, { - AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM: '0', - }), - [], - ); - assert.deepEqual( - await trimRunnerBuildScratch(derived, protectedPaths, { - AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: derived, - }), - [], - ); - const unkeyed = path.join(base, 'derived'); - const unkeyedProducts = seedBuiltKey(unkeyed); - assert.deepEqual(await trimRunnerBuildScratch(unkeyed, unkeyedProducts, {}), []); - - assert.deepEqual(tree(derived), before); -}); - test('a product outside the cache root leaves the tree untouched', async () => { const derived = path.join(base, KEY); seedBuiltKey(derived); const before = tree(derived); - assert.deepEqual( - await trimRunnerBuildScratch(derived, [path.join(base, 'elsewhere.app')], {}), - [], - ); + assert.deepEqual(await trimRunnerBuildScratch(derived, [path.join(base, 'elsewhere.app')]), []); assert.deepEqual(tree(derived), before); }); @@ -132,7 +103,7 @@ test('a product that is a symlink keeps the unit it points into', async () => { const link = path.join(derived, 'Build', 'Products', 'Linked.app'); fs.symlinkSync(target, link); - const removed = await trimRunnerBuildScratch(derived, [xctestrun!, link], {}); + const removed = await trimRunnerBuildScratch(derived, [xctestrun!, link]); assert.deepEqual(removed.sort(), [ 'Logs', @@ -142,7 +113,7 @@ test('a product that is a symlink keeps the unit it points into', async () => { assert.equal(fs.existsSync(path.join(target, 'obj.o')), true); }); -test('a runner build trims its key, and the next start reuses the products without rebuilding', async () => { +function mockRunnerBuild() { resetAllProcessMemosForTests(); const projectRoot = mkdtempForTestSync('agent-device-runner-trim-root-'); fs.mkdirSync( @@ -177,6 +148,12 @@ test('a runner build trims its key, and the next start reuses the products witho readVersion: () => '0.0.0-test', }); + return { runCmdStreaming }; +} + +test('a runner build trims its key, and the next start reuses the products without rebuilding', async () => { + const { runCmdStreaming } = mockRunnerBuild(); + const built = await ensureXctestrunArtifact(IOS_SIMULATOR, {}); assert.equal(built.artifact, 'rebuilt'); @@ -192,3 +169,14 @@ test('a runner build trims its key, and the next start reuses the products witho assert.equal(reused.xctestrunPath, built.xctestrunPath); assert.equal(runCmdStreaming.mock.calls.length, 1); }); + +test('a runner build under a derived path override keeps its build scratch', async () => { + mockRunnerBuild(); + process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = path.join(base, 'fixed-derived'); + + const built = await ensureXctestrunArtifact(IOS_SIMULATOR, {}); + + assert.equal(built.artifact, 'rebuilt'); + assert.equal(fs.existsSync(path.join(built.derived, 'Build', 'Intermediates.noindex')), true); + assert.equal(fs.existsSync(path.join(built.derived, 'Logs')), true); +}); diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index 1fba7f11b1..eabab1e8e7 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -7,6 +7,7 @@ import { runCmdStreaming, withKeyedLock, withProcessLock, + emitDiagnostic, emitRequestProgress, findProjectRoot, getRequestSignal, @@ -365,7 +366,10 @@ async function buildXctestrunArtifact(params: { ), derived, ); - await trimRunnerBuildScratchBestEffort(derived, [built, ...builtProductPaths]); + // An AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH override is a fixed tree rebuilt incrementally, not a keyed cache. + if (path.basename(derived) === resolveRunnerCacheKey(expectedCacheMetadata)) { + await trimRunnerBuildScratchBestEffort(derived, [built, ...builtProductPaths]); + } emitRunnerXctestrunDecision('build', 'built_new', { derived, xctestrunPath: built, @@ -392,7 +396,13 @@ async function trimRunnerBuildScratchBestEffort( const removed = await trimRunnerBuildScratch(derived, protectedPaths); if (removed.length > 0) emitRunnerXctestrunDecision('clean', 'build_scratch_trimmed', { derived }); - } catch {} + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'runner_xctestrun_cache_trim_failed', + data: { derived, error: error instanceof Error ? error.message : String(error) }, + }); + } } async function tryReuseExistingXctestrun( diff --git a/packages/platform-apple/src/runner/runner-cache-trim.ts b/packages/platform-apple/src/runner/runner-cache-trim.ts index dea98a79a4..4f0e1ba627 100644 --- a/packages/platform-apple/src/runner/runner-cache-trim.ts +++ b/packages/platform-apple/src/runner/runner-cache-trim.ts @@ -2,8 +2,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { RUNNER_CACHE_METADATA_FILE } from './runner-cache-metadata.ts'; -const CACHE_KEY_DIRECTORY = /^cache-[0-9a-f]{16}$/; - /** * Removes the build scratch from a keyed runner cache after a successful build: intermediates, * precompiled and module caches, logs. Reuse and launch read only the products the manifest @@ -11,19 +9,13 @@ const CACHE_KEY_DIRECTORY = /^cache-[0-9a-f]{16}$/; * scratch, so the scratch is never read again; a runner source or Xcode change mints a new key * instead of building into this one. * - * Returns the removed entries relative to `derived`. Nothing is removed when - * `AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM=0`, when `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` overrides the - * layout (a fixed path is a development loop that rebuilds into the same tree incrementally), or - * when a product lies outside `derived`. + * Returns the removed entries relative to `derived`. Nothing is removed when a product lies + * outside `derived`. The caller trims only a keyed cache directory. */ export async function trimRunnerBuildScratch( derived: string, protectedPaths: readonly string[], - env: NodeJS.ProcessEnv = process.env, ): Promise { - if (env.AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM?.trim() === '0') return []; - if (env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim()) return []; - if (!CACHE_KEY_DIRECTORY.test(path.basename(derived))) return []; const kept = resolveKeptPaths(derived, protectedPaths); if (!kept) return []; return await trimDirectory(derived, derived, kept); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 9ac07c207e..7c70f4ce4e 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -280,7 +280,7 @@ agent-device prepare ios-runner --platform ios --timeout 240000 - If health checking exposes a bad restored runner artifact, Agent Device marks that artifact bad and rebuilds once. - If a fresh runner launch gets stuck before accepting connections, Agent Device invalidates that runner session and launches it once more without forcing a rebuild. - CI may cache `~/.agent-device/apple-runner/derived` when the cache key includes the exact Agent Device package contents and selected Xcode version. -- After a successful build, Agent Device removes the build scratch from the new cache key (intermediates, precompiled and module caches, logs) and keeps `Build/Products` and the metadata file, which is all reuse and launch read; a key goes from about 160-230 MB to about 5 MB. A key that fails certification is rebuilt from scratch either way, and a runner source or Xcode change mints a new key, so the scratch is never used again. `AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM=0` keeps it. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never trimmed, because a fixed path rebuilds into the same tree incrementally. +- After a successful build, Agent Device removes the build scratch from the new cache key (intermediates, precompiled and module caches, logs) and keeps `Build/Products` and the metadata file, which is all reuse and launch read; a key goes from about 160-230 MB to about 5 MB. A key that fails certification is rebuilt from scratch either way, and a runner source or Xcode change mints a new key, so the scratch is never used again. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never trimmed, because a fixed path rebuilds into the same tree incrementally. - Agent Device versions from before the Apple runner rename kept their cache in `~/.agent-device/ios-runner`. Current versions never read it, and it is not removed automatically because an older version installed on the same machine may still use it. Delete it with `rm -rf ~/.agent-device/ios-runner` once no old version runs. - Runner reuse is authorized only by the cache metadata's content manifest: a restored tree whose files no longer match the recorded digests, modes, or symlink targets is discarded and rebuilt. A cache key must stay exact — the runtime never falls back to a broader cache. - Certification is fail-closed: when a product tree cannot be certified at all — a product escaping the derived-data root, an unreadable subtree, a file over 128 MB, or a non-regular entry such as a socket — the build fails with `runner_cache_uncertifiable` naming the path instead of launching uncertified bytes. Point `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` at a plain directory the current user owns; replacing the tree (the error's hint says how) clears a refusal. diff --git a/website/docs/docs/configuration.md b/website/docs/docs/configuration.md index 5e4722e9e1..9e524a5da8 100644 --- a/website/docs/docs/configuration.md +++ b/website/docs/docs/configuration.md @@ -130,7 +130,7 @@ These env vars are the supported user-facing configuration surface. Other `AGENT | Local daemon storage | `AGENT_DEVICE_STATE_DIR` | Public | | Metro and install helpers | `AGENT_DEVICE_METRO_BEARER_TOKEN`, `AGENT_DEVICE_BUNDLETOOL_JAR` | Public | | App hooks and logs | `AGENT_DEVICE_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_APP_LOG_MAX_BYTES`, `AGENT_DEVICE_APP_LOG_MAX_FILES`, `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS`, `AGENT_DEVICE_EVENT_LOG_MAX_BYTES` | Public. Byte caps take whole integers (`5242880`), not `5MB`. | -| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED`, `AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. `AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM=0` keeps the build scratch of a runner cache key; by default it is removed after a successful build. | +| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. | | Install/update and platform helpers | `AGENT_DEVICE_NO_UPDATE_NOTIFIER`, `AGENT_DEVICE_MACOS_HELPER_BIN`, `AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION` | Public operator controls | | macOS app backend | `AGENT_DEVICE_MACOS_APP_BACKEND`, `AGENT_DEVICE_MACOS_GHOST_CURSOR` | Public operator controls, read by the daemon. `native` drives macOS app sessions through the macOS helper instead of XCTest; see [Commands](/docs/commands). Unset or `xctest` keeps the runner. The drawn agent pointer adds about 0.3 s to each native click, fill, type, and scroll; `AGENT_DEVICE_MACOS_GHOST_CURSOR=0` turns it off. Restart the daemon after changing either value. | From c9fadb42947c7de81c7f232adc3e855a03eadf56 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 6 Oct 2026 23:57:14 -0400 Subject: [PATCH 6/8] fix(apple-runner): trim only keys inside the managed runner cache --- .../__tests__/runner-cache-trim.test.ts | 51 +++++++++++++++++-- .../src/runner/runner-artifact-manifest.ts | 2 +- .../src/runner/runner-artifact.ts | 13 +++-- .../src/runner/runner-cache-metadata.ts | 2 +- .../src/runner/runner-cache-trim.ts | 36 +++++++++---- .../platform-apple/src/runner/runner-cache.ts | 1 + website/docs/docs/commands.md | 2 +- 7 files changed, 83 insertions(+), 24 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts index 6584d7ef55..20a2cd21b2 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts @@ -63,7 +63,7 @@ test('trimming keeps the products and the metadata file and removes the rest', a const derived = path.join(base, KEY); const protectedPaths = seedBuiltKey(derived); - const removed = await trimRunnerBuildScratch(derived, protectedPaths); + const removed = await trimRunnerBuildScratch(derived, protectedPaths, base); assert.deepEqual(removed.sort(), [ 'Build/Intermediates.noindex', @@ -89,13 +89,48 @@ test('trimming keeps the products and the metadata file and removes the rest', a test('a product outside the cache root leaves the tree untouched', async () => { const derived = path.join(base, KEY); seedBuiltKey(derived); + const outside = path.join(base, 'elsewhere.app'); + fs.mkdirSync(outside); const before = tree(derived); - assert.deepEqual(await trimRunnerBuildScratch(derived, [path.join(base, 'elsewhere.app')]), []); + assert.deepEqual(await trimRunnerBuildScratch(derived, [outside], base), []); assert.deepEqual(tree(derived), before); }); +test('a directory that is not a key of the managed root is not trimmed', async () => { + const managedRoot = path.join(base, 'managed'); + const fixed = path.join(base, 'fixed', KEY); + fs.mkdirSync(managedRoot); + const protectedPaths = seedBuiltKey(fixed); + const before = tree(fixed); + + assert.deepEqual(await trimRunnerBuildScratch(fixed, protectedPaths, managedRoot), []); + + assert.deepEqual(tree(fixed), before); +}); + +test('a symlinked key resolving outside the managed root is not trimmed', async () => { + const managedRoot = path.join(base, 'managed'); + const target = path.join(base, 'target'); + fs.mkdirSync(managedRoot); + const protectedPaths = seedBuiltKey(target); + const link = path.join(managedRoot, KEY); + fs.symlinkSync(target, link); + const before = tree(target); + + assert.deepEqual( + await trimRunnerBuildScratch( + link, + protectedPaths.map((product) => path.join(link, path.relative(target, product))), + managedRoot, + ), + [], + ); + + assert.deepEqual(tree(target), before); +}); + test('a product that is a symlink keeps the unit it points into', async () => { const derived = path.join(base, KEY); const [xctestrun] = seedBuiltKey(derived); @@ -103,7 +138,7 @@ test('a product that is a symlink keeps the unit it points into', async () => { const link = path.join(derived, 'Build', 'Products', 'Linked.app'); fs.symlinkSync(target, link); - const removed = await trimRunnerBuildScratch(derived, [xctestrun!, link]); + const removed = await trimRunnerBuildScratch(derived, [xctestrun!, link], base); assert.deepEqual(removed.sort(), [ 'Logs', @@ -170,13 +205,19 @@ test('a runner build trims its key, and the next start reuses the products witho assert.equal(runCmdStreaming.mock.calls.length, 1); }); -test('a runner build under a derived path override keeps its build scratch', async () => { +test('a derived path override named like a cache key keeps its build scratch', async () => { mockRunnerBuild(); - process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = path.join(base, 'fixed-derived'); + const managed = await ensureXctestrunArtifact(IOS_SIMULATOR, {}); + process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = path.join( + base, + 'fixed', + path.basename(managed.derived), + ); const built = await ensureXctestrunArtifact(IOS_SIMULATOR, {}); assert.equal(built.artifact, 'rebuilt'); + assert.equal(built.derived, process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH); assert.equal(fs.existsSync(path.join(built.derived, 'Build', 'Intermediates.noindex')), true); assert.equal(fs.existsSync(path.join(built.derived, 'Logs')), true); }); diff --git a/packages/platform-apple/src/runner/runner-artifact-manifest.ts b/packages/platform-apple/src/runner/runner-artifact-manifest.ts index b9a8486291..aa8b76e181 100644 --- a/packages/platform-apple/src/runner/runner-artifact-manifest.ts +++ b/packages/platform-apple/src/runner/runner-artifact-manifest.ts @@ -657,7 +657,7 @@ function isNonEmptyArray(value: unknown): value is Item[] { return Array.isArray(value) && value.length > 0; } -function isPathInsideDirectory(targetPath: string, directoryPath: string): boolean { +export function isPathInsideDirectory(targetPath: string, directoryPath: string): boolean { const relativePath = path.relative(path.resolve(directoryPath), path.resolve(targetPath)); return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath); } diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index eabab1e8e7..b980a6c593 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -35,6 +35,7 @@ import { resolveRunnerArchBuildSettings, resolveRunnerBuildLocationSettings, resolveRunnerBundleBuildSettings, + resolveRunnerDerivedBasePath, resolveRunnerDerivedPath, resolveRunnerMaxConcurrentDestinationsFlag, resolveRunnerPerformanceBuildSettings, @@ -366,10 +367,7 @@ async function buildXctestrunArtifact(params: { ), derived, ); - // An AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH override is a fixed tree rebuilt incrementally, not a keyed cache. - if (path.basename(derived) === resolveRunnerCacheKey(expectedCacheMetadata)) { - await trimRunnerBuildScratchBestEffort(derived, [built, ...builtProductPaths]); - } + await trimRunnerBuildScratchBestEffort(device, derived, [built, ...builtProductPaths]); emitRunnerXctestrunDecision('build', 'built_new', { derived, xctestrunPath: built, @@ -388,12 +386,17 @@ async function buildXctestrunArtifact(params: { /** Runs under the cache lock, so no rebuild or reuse of this key sees the tree mid-trim. */ async function trimRunnerBuildScratchBestEffort( + device: DeviceInfo, derived: string, protectedPaths: readonly string[], ): Promise { try { const { trimRunnerBuildScratch } = await import('./runner-cache-trim.ts'); - const removed = await trimRunnerBuildScratch(derived, protectedPaths); + const removed = await trimRunnerBuildScratch( + derived, + protectedPaths, + resolveRunnerDerivedBasePath(device), + ); if (removed.length > 0) emitRunnerXctestrunDecision('clean', 'build_scratch_trimmed', { derived }); } catch (error) { diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts index 79954c917f..9211b458ef 100644 --- a/packages/platform-apple/src/runner/runner-cache-metadata.ts +++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts @@ -491,7 +491,7 @@ export function resolveRunnerDerivedPath( return path.join(base, cacheKey); } -function resolveRunnerDerivedBasePath(device: DeviceInfo): string { +export function resolveRunnerDerivedBasePath(device: DeviceInfo): string { return path.join(RUNNER_DERIVED_ROOT, 'derived', resolveRunnerDerivedBaseName(device)); } diff --git a/packages/platform-apple/src/runner/runner-cache-trim.ts b/packages/platform-apple/src/runner/runner-cache-trim.ts index 4f0e1ba627..5044ebaac4 100644 --- a/packages/platform-apple/src/runner/runner-cache-trim.ts +++ b/packages/platform-apple/src/runner/runner-cache-trim.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { isPathInsideDirectory } from './runner-artifact-manifest.ts'; import { RUNNER_CACHE_METADATA_FILE } from './runner-cache-metadata.ts'; /** @@ -9,18 +10,31 @@ import { RUNNER_CACHE_METADATA_FILE } from './runner-cache-metadata.ts'; * scratch, so the scratch is never read again; a runner source or Xcode change mints a new key * instead of building into this one. * - * Returns the removed entries relative to `derived`. Nothing is removed when a product lies - * outside `derived`. The caller trims only a keyed cache directory. + * Returns the removed entries relative to `derived`. Nothing is removed when `derived` does not + * resolve, by real path, to a direct child of `managedRoot`, or when a product lies outside + * `derived`. A symlinked `derived` therefore trims only when its target is a key in `managedRoot`. */ export async function trimRunnerBuildScratch( derived: string, protectedPaths: readonly string[], + managedRoot: string, ): Promise { + if (!isManagedKey(derived, managedRoot)) return []; const kept = resolveKeptPaths(derived, protectedPaths); if (!kept) return []; return await trimDirectory(derived, derived, kept); } +function isManagedKey(derived: string, managedRoot: string): boolean { + try { + const realRoot = fs.realpathSync(managedRoot); + const realDerived = fs.realpathSync(derived); + return isPathInsideDirectory(realDerived, realRoot) && path.dirname(realDerived) === realRoot; + } catch { + return false; + } +} + /** * `Build/` is the unit kept under `Build`, so the products survive and their siblings do not. * A product that is missing or lies outside the key makes the trim a no-op. A product that is a @@ -31,11 +45,15 @@ function resolveKeptPaths(derived: string, protectedPaths: readonly string[]): S try { const realDerived = fs.realpathSync(derived); for (const protectedPath of protectedPaths) { - const lexical = path.relative(derived, protectedPath); - const real = path.relative(realDerived, fs.realpathSync(protectedPath)); - if (isOutside(lexical) || isOutside(real)) return null; - kept.add(keptUnit(lexical)); - kept.add(keptUnit(real)); + const realProtected = fs.realpathSync(protectedPath); + if ( + !isPathInsideDirectory(protectedPath, derived) || + !isPathInsideDirectory(realProtected, realDerived) + ) { + return null; + } + kept.add(keptUnit(path.relative(derived, protectedPath))); + kept.add(keptUnit(path.relative(realDerived, realProtected))); } } catch { return null; @@ -48,10 +66,6 @@ function keptUnit(relative: string): string { return segments.slice(0, segments[0] === 'Build' ? 2 : 1).join(path.sep); } -function isOutside(relative: string): boolean { - return !relative || relative.startsWith('..') || path.isAbsolute(relative); -} - async function trimDirectory( root: string, directory: string, diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts index 1c013eec87..963df33bce 100644 --- a/packages/platform-apple/src/runner/runner-cache.ts +++ b/packages/platform-apple/src/runner/runner-cache.ts @@ -27,6 +27,7 @@ export { resolveRunnerArchBuildSettings, resolveRunnerBuildLocationSettings, resolveRunnerBundleBuildSettings, + resolveRunnerDerivedBasePath, resolveRunnerDerivedPath, resolveRunnerMaxConcurrentDestinationsFlag, resolveRunnerPerformanceBuildSettings, diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 7c70f4ce4e..d05c48f6b9 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -280,7 +280,7 @@ agent-device prepare ios-runner --platform ios --timeout 240000 - If health checking exposes a bad restored runner artifact, Agent Device marks that artifact bad and rebuilds once. - If a fresh runner launch gets stuck before accepting connections, Agent Device invalidates that runner session and launches it once more without forcing a rebuild. - CI may cache `~/.agent-device/apple-runner/derived` when the cache key includes the exact Agent Device package contents and selected Xcode version. -- After a successful build, Agent Device removes the build scratch from the new cache key (intermediates, precompiled and module caches, logs) and keeps `Build/Products` and the metadata file, which is all reuse and launch read; a key goes from about 160-230 MB to about 5 MB. A key that fails certification is rebuilt from scratch either way, and a runner source or Xcode change mints a new key, so the scratch is never used again. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never trimmed, because a fixed path rebuilds into the same tree incrementally. +- After a successful build, Agent Device removes the build scratch from the new cache key (intermediates, precompiled and module caches, logs) and keeps `Build/Products` and the metadata file, which is all reuse and launch read; one iOS simulator key measured 165.7 MB before the trim and 5.9 MB after. A key that fails certification is rebuilt from scratch either way, and a runner source or Xcode change mints a new key, so the scratch is never used again. Only a key directly inside the managed runner cache is trimmed, compared by resolved real path; a `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` pointing elsewhere is never trimmed, because a fixed path rebuilds into the same tree incrementally. - Agent Device versions from before the Apple runner rename kept their cache in `~/.agent-device/ios-runner`. Current versions never read it, and it is not removed automatically because an older version installed on the same machine may still use it. Delete it with `rm -rf ~/.agent-device/ios-runner` once no old version runs. - Runner reuse is authorized only by the cache metadata's content manifest: a restored tree whose files no longer match the recorded digests, modes, or symlink targets is discarded and rebuilt. A cache key must stay exact — the runtime never falls back to a broader cache. - Certification is fail-closed: when a product tree cannot be certified at all — a product escaping the derived-data root, an unreadable subtree, a file over 128 MB, or a non-regular entry such as a socket — the build fails with `runner_cache_uncertifiable` naming the path instead of launching uncertified bytes. Point `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` at a plain directory the current user owns; replacing the tree (the error's hint says how) clears a refusal. From 39ae0023f8676b924ea3770bbba0f893cdd96b57 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 7 Oct 2026 12:12:03 -0400 Subject: [PATCH 7/8] fix(apple-runner): trim only the key this build resolves with no path override --- .../__tests__/runner-cache-trim.test.ts | 34 ++++++++++++++----- .../src/runner/runner-artifact.ts | 14 ++++---- .../src/runner/runner-cache-trim.ts | 15 ++++---- website/docs/docs/commands.md | 2 +- 4 files changed, 40 insertions(+), 25 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts index 20a2cd21b2..27c9d09bae 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts @@ -63,7 +63,7 @@ test('trimming keeps the products and the metadata file and removes the rest', a const derived = path.join(base, KEY); const protectedPaths = seedBuiltKey(derived); - const removed = await trimRunnerBuildScratch(derived, protectedPaths, base); + const removed = await trimRunnerBuildScratch(derived, protectedPaths, derived); assert.deepEqual(removed.sort(), [ 'Build/Intermediates.noindex', @@ -93,24 +93,24 @@ test('a product outside the cache root leaves the tree untouched', async () => { fs.mkdirSync(outside); const before = tree(derived); - assert.deepEqual(await trimRunnerBuildScratch(derived, [outside], base), []); + assert.deepEqual(await trimRunnerBuildScratch(derived, [outside], derived), []); assert.deepEqual(tree(derived), before); }); -test('a directory that is not a key of the managed root is not trimmed', async () => { - const managedRoot = path.join(base, 'managed'); +test('a directory that is not the expected key is not trimmed', async () => { + const expected = path.join(base, 'managed', KEY); const fixed = path.join(base, 'fixed', KEY); - fs.mkdirSync(managedRoot); + fs.mkdirSync(expected, { recursive: true }); const protectedPaths = seedBuiltKey(fixed); const before = tree(fixed); - assert.deepEqual(await trimRunnerBuildScratch(fixed, protectedPaths, managedRoot), []); + assert.deepEqual(await trimRunnerBuildScratch(fixed, protectedPaths, expected), []); assert.deepEqual(tree(fixed), before); }); -test('a symlinked key resolving outside the managed root is not trimmed', async () => { +test('a symlinked key resolving outside the expected key is not trimmed', async () => { const managedRoot = path.join(base, 'managed'); const target = path.join(base, 'target'); fs.mkdirSync(managedRoot); @@ -123,7 +123,7 @@ test('a symlinked key resolving outside the managed root is not trimmed', async await trimRunnerBuildScratch( link, protectedPaths.map((product) => path.join(link, path.relative(target, product))), - managedRoot, + path.join(managedRoot, 'other-key'), ), [], ); @@ -138,7 +138,7 @@ test('a product that is a symlink keeps the unit it points into', async () => { const link = path.join(derived, 'Build', 'Products', 'Linked.app'); fs.symlinkSync(target, link); - const removed = await trimRunnerBuildScratch(derived, [xctestrun!, link], base); + const removed = await trimRunnerBuildScratch(derived, [xctestrun!, link], derived); assert.deepEqual(removed.sort(), [ 'Logs', @@ -221,3 +221,19 @@ test('a derived path override named like a cache key keeps its build scratch', a assert.equal(fs.existsSync(path.join(built.derived, 'Build', 'Intermediates.noindex')), true); assert.equal(fs.existsSync(path.join(built.derived, 'Logs')), true); }); + +test('a derived path override inside the managed root keeps its build scratch', async () => { + mockRunnerBuild(); + const managed = await ensureXctestrunArtifact(IOS_SIMULATOR, {}); + process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = path.join( + path.dirname(managed.derived), + 'development', + ); + + const built = await ensureXctestrunArtifact(IOS_SIMULATOR, {}); + + assert.equal(built.artifact, 'rebuilt'); + assert.equal(built.derived, process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH); + assert.equal(fs.existsSync(path.join(built.derived, 'Build', 'Intermediates.noindex')), true); + assert.equal(fs.existsSync(path.join(built.derived, 'Logs')), true); +}); diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index b980a6c593..47fd0eaa37 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -367,7 +367,11 @@ async function buildXctestrunArtifact(params: { ), derived, ); - await trimRunnerBuildScratchBestEffort(device, derived, [built, ...builtProductPaths]); + await trimRunnerBuildScratchBestEffort( + path.join(resolveRunnerDerivedBasePath(device), resolveRunnerCacheKey(expectedCacheMetadata)), + derived, + [built, ...builtProductPaths], + ); emitRunnerXctestrunDecision('build', 'built_new', { derived, xctestrunPath: built, @@ -386,17 +390,13 @@ async function buildXctestrunArtifact(params: { /** Runs under the cache lock, so no rebuild or reuse of this key sees the tree mid-trim. */ async function trimRunnerBuildScratchBestEffort( - device: DeviceInfo, + expectedKeyPath: string, derived: string, protectedPaths: readonly string[], ): Promise { try { const { trimRunnerBuildScratch } = await import('./runner-cache-trim.ts'); - const removed = await trimRunnerBuildScratch( - derived, - protectedPaths, - resolveRunnerDerivedBasePath(device), - ); + const removed = await trimRunnerBuildScratch(derived, protectedPaths, expectedKeyPath); if (removed.length > 0) emitRunnerXctestrunDecision('clean', 'build_scratch_trimmed', { derived }); } catch (error) { diff --git a/packages/platform-apple/src/runner/runner-cache-trim.ts b/packages/platform-apple/src/runner/runner-cache-trim.ts index 5044ebaac4..76791de0ea 100644 --- a/packages/platform-apple/src/runner/runner-cache-trim.ts +++ b/packages/platform-apple/src/runner/runner-cache-trim.ts @@ -11,25 +11,24 @@ import { RUNNER_CACHE_METADATA_FILE } from './runner-cache-metadata.ts'; * instead of building into this one. * * Returns the removed entries relative to `derived`. Nothing is removed when `derived` does not - * resolve, by real path, to a direct child of `managedRoot`, or when a product lies outside - * `derived`. A symlinked `derived` therefore trims only when its target is a key in `managedRoot`. + * resolve, by real path, to `expectedKeyPath` (the key this build gets with no path override), or + * when a product lies outside `derived`. A symlinked `derived` therefore trims only when its target + * is that key. */ export async function trimRunnerBuildScratch( derived: string, protectedPaths: readonly string[], - managedRoot: string, + expectedKeyPath: string, ): Promise { - if (!isManagedKey(derived, managedRoot)) return []; + if (!isExpectedKey(derived, expectedKeyPath)) return []; const kept = resolveKeptPaths(derived, protectedPaths); if (!kept) return []; return await trimDirectory(derived, derived, kept); } -function isManagedKey(derived: string, managedRoot: string): boolean { +function isExpectedKey(derived: string, expectedKeyPath: string): boolean { try { - const realRoot = fs.realpathSync(managedRoot); - const realDerived = fs.realpathSync(derived); - return isPathInsideDirectory(realDerived, realRoot) && path.dirname(realDerived) === realRoot; + return fs.realpathSync(derived) === fs.realpathSync(expectedKeyPath); } catch { return false; } diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index d05c48f6b9..f8681734a3 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -280,7 +280,7 @@ agent-device prepare ios-runner --platform ios --timeout 240000 - If health checking exposes a bad restored runner artifact, Agent Device marks that artifact bad and rebuilds once. - If a fresh runner launch gets stuck before accepting connections, Agent Device invalidates that runner session and launches it once more without forcing a rebuild. - CI may cache `~/.agent-device/apple-runner/derived` when the cache key includes the exact Agent Device package contents and selected Xcode version. -- After a successful build, Agent Device removes the build scratch from the new cache key (intermediates, precompiled and module caches, logs) and keeps `Build/Products` and the metadata file, which is all reuse and launch read; one iOS simulator key measured 165.7 MB before the trim and 5.9 MB after. A key that fails certification is rebuilt from scratch either way, and a runner source or Xcode change mints a new key, so the scratch is never used again. Only a key directly inside the managed runner cache is trimmed, compared by resolved real path; a `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` pointing elsewhere is never trimmed, because a fixed path rebuilds into the same tree incrementally. +- After a successful build, Agent Device removes the build scratch from the new cache key (intermediates, precompiled and module caches, logs) and keeps `Build/Products` and the metadata file, which is all reuse and launch read; one iOS simulator key measured 165.7 MB before the trim and 5.9 MB after. A key that fails certification is rebuilt from scratch either way, and a runner source or Xcode change mints a new key, so the scratch is never used again. Only the key this build gets with no path override is trimmed, compared by resolved real path; a `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` pointing anywhere else, including inside the managed runner cache, is never trimmed, because a fixed path rebuilds into the same tree incrementally. - Agent Device versions from before the Apple runner rename kept their cache in `~/.agent-device/ios-runner`. Current versions never read it, and it is not removed automatically because an older version installed on the same machine may still use it. Delete it with `rm -rf ~/.agent-device/ios-runner` once no old version runs. - Runner reuse is authorized only by the cache metadata's content manifest: a restored tree whose files no longer match the recorded digests, modes, or symlink targets is discarded and rebuilt. A cache key must stay exact — the runtime never falls back to a broader cache. - Certification is fail-closed: when a product tree cannot be certified at all — a product escaping the derived-data root, an unreadable subtree, a file over 128 MB, or a non-regular entry such as a socket — the build fails with `runner_cache_uncertifiable` naming the path instead of launching uncertified bytes. Point `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` at a plain directory the current user owns; replacing the tree (the error's hint says how) clears a refusal. From fbefaf05197f29addcbfa06ec2e6289d8b2268c9 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 7 Oct 2026 15:27:28 -0400 Subject: [PATCH 8/8] fix(apple-runner): require the trimmed key to be a real directory under the real runner root --- packages/platform-apple/src/runner/runner-artifact.ts | 3 +-- packages/platform-apple/src/runner/runner-cache-metadata.ts | 2 +- packages/platform-apple/src/runner/runner-cache.ts | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index b1c9220f2a..2db0996401 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -39,7 +39,6 @@ import { resolveRunnerBuildLocationSettings, resolveRunnerBundleBuildSettings, resolveRunnerDerivedPath, - resolveRunnerKeyedDerivedPath, resolveRunnerMaxConcurrentDestinationsFlag, resolveRunnerPerformanceBuildSettings, resolveRunnerSandboxBuildArgs, @@ -61,7 +60,7 @@ import { resolveRunnerBuildDestination, resolveRunnerXctestrunHints, } from './apple-runner-platform.ts'; -import { resolveRunnerCacheKey } from './runner-cache-metadata.ts'; +import { resolveRunnerCacheKey, resolveRunnerKeyedDerivedPath } from './runner-cache-metadata.ts'; import { resolveAppleRunnerProjectPath } from './runner-source.ts'; export { prepareXctestrunWithEnv } from './runner-artifact-env.ts'; diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts index be302da00e..66e0546a25 100644 --- a/packages/platform-apple/src/runner/runner-cache-metadata.ts +++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts @@ -496,7 +496,7 @@ export function resolveRunnerKeyedDerivedPath( return path.join(resolveRunnerDerivedBasePath(device), resolveRunnerCacheKey(metadata)); } -export function resolveRunnerDerivedBasePath(device: DeviceInfo): string { +function resolveRunnerDerivedBasePath(device: DeviceInfo): string { return path.join(RUNNER_DERIVED_ROOT, 'derived', resolveRunnerDerivedBaseName(device)); } diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts index b987fa2521..e23a69b875 100644 --- a/packages/platform-apple/src/runner/runner-cache.ts +++ b/packages/platform-apple/src/runner/runner-cache.ts @@ -27,7 +27,6 @@ export { resolveRunnerArchBuildSettings, resolveRunnerBuildLocationSettings, resolveRunnerBundleBuildSettings, - resolveRunnerDerivedBasePath, resolveRunnerDerivedPath, resolveRunnerMaxConcurrentDestinationsFlag, resolveRunnerPerformanceBuildSettings,