From e2a27d92b23a4e09ebb15b4c4734b3b12b7449eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 26 Sep 2026 08:13:33 +0200 Subject: [PATCH 1/7] fix(ios-runner): certify cached runner products by content, not by stats A restored DerivedData tree was reuse-eligible when its cache metadata matched and every named product still existed with the size and mtime the metadata recorded. That signature survives patching a Mach-O in place, stripping a signature, or rewriting bytes through a restore that preserved stats, so a build could be blessed under an identity it was not compiled with. Reuse is now authorized by a content manifest: the writer digests every file under the cache root, records permission bits, and records symlink targets; the reader walks the same tree with the same traversal and refuses the first entry whose bytes, kind, mode, or target disagrees. Product discovery by directory scan is gone, along with the stat-only reuse path and the 'restore-key' cache kind it produced, because reuse cannot occur without a certified manifest. Source identity was equally narrow: only .swift/.m/.h files under apple/runner counted, so an edited .pbxproj, .xcscheme, or .xcworkspacedata reused a stale runner. Everything inside an Xcode package is now a build input, xcuserdata aside, and AGENT_DEVICE_XCUITEST_ARCHS reaches both the xcodebuild arguments and the cache identity. The build script and the metadata writer now emit their settings from one owner, and the writer checks them against what xcodebuild echoed in the build log before it certifies anything, so a drifted recipe cannot publish a manifest. Cache schema is 3; anything older rebuilds once. --- .../setup-apple-runner-build/action.yml | 11 + packages/contracts/src/prepare.ts | 2 +- .../platform-apple/src/core/runner-client.ts | 13 + .../src/runner-operations-facade.ts | 11 + .../__tests__/runner-artifact-reuse.test.ts | 616 +++++++++++++++ .../__tests__/runner-cache-metadata.test.ts | 9 +- .../src/runner/__tests__/runner-cache.test.ts | 314 +++++++- .../runner/__tests__/runner-client.test.ts | 700 +----------------- .../__tests__/runner-command-retry.test.ts | 2 +- .../runner/__tests__/runner-source.test.ts | 63 ++ .../__tests__/runner-xctestrun.fixtures.ts | 152 ++++ .../runner/__tests__/runner-xctestrun.test.ts | 333 +++++++-- .../src/runner/apple-runner-platform.ts | 45 ++ .../src/runner/runner-artifact.ts | 80 +- .../src/runner/runner-cache-metadata.ts | 161 +++- .../platform-apple/src/runner/runner-cache.ts | 514 ++++++++++--- .../src/runner/runner-source.ts | 63 +- scripts/__tests__/apple-ci-impact.test.ts | 11 + scripts/build-xcuitest-apple.sh | 48 +- scripts/write-xcuitest-cache-metadata.mjs | 527 ------------- scripts/write-xcuitest-cache-metadata.ts | 79 ++ scripts/xcuitest-build-settings.ts | 42 ++ src/commands/schema/cli-help-topics.test.ts | 2 +- src/commands/schema/cli-help.ts | 2 +- src/mcp/command-output-schemas.ts | 2 +- website/docs/docs/commands.md | 2 +- 26 files changed, 2260 insertions(+), 1544 deletions(-) create mode 100644 packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts create mode 100644 packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts delete mode 100644 scripts/write-xcuitest-cache-metadata.mjs create mode 100644 scripts/write-xcuitest-cache-metadata.ts create mode 100644 scripts/xcuitest-build-settings.ts diff --git a/.github/actions/setup-apple-runner-build/action.yml b/.github/actions/setup-apple-runner-build/action.yml index fedd064796..9c53d8f312 100644 --- a/.github/actions/setup-apple-runner-build/action.yml +++ b/.github/actions/setup-apple-runner-build/action.yml @@ -108,6 +108,17 @@ runs: shell: bash run: node --experimental-strip-types scripts/patch-xcuitest-runner-icon.ts "${{ inputs.derived-path }}" + # The icon patch rewrites product bytes after the cache was saved unpatched, so the + # content manifest on this machine must certify the tree that will actually run. + - name: Re-publish Apple runner cache metadata + shell: bash + run: | + node --experimental-strip-types scripts/write-xcuitest-cache-metadata.ts \ + "${{ inputs.xcuitest-platform }}" \ + "${{ inputs.derived-path }}" \ + "${{ inputs.xcuitest-destination }}" \ + "${{ inputs.derived-path }}/Logs/agent-device-build-for-testing.log" + - name: Report Apple runner build cache env: CACHE_HIT: ${{ steps.restore-runner-build.outputs.cache-hit }} diff --git a/packages/contracts/src/prepare.ts b/packages/contracts/src/prepare.ts index 0749af9936..64d37b1e99 100644 --- a/packages/contracts/src/prepare.ts +++ b/packages/contracts/src/prepare.ts @@ -1,7 +1,7 @@ import type { DeviceKind, PublicPlatform } from '@agent-device/kernel/device'; import type { JsonObject } from './json.ts'; -export type PrepareIosRunnerCacheKind = 'exact' | 'restore-key' | 'miss' | 'external'; +export type PrepareIosRunnerCacheKind = 'exact' | 'miss' | 'external'; export type PrepareIosRunnerArtifactState = 'valid' | 'rebuilt'; export type PrepareIosRunnerTiming = { diff --git a/packages/platform-apple/src/core/runner-client.ts b/packages/platform-apple/src/core/runner-client.ts index f084648476..41585fa780 100644 --- a/packages/platform-apple/src/core/runner-client.ts +++ b/packages/platform-apple/src/core/runner-client.ts @@ -38,3 +38,16 @@ export { hasCachedAppleRunnerArtifact, resolveRunnerAppBundleId, } from '../runner/runner-xctestrun.ts'; +export { findXctestrun as findRunnerXctestrun } from '../runner/runner-artifact.ts'; +export { resolveExistingXctestrunProductPaths as resolveExistingRunnerProductPaths } from '../runner/runner-xctestrun-products.ts'; +export { + requireRunnerBuildSettingsMatchBuildLog, + resolveExpectedRunnerCacheMetadata, + resolveRunnerArchBuildSettings, + resolveRunnerBundleBuildSettings, + resolveRunnerPerformanceBuildSettings, + resolveRunnerSandboxBuildArgs, + resolveRunnerSigningBuildSettings, +} from '../runner/runner-cache-metadata.ts'; +export { resolveRunnerScriptDevice } from '../runner/apple-runner-platform.ts'; +export { writeRunnerCacheMetadataForArtifacts } from '../runner/runner-cache.ts'; diff --git a/packages/platform-apple/src/runner-operations-facade.ts b/packages/platform-apple/src/runner-operations-facade.ts index 3cfb64fc2a..10277ef81d 100644 --- a/packages/platform-apple/src/runner-operations-facade.ts +++ b/packages/platform-apple/src/runner-operations-facade.ts @@ -1,6 +1,7 @@ export { applyXctestRunnerAppIconFromDerivedPath, detachIosRunnerSessionsForShutdown, + findRunnerXctestrun, hasLiveIosRunnerSession, notifyIosRunnerAppRelaunched, prepareIosRunner, @@ -10,11 +11,21 @@ export { readStaleRunnerLease, releaseIosRunnerOnClose, releaseSpeculativeIosRunnerSessionFor, + resolveExistingRunnerProductPaths, + resolveExpectedRunnerCacheMetadata, resolveRunnerAppBundleId, + resolveRunnerArchBuildSettings, + resolveRunnerBundleBuildSettings, + resolveRunnerPerformanceBuildSettings, + resolveRunnerSandboxBuildArgs, + resolveRunnerScriptDevice, + resolveRunnerSigningBuildSettings, + requireRunnerBuildSettingsMatchBuildLog, runAppleRunnerCommand, stopAllIosRunnerSessions, stopIosRunnerSession, verifyLeaseRunnerPidIdentity, + writeRunnerCacheMetadataForArtifacts, } from './core/runner-client.ts'; export { queryAppleRunnerSelector } from './core/runner-selector-query.ts'; export { restoreLegacyXctestDeviceSetRedirect } from './runner/runner-device-set.ts'; diff --git a/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts b/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts new file mode 100644 index 0000000000..05aa0c3917 --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts @@ -0,0 +1,616 @@ +import { + AppError, + createRequestCanceledError, + isRequestCanceledError, +} from '@agent-device/kernel/errors'; +import type { RequestProgressEvent } from '@agent-device/contracts/progress'; +import { beforeEach, onTestFinished, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { appleRunnerTestHost } from '../test-host.ts'; +import { resolveRunnerCacheMetadataPath } from '../runner-cache.ts'; +import { ensureXctestrunArtifact } from '../runner-artifact.ts'; +import { + createRunnerPhaseBudget, + markRunnerXctestrunArtifactBadForRun, + resolveExpectedRunnerCacheMetadata, + resolveRunnerDerivedPath, +} from '../runner-xctestrun.ts'; +import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts'; +import { + REPO_ROOT_FOR_TEST as repoRoot, + makeCachedRunnerXctestrun, + makeProjectScratchDir, + makeScratchDir, + seedRunnerProductBundle, + stripRunnerCacheArtifacts, + withRunnerDerivedPathEnv, + withoutRunnerDerivedPathEnv, + writeRunnerCacheMetadataWithArtifacts, + writeXctestrunFixture, +} from './runner-xctestrun.fixtures.ts'; + +const mockRunCmdStreaming = vi.fn(); +const { mockRepairMacOsRunnerProductsIfNeeded } = vi.hoisted(() => ({ + mockRepairMacOsRunnerProductsIfNeeded: vi.fn(), +})); + +vi.mock('../runner-macos-products.ts', async () => { + const actual = await vi.importActual( + '../runner-macos-products.ts', + ); + return { + ...actual, + repairMacOsRunnerProductsIfNeeded: mockRepairMacOsRunnerProductsIfNeeded, + }; +}); + +import type { DeviceInfo } from '@agent-device/kernel/device'; + +const iosSimulator: DeviceInfo = { + platform: 'apple', + id: 'sim-1', + name: 'iPhone Simulator', + kind: 'simulator', + booted: true, +}; + +const macOsDevice: DeviceInfo = { + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, +}; + +/** + * `ensureXctestrunArtifact` decides between reusing a certified DerivedData tree and rebuilding + * one. The cases below are that decision table: what a manifest does and does not certify, what + * survives repair, and what gets discarded before a rebuild can bless it. + */ + +beforeEach(() => { + vi.resetAllMocks(); + appleRunnerTestHost.update({ + runCmdStreaming: mockRunCmdStreaming, + runCmdSync: appleToolchainProbeResult, + readProcessStartTime: () => 'test-process-start', + }); + mockRunCmdStreaming.mockResolvedValue(undefined); + mockRepairMacOsRunnerProductsIfNeeded.mockResolvedValue(undefined); +}); + +test('ensureXctestrunArtifact reuses matching manifest artifacts from another project root', async () => { + const tmpDir = await makeScratchDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const productPath = path.join(derivedPath, 'Runner.app'); + const xctestrunPath = path.join(derivedPath, 'manifest.xctestrun'); + await seedRunnerProductBundle(productPath); + writeXctestrunFixture(xctestrunPath, { + projectRoot: '/tmp/other-agent-device-worktree', + productRelativePaths: ['Runner.app'], + }); + writeRunnerCacheMetadataWithArtifacts({ + derivedPath, + device: macOsDevice, + xctestrunPath, + productPaths: [productPath], + }); + withRunnerDerivedPathEnv(derivedPath); + + const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath; + + assert.equal(result, xctestrunPath); + assert.equal(mockRunCmdStreaming.mock.calls.length, 0); + assert.deepEqual(mockRepairMacOsRunnerProductsIfNeeded.mock.calls[0]?.[1], [productPath]); +}); + +test('ensureXctestrunArtifact rebuilds foreign artifacts when metadata does not match', async () => { + const projectRoot = repoRoot; + const tmpDir = await makeProjectScratchDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const productPath = path.join(derivedPath, 'Runner.app'); + const foreignXctestrunPath = path.join(derivedPath, 'foreign.xctestrun'); + const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); + await fs.promises.mkdir(productPath, { recursive: true }); + writeXctestrunFixture(foreignXctestrunPath, { + projectRoot: '/tmp/other-agent-device-worktree', + productRelativePaths: ['Runner.app'], + }); + writeRunnerCacheMetadataWithArtifacts({ + derivedPath, + device: macOsDevice, + xctestrunPath: foreignXctestrunPath, + productPaths: [productPath], + }); + const metadataPath = resolveRunnerCacheMetadataPath(derivedPath); + const staleMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + staleMetadata.runnerSandboxBuildArgs = staleMetadata.runnerSandboxBuildArgs.map((arg: string) => + arg.startsWith('OTHER_SWIFT_FLAGS=') + ? 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS' + : arg, + ); + fs.writeFileSync(metadataPath, JSON.stringify(staleMetadata, null, 2)); + withRunnerDerivedPathEnv(derivedPath); + + mockRunCmdStreaming.mockImplementation(async () => { + await seedRunnerProductBundle(path.join(derivedPath, 'rebuilt', 'Runner.app')); + writeXctestrunFixture(rebuiltXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + }); + + const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath; + + assert.equal(result, rebuiltXctestrunPath); + assert.equal(mockRunCmdStreaming.mock.calls.length, 1); + assert.equal(fs.existsSync(foreignXctestrunPath), false); +}); + +test('ensureXctestrunArtifact ignores manifest artifacts outside the cache root', async () => { + const projectRoot = repoRoot; + const tmpDir = await makeProjectScratchDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const externalDir = path.join(tmpDir, 'external'); + const externalProductPath = path.join(externalDir, 'Runner.app'); + const externalXctestrunPath = path.join(externalDir, 'external.xctestrun'); + const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); + await fs.promises.mkdir(externalProductPath, { recursive: true }); + writeXctestrunFixture(externalXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + await fs.promises.mkdir(derivedPath, { recursive: true }); + writeRunnerCacheMetadataWithArtifacts({ + derivedPath, + device: macOsDevice, + xctestrunPath: externalXctestrunPath, + productPaths: [externalProductPath], + }); + withRunnerDerivedPathEnv(derivedPath); + + mockRunCmdStreaming.mockImplementation(async () => { + await seedRunnerProductBundle(path.join(derivedPath, 'rebuilt', 'Runner.app')); + writeXctestrunFixture(rebuiltXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + }); + + const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath; + + assert.equal(result, rebuiltXctestrunPath); + assert.equal(mockRunCmdStreaming.mock.calls.length, 1); +}); + +test('ensureXctestrunArtifact aborts only the disconnected request build and preserves concurrent unrelated builds', async () => { + // The request AbortSignal must reach the xctestrun build (killProcessTree via + // runCmdStreaming); removing global abort must not orphan a disconnected prep. + // Request-scoped: aborting one request's build leaves an unrelated concurrent + // build (different device -> different derived, different signal) untouched. + withoutRunnerDerivedPathEnv(); + const canceledDevice = iosSimulator; + const survivorDevice = macOsDevice; + for (const device of [canceledDevice, survivorDevice]) { + const derived = resolveRunnerDerivedPath( + device, + resolveExpectedRunnerCacheMetadata(device, repoRoot), + ); + onTestFinished(async () => { + await fs.promises.rm(derived, { recursive: true, force: true }); + }); + } + + const canceledController = new AbortController(); + const survivorController = new AbortController(); + const deferred = (): { promise: Promise; resolve: (value: T) => void } => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; + }; + const waitForAbort = (signal: AbortSignal): Promise => + signal.aborted + ? Promise.resolve() + : new Promise((resolve) => + signal.addEventListener('abort', () => resolve(), { + once: true, + }), + ); + const canceledBuildStarted = deferred(); + const survivorBuildStarted = deferred(); + const releaseSurvivor = deferred(); + const cancellationError = createRequestCanceledError(); + + mockRunCmdStreaming.mockImplementation(async (_cmd, args, options) => { + const derived = args[args.indexOf('-derivedDataPath') + 1]; + if (options?.signal === canceledController.signal) { + canceledBuildStarted.resolve(); + await waitForAbort(options.signal); + throw cancellationError; + } + survivorBuildStarted.resolve(); + await releaseSurvivor.promise; + await seedRunnerProductBundle(path.join(derived, 'rebuilt', 'Runner.app')); + writeXctestrunFixture(path.join(derived, 'rebuilt', 'rebuilt.xctestrun'), { + projectRoot: repoRoot, + productRelativePaths: ['Runner.app'], + }); + }); + + const canceledPromise = ensureXctestrunArtifact(canceledDevice, { + budget: createRunnerPhaseBudget(undefined, canceledController.signal), + }); + const survivorPromise = ensureXctestrunArtifact(survivorDevice, { + budget: createRunnerPhaseBudget(undefined, survivorController.signal), + }); + + await Promise.all([canceledBuildStarted.promise, survivorBuildStarted.promise]); + + canceledController.abort(); + await assert.rejects(canceledPromise, (error: unknown) => { + assert.equal(error, cancellationError); + assert.ok(isRequestCanceledError(error)); + return true; + }); + // The unrelated concurrent build's signal was never aborted. + assert.equal(survivorController.signal.aborted, false); + + releaseSurvivor.resolve(); + const survivorResult = await survivorPromise; + assert.ok(survivorResult.xctestrunPath.endsWith('rebuilt.xctestrun')); + + const canceledCall = mockRunCmdStreaming.mock.calls.find( + (call) => call[2]?.signal === canceledController.signal, + ); + const survivorCall = mockRunCmdStreaming.mock.calls.find( + (call) => call[2]?.signal === survivorController.signal, + ); + assert.ok(canceledCall, 'canceled build received its request signal'); + assert.ok(survivorCall, 'survivor build received its request signal'); +}); + +test('ensureXctestrunArtifact rebuilds after cached macOS runner repair failure', async () => { + // Cached runner artifacts can look reusable until ad-hoc repair fails; ensure we clean once, + // rebuild, and return the repaired rebuilt xctestrun instead of looping on stale cache state. + const projectRoot = repoRoot; + const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun(macOsDevice); + const projectPath = path.join( + projectRoot, + 'apple', + 'runner', + 'AgentDeviceRunner', + 'AgentDeviceRunner.xcodeproj', + ); + + const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); + + withRunnerDerivedPathEnv(derivedPath); + + const repairedPaths: string[] = []; + + mockRepairMacOsRunnerProductsIfNeeded.mockImplementation( + async (_device, _productPaths, xctestrunPath) => { + repairedPaths.push(xctestrunPath); + if (xctestrunPath === existingXctestrunPath) { + throw new AppError('COMMAND_FAILED', 'cached runner is damaged', { + reason: 'RUNNER_PRODUCT_REPAIR_FAILED', + }); + } + }, + ); + mockRunCmdStreaming.mockImplementation(async (command, args) => { + assert.equal(command, 'xcodebuild'); + assert.ok(Array.isArray(args)); + assert.equal(args[args.indexOf('-project') + 1], projectPath); + assert.equal(args[args.indexOf('-derivedDataPath') + 1], derivedPath); + await seedRunnerProductBundle(path.join(derivedPath, 'rebuilt', 'Runner.app')); + writeXctestrunFixture(rebuiltXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + }); + + const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath; + + assert.equal(result, rebuiltXctestrunPath); + assert.equal(mockRunCmdStreaming.mock.calls.length, 1); + assert.equal(fs.existsSync(existingXctestrunPath), false); + assert.deepEqual(repairedPaths, [existingXctestrunPath, rebuiltXctestrunPath]); +}); + +test('ensureXctestrunArtifact prefers validated cache manifest over recursive scan', async () => { + const tmpDir = await makeScratchDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const manifestProductPath = path.join(derivedPath, 'ManifestRunner.app'); + const manifestXctestrunPath = path.join(derivedPath, 'manifest.xctestrun'); + const newerProductPath = path.join(derivedPath, 'NewerRunner.app'); + const newerXctestrunPath = path.join(derivedPath, 'newer.xctestrun'); + await seedRunnerProductBundle(manifestProductPath); + await seedRunnerProductBundle(newerProductPath); + writeXctestrunFixture(manifestXctestrunPath, { + projectRoot: repoRoot, + productRelativePaths: ['ManifestRunner.app'], + }); + writeXctestrunFixture(newerXctestrunPath, { + projectRoot: repoRoot, + productRelativePaths: ['NewerRunner.app'], + }); + const now = new Date(); + fs.utimesSync(manifestXctestrunPath, now, now); + fs.utimesSync( + newerXctestrunPath, + new Date(now.getTime() + 5_000), + new Date(now.getTime() + 5_000), + ); + writeRunnerCacheMetadataWithArtifacts({ + derivedPath, + device: macOsDevice, + xctestrunPath: manifestXctestrunPath, + productPaths: [manifestProductPath], + }); + withRunnerDerivedPathEnv(derivedPath); + + const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath; + + assert.equal(result, manifestXctestrunPath); + assert.equal(mockRunCmdStreaming.mock.calls.length, 0); + assert.deepEqual(mockRepairMacOsRunnerProductsIfNeeded.mock.calls[0]?.[1], [manifestProductPath]); +}); + +test('ensureXctestrunArtifact ignores a newer foreign xctestrun beside a certified build', async () => { + // The product-discovery scan is gone: a certified manifest is the only reuse authority, so + // a newer .xctestrun that nothing certifies cannot be blessed over it. + const tmpDir = await makeScratchDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const manifestProductPath = path.join(derivedPath, 'ManifestRunner.app'); + const manifestXctestrunPath = path.join(derivedPath, 'manifest.xctestrun'); + const newerProductPath = path.join(derivedPath, 'NewerRunner.app'); + const newerXctestrunPath = path.join(derivedPath, 'newer.xctestrun'); + await seedRunnerProductBundle(manifestProductPath); + await seedRunnerProductBundle(newerProductPath); + writeXctestrunFixture(manifestXctestrunPath, { + projectRoot: repoRoot, + productRelativePaths: ['ManifestRunner.app'], + }); + writeXctestrunFixture(newerXctestrunPath, { + projectRoot: repoRoot, + productRelativePaths: ['NewerRunner.app'], + }); + const now = new Date(); + fs.utimesSync(manifestXctestrunPath, now, now); + fs.utimesSync( + newerXctestrunPath, + new Date(now.getTime() + 5_000), + new Date(now.getTime() + 5_000), + ); + writeRunnerCacheMetadataWithArtifacts({ + derivedPath, + device: macOsDevice, + xctestrunPath: manifestXctestrunPath, + productPaths: [manifestProductPath], + }); + withRunnerDerivedPathEnv(derivedPath); + + const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath; + + assert.equal(result, manifestXctestrunPath); + assert.equal(mockRunCmdStreaming.mock.calls.length, 0); + assert.deepEqual(mockRepairMacOsRunnerProductsIfNeeded.mock.calls[0]?.[1], [manifestProductPath]); +}); + +test('ensureXctestrunArtifact discards and rebuilds a manifest whose bytes no longer match', async () => { + const tmpDir = await makeProjectScratchDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const productPath = path.join(derivedPath, 'Runner.app'); + const cachedXctestrunPath = path.join(derivedPath, 'cached.xctestrun'); + const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); + await seedRunnerProductBundle(productPath); + writeXctestrunFixture(cachedXctestrunPath, { + projectRoot: repoRoot, + productRelativePaths: ['Runner.app'], + }); + writeRunnerCacheMetadataWithArtifacts({ + derivedPath, + device: macOsDevice, + xctestrunPath: cachedXctestrunPath, + productPaths: [productPath], + }); + // The failure the old stat signature could not see: same size, same stats, new bytes. + const stat = fs.statSync(path.join(productPath, 'Runner')); + fs.writeFileSync(path.join(productPath, 'Runner'), Buffer.from('runner-executab\n'), { + mode: 0o755, + }); + fs.utimesSync(path.join(productPath, 'Runner'), stat.atime, stat.mtime); + withRunnerDerivedPathEnv(derivedPath); + + mockRunCmdStreaming.mockImplementation(async () => { + await seedRunnerProductBundle(path.join(derivedPath, 'rebuilt', 'Runner.app')); + writeXctestrunFixture(rebuiltXctestrunPath, { + projectRoot: repoRoot, + productRelativePaths: ['Runner.app'], + }); + }); + + const result = await ensureXctestrunArtifact(macOsDevice, {}); + + assert.equal(result.xctestrunPath, rebuiltXctestrunPath); + assert.equal(result.reason, 'artifact_content_mismatch'); + assert.equal(mockRunCmdStreaming.mock.calls.length, 1); + assert.equal(fs.existsSync(cachedXctestrunPath), false); +}); + +test('ensureXctestrunArtifact rebuilds cached runner when Swift build flags mismatch', async () => { + const projectRoot = repoRoot; + const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun(macOsDevice); + const metadataPath = resolveRunnerCacheMetadataPath(derivedPath); + const expectedMetadata = resolveExpectedRunnerCacheMetadata(macOsDevice, repoRoot); + const staleMetadata = { + ...expectedMetadata, + runnerSandboxBuildArgs: expectedMetadata.runnerSandboxBuildArgs.map((arg) => + arg.startsWith('OTHER_SWIFT_FLAGS=') + ? 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS' + : arg, + ), + }; + fs.writeFileSync(metadataPath, JSON.stringify(staleMetadata, null, 2)); + + const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); + + withRunnerDerivedPathEnv(derivedPath); + + mockRunCmdStreaming.mockImplementation(async () => { + await seedRunnerProductBundle(path.join(derivedPath, 'rebuilt', 'Runner.app')); + writeXctestrunFixture(rebuiltXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + }); + + const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath; + + assert.equal(result, rebuiltXctestrunPath); + assert.equal(mockRunCmdStreaming.mock.calls.length, 1); + assert.equal(fs.existsSync(existingXctestrunPath), false); + const rebuiltMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + assert.deepEqual( + stripRunnerCacheArtifacts(rebuiltMetadata), + resolveExpectedRunnerCacheMetadata(macOsDevice, repoRoot), + ); + assert.equal(rebuiltMetadata.artifacts?.xctestrunPath, rebuiltXctestrunPath); +}); + +test('ensureXctestrunArtifact passes sandbox-disabling settings to xcodebuild', async () => { + const projectRoot = repoRoot; + const tmpDir = await makeProjectScratchDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const rebuiltXctestrunPath = path.join(derivedPath, 'Build', 'Products', 'rebuilt.xctestrun'); + + withRunnerDerivedPathEnv(derivedPath); + + mockRunCmdStreaming.mockImplementationOnce(async () => { + await seedRunnerProductBundle(path.join(derivedPath, 'Build', 'Products', 'Runner.app')); + writeXctestrunFixture(rebuiltXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + }); + + const result = await ensureXctestrunArtifact(iosSimulator, { + forceRunnerXctestrunRebuild: true, + }); + + assert.equal(result.xctestrunPath, rebuiltXctestrunPath); + assert.equal(mockRunCmdStreaming.mock.calls.length, 1); + const args = mockRunCmdStreaming.mock.calls[0]?.[1] ?? []; + assert.equal(args.includes('-IDEPackageSupportDisableManifestSandbox=1'), true); + assert.equal(args.includes('-IDEPackageSupportDisablePluginExecutionSandbox=1'), true); + assert.equal(args.includes('ENABLE_USER_SCRIPT_SANDBOXING=NO'), true); + assert.equal( + args.includes( + 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY', + ), + true, + ); +}); + +test('ensureXctestrunArtifact emits build progress on cache miss', async () => { + const projectRoot = repoRoot; + const tmpDir = await makeProjectScratchDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const rebuiltXctestrunPath = path.join(derivedPath, 'Build', 'Products', 'rebuilt.xctestrun'); + const events: RequestProgressEvent[] = []; + + withRunnerDerivedPathEnv(derivedPath); + + mockRunCmdStreaming.mockImplementationOnce(async () => { + await seedRunnerProductBundle(path.join(derivedPath, 'Build', 'Products', 'Runner.app')); + writeXctestrunFixture(rebuiltXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + }); + + appleRunnerTestHost.update({ emitRequestProgress: (event) => events.push(event) }); + + const result = await ensureXctestrunArtifact(iosSimulator, { + forceRunnerXctestrunRebuild: true, + }); + + assert.equal(result.xctestrunPath, rebuiltXctestrunPath); + assert.deepEqual(events, [ + { + type: 'command', + status: 'progress', + message: 'Building Apple runner...', + }, + ]); +}); + +test('ensureXctestrunArtifact stress-recovers after a bad restored artifact', async () => { + const projectRoot = repoRoot; + const tmpDir = await makeProjectScratchDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const productPath = path.join(derivedPath, 'Runner.app'); + const cachedXctestrunPath = path.join(derivedPath, 'cached.xctestrun'); + await seedRunnerProductBundle(productPath); + writeXctestrunFixture(cachedXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + writeRunnerCacheMetadataWithArtifacts({ + derivedPath, + device: macOsDevice, + xctestrunPath: cachedXctestrunPath, + productPaths: [productPath], + }); + withRunnerDerivedPathEnv(derivedPath); + + const hit = await ensureXctestrunArtifact(macOsDevice, {}); + + assert.equal(hit.xctestrunPath, cachedXctestrunPath); + assert.equal(hit.cache, 'exact'); + assert.equal(hit.artifact, 'valid'); + assert.equal(hit.buildMs, 0); + assert.equal(mockRunCmdStreaming.mock.calls.length, 0); + + await markRunnerXctestrunArtifactBadForRun(hit, 'stress health failed'); + assert.equal(fs.existsSync(cachedXctestrunPath), false); + + const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); + mockRunCmdStreaming.mockImplementationOnce(async () => { + await seedRunnerProductBundle(path.join(derivedPath, 'rebuilt', 'Runner.app')); + writeXctestrunFixture(rebuiltXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + }); + + const rebuilt = await ensureXctestrunArtifact(macOsDevice, { + budget: createRunnerPhaseBudget(300_000, undefined), + }); + + assert.equal(rebuilt.xctestrunPath, rebuiltXctestrunPath); + assert.equal(rebuilt.cache, 'miss'); + assert.equal(rebuilt.artifact, 'rebuilt'); + assert.equal(rebuilt.reason, 'cache_metadata_missing'); + assert.equal(mockRunCmdStreaming.mock.calls.length, 1); + assert.equal(Math.ceil(Number(mockRunCmdStreaming.mock.calls[0]?.[2]?.timeoutMs) / 1e3), 300); // phase remainder (#2422) +}); + +test('ensureXctestrunArtifact rethrows unexpected cached macOS runner repair errors', async () => { + const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun(macOsDevice); + + withRunnerDerivedPathEnv(derivedPath); + + mockRepairMacOsRunnerProductsIfNeeded.mockRejectedValue(new Error('permission denied')); + + await assert.rejects(ensureXctestrunArtifact(macOsDevice, {}), /permission denied/); + assert.equal(mockRunCmdStreaming.mock.calls.length, 0); + assert.equal(fs.existsSync(existingXctestrunPath), true); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts index 64ebcba9df..a897aa7b5b 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts @@ -121,7 +121,7 @@ test('resolveRunnerSandboxBuildArgs disables nested Xcode and Swift sandboxing', '-IDEPackageSupportDisableManifestSandbox=1', '-IDEPackageSupportDisablePluginExecutionSandbox=1', 'ENABLE_USER_SCRIPT_SANDBOXING=NO', - 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox', + 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY', ]); }); @@ -133,7 +133,7 @@ test('resolveRunnerSandboxBuildArgs includes Swift runner unit tests only when r '-IDEPackageSupportDisableManifestSandbox=1', '-IDEPackageSupportDisablePluginExecutionSandbox=1', 'ENABLE_USER_SCRIPT_SANDBOXING=NO', - 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS', + 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY -D AGENT_DEVICE_RUNNER_UNIT_TESTS', ]); } finally { if (previous === undefined) { @@ -172,9 +172,10 @@ test('metadata diff names only the comparable keys that differ, with expected an runnerPerformanceBuildSettings: ['ENABLE_CODE_COVERAGE=YES'], artifacts: { xctestrunPath: '/tmp/derived/Runner.xctestrun', - xctestrunMtimeMs: 1, xctestrunSize: 2, - productPaths: [{ path: '/tmp/derived/Runner.app', mtimeMs: 1, size: 2 }], + xctestrunDigest: 'a'.repeat(64), + productPaths: ['/tmp/derived/Runner.app'], + entries: [{ path: 'Runner.app/Runner', size: 2, mode: 0o755, digest: 'b'.repeat(64) }], }, }; diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts index b184b2fda1..2fac41e36a 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts @@ -1,11 +1,15 @@ import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { onTestFinished, test } from 'vitest'; import { evaluateExistingXctestrun, + resolveRunnerCacheMetadataPath, writeRunnerCacheMetadata, + writeRunnerCacheMetadataForArtifacts, type ExistingXctestrunState, + type RunnerXctestrunCacheMetadata, } from '../runner-cache.ts'; import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts'; import { IOS_SIMULATOR } from './device-fixtures.ts'; @@ -14,33 +18,78 @@ import { stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; stubAppleToolchainProbes(); -function evaluateAgainstCachedMetadata( - cached: Record, -): Promise { +const EXECUTABLE_BYTES = Buffer.alloc(4096, 7); + +type CachedRunnerBuild = { + derived: string; + xctestrunPath: string; + runnerAppPath: string; + executablePath: string; + expected: RunnerXctestrunCacheMetadata; +}; + +/** + * A cache root laid out the way a build leaves it: the `.xctestrun` under Build/Products, a + * product bundle with an executable, and a manifest certifying both. + */ +function makeCachedRunnerBuild(): CachedRunnerBuild { const derived = mkdtempForTestSync('agent-device-runner-cache-eval-'); onTestFinished(() => fs.rmSync(derived, { recursive: true, force: true })); - const xctestrunPath = path.join(derived, 'Runner.xctestrun'); - fs.writeFileSync(xctestrunPath, 'xctestrun'); - writeRunnerCacheMetadata(derived, cached as never); - return evaluateExistingXctestrun({ + const productsPath = path.join(derived, 'Build', 'Products'); + const runnerAppPath = path.join(productsPath, 'Debug-iphonesimulator', 'Runner-Runner.app'); + fs.mkdirSync(runnerAppPath, { recursive: true }); + const xctestrunPath = path.join(productsPath, 'Runner_iphonesimulator26.2-arm64.xctestrun'); + fs.writeFileSync(xctestrunPath, 'xctestrun'); + const executablePath = path.join(runnerAppPath, 'Runner'); + fs.writeFileSync(executablePath, EXECUTABLE_BYTES, { mode: 0o755 }); + const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]); + return { derived, xctestrunPath, runnerAppPath, executablePath, expected }; +} + +function mismatchOf( + state: ExistingXctestrunState, +): Extract { + assert.equal(state.reason, 'artifact_content_mismatch'); + return state; +} + +test('a manifest-certified build is reused', async () => { + const { derived, xctestrunPath, executablePath, expected } = makeCachedRunnerBuild(); + + const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); + + assert.equal(state.reason, 'reuse_ready'); + assert.equal(state.reason === 'reuse_ready' ? state.xctestrunPath : null, xctestrunPath); + assert.deepEqual(state.reason === 'reuse_ready' ? state.productPaths : null, [ + path.join(derived, 'Build', 'Products', 'Debug-iphonesimulator', 'Runner-Runner.app'), + ]); + assert.ok(fs.existsSync(executablePath)); +}); + +test('reuse ignores the non-comparable package version', async () => { + const { derived, expected } = makeCachedRunnerBuild(); + + const state = await evaluateExistingXctestrun({ derived, - projectRoot: process.cwd(), - expectedCacheMetadata: resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR), - findXctestrun: () => xctestrunPath, - xctestrunReferencesProjectRoot: () => true, - resolveExistingXctestrunProductPaths: () => Promise.resolve([path.join(derived, 'Runner.app')]), + expectedCacheMetadata: { ...expected, packageVersion: `${expected.packageVersion}-next` }, }); -} + + assert.equal(state.reason, 'reuse_ready'); +}); test('a metadata mismatch names the differing keys with expected and actual', async () => { const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - - const state = await evaluateAgainstCachedMetadata({ + const derived = mkdtempForTestSync('agent-device-runner-cache-eval-'); + onTestFinished(() => fs.rmSync(derived, { recursive: true, force: true })); + writeRunnerCacheMetadata(derived, { ...expected, xcodeBuildVersion: '17A100', runnerSandboxBuildArgs: [...expected.runnerSandboxBuildArgs, 'EXTRA=1'], }); + const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); + assert.equal(state.reason, 'cache_metadata_mismatch'); assert.deepEqual(state.reason === 'cache_metadata_mismatch' ? state.metadataDifferences : null, [ { @@ -52,13 +101,238 @@ test('a metadata mismatch names the differing keys with expected and actual', as ]); }); -test('metadata that differs only in the non-comparable fields still reuses the cache', async () => { - const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); +test('an architecture override changes the cache identity', () => { + const previous = process.env.AGENT_DEVICE_XCUITEST_ARCHS; + try { + process.env.AGENT_DEVICE_XCUITEST_ARCHS = 'arm64'; + const pinned = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + delete process.env.AGENT_DEVICE_XCUITEST_ARCHS; + const unpinned = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + + assert.deepEqual(pinned.runnerArchBuildSettings, ['ARCHS=arm64']); + assert.deepEqual(unpinned.runnerArchBuildSettings, []); + assert.notDeepEqual(pinned, unpinned); + } finally { + if (previous === undefined) { + delete process.env.AGENT_DEVICE_XCUITEST_ARCHS; + } else { + process.env.AGENT_DEVICE_XCUITEST_ARCHS = previous; + } + } +}); + +test('every runner build compiles the isolation canary', () => { + const swiftFlags = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR).runnerSandboxBuildArgs.find( + (arg) => arg.startsWith('OTHER_SWIFT_FLAGS='), + ); + + assert.equal( + swiftFlags, + 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY', + ); +}); + +test('executable bytes rewritten with equal size and preserved stats break reuse', async () => { + const { derived, executablePath, expected } = makeCachedRunnerBuild(); + const stat = fs.statSync(executablePath); + + fs.writeFileSync(executablePath, Buffer.alloc(EXECUTABLE_BYTES.length, 9), { mode: 0o755 }); + fs.utimesSync(executablePath, stat.atime, stat.mtime); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.deepEqual(state.mismatch, { + path: path.relative(derived, executablePath), + reason: 'digest_mismatch', + }); +}); + +test('a size change breaks reuse', async () => { + const { derived, executablePath, expected } = makeCachedRunnerBuild(); + + fs.appendFileSync(executablePath, 'x'); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.equal(state.mismatch.reason, 'size_changed'); +}); + +test('a lost permission bit breaks reuse', async () => { + const { derived, executablePath, expected } = makeCachedRunnerBuild(); + + fs.chmodSync(executablePath, 0o644); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.equal(state.mismatch.reason, 'mode_changed'); +}); + +test('a missing executable breaks reuse', async () => { + const { derived, executablePath, expected } = makeCachedRunnerBuild(); + + fs.rmSync(executablePath); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); - const state = await evaluateAgainstCachedMetadata({ + assert.equal(state.mismatch.reason, 'missing'); +}); + +test('a certified executable replaced by a directory breaks reuse', async () => { + const { derived, executablePath, expected } = makeCachedRunnerBuild(); + + fs.rmSync(executablePath); + fs.mkdirSync(executablePath); + + // The manifest names a file at that path; the tree now holds no file there at all. + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.equal(state.mismatch.reason, 'missing'); +}); + +test('a file added under a certified product breaks reuse', async () => { + const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); + + fs.writeFileSync(path.join(runnerAppPath, 'injected.dylib'), 'injected'); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.equal(state.mismatch.reason, 'undeclared_entry'); +}); + +test('a certified symlink that starts escaping the cache root breaks reuse', async () => { + const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); + onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); + const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); + const linkPath = path.join(runnerAppPath, 'Frameworks'); + fs.mkdirSync(path.join(runnerAppPath, 'FrameworksInside'), { recursive: true }); + fs.symlinkSync('FrameworksInside', linkPath); + writeRunnerCacheMetadataForArtifacts(derived, expected, publishedXctestrun(derived), [ + runnerAppPath, + ]); + + fs.rmSync(linkPath); + fs.symlinkSync(path.join(outside, 'evil'), linkPath); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.equal(state.mismatch.reason, 'symlink_target_changed'); +}); + +test('a manifest that certifies an escaping symlink refuses reuse', async () => { + const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); + onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); + const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); + const escapingTarget = path.join(outside, 'Frameworks'); + fs.symlinkSync(escapingTarget, path.join(runnerAppPath, 'Frameworks')); + writeRunnerCacheMetadata(derived, { ...expected, - packageVersion: `${expected.packageVersion}-next`, + artifacts: { + xctestrunPath: publishedXctestrun(derived), + xctestrunSize: fs.statSync(publishedXctestrun(derived)).size, + xctestrunDigest: digest(publishedXctestrun(derived)), + productPaths: [runnerAppPath], + entries: [ + { + path: path + .relative(derived, path.join(runnerAppPath, 'Frameworks')) + .replaceAll(path.sep, '/'), + symlink: escapingTarget, + }, + { + path: path + .relative(derived, path.join(runnerAppPath, 'Runner')) + .replaceAll(path.sep, '/'), + size: EXECUTABLE_BYTES.length, + mode: 0o755, + digest: digest(path.join(runnerAppPath, 'Runner')), + }, + ], + }, }); - assert.equal(state.reason, 'reuse_ready'); + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.equal(state.mismatch.reason, 'escaping_symlink'); +}); + +test('a build holding an escaping symlink publishes no manifest at all', async () => { + const { derived, runnerAppPath, xctestrunPath, expected } = makeCachedRunnerBuild(); + fs.symlinkSync('../../../../../outside', path.join(runnerAppPath, 'Frameworks')); + + writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]); + + const published = JSON.parse( + fs.readFileSync(resolveRunnerCacheMetadataPath(derived), 'utf8'), + ) as { artifacts?: unknown }; + assert.equal(published.artifacts, undefined); +}); + +test('products without a content manifest are a miss, never a reuse', async () => { + const { derived, expected } = makeCachedRunnerBuild(); + + writeRunnerCacheMetadata(derived, expected); + + const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); + + assert.equal(state.reason, 'artifact_manifest_missing'); }); + +test('a manifest naming paths outside the cache root is a miss', async () => { + const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); + onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); + const { derived, expected } = makeCachedRunnerBuild(); + const foreignXctestrun = path.join(outside, 'foreign.xctestrun'); + fs.writeFileSync(foreignXctestrun, 'xctestrun'); + writeRunnerCacheMetadata(derived, { + ...expected, + artifacts: { + xctestrunPath: foreignXctestrun, + xctestrunSize: 1, + xctestrunDigest: '0'.repeat(64), + productPaths: [path.join(outside, 'Runner-Runner.app')], + entries: [{ path: 'Runner', size: 1, mode: 0o755, digest: '0'.repeat(64) }], + }, + }); + + const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); + + assert.equal(state.reason, 'artifact_manifest_missing'); +}); + +test('a manifest written for a foreign cache root certifies nothing', async () => { + const { derived, expected } = makeCachedRunnerBuild(); + writeRunnerCacheMetadataForArtifacts( + derived, + expected, + path.join(derived, 'Build', 'Products', 'other.xctestrun'), + [path.join(derived, 'Build', 'Products', 'Debug-iphonesimulator', 'Runner-Runner.app')], + ); + + const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); + + assert.equal(state.reason, 'artifact_manifest_missing'); +}); + +function publishedXctestrun(derived: string): string { + return path.join(derived, 'Build', 'Products', 'Runner_iphonesimulator26.2-arm64.xctestrun'); +} + +function digest(filePath: string): string { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} diff --git a/packages/platform-apple/src/runner/__tests__/runner-client.test.ts b/packages/platform-apple/src/runner/__tests__/runner-client.test.ts index 9b99d107eb..5722bb1517 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-client.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-client.test.ts @@ -1,33 +1,7 @@ -import { - createRequestCanceledError, - isRequestCanceledError, - AppError, -} from '@agent-device/kernel/errors'; -import type { RequestProgressEvent } from '@agent-device/contracts/progress'; -import { beforeEach, test, onTestFinished, vi } from 'vitest'; +import { onTestFinished, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { mkdtempForTest } from './tmp-dir.ts'; -import { appleRunnerTestHost } from '../test-host.ts'; - -const mockRunCmdStreaming = vi.fn(); -const mockRunCmdSync = vi.fn(); -const { mockRepairMacOsRunnerProductsIfNeeded } = vi.hoisted(() => ({ - mockRepairMacOsRunnerProductsIfNeeded: vi.fn(), -})); - -vi.mock('../runner-macos-products.ts', async () => { - const actual = await vi.importActual( - '../runner-macos-products.ts', - ); - return { - ...actual, - repairMacOsRunnerProductsIfNeeded: mockRepairMacOsRunnerProductsIfNeeded, - }; -}); - import type { DeviceInfo } from '@agent-device/kernel/device'; import { isReadOnlyRunnerCommand } from '../runner-command-traits.ts'; import { withRunnerCommandId } from '../runner-contract.ts'; @@ -38,17 +12,18 @@ import { import { acquireRunnerXctestrunCacheLock, assertSafeDerivedCleanup, - resolveRunnerCacheMetadataPath, shouldDeleteRunnerDerivedRootEntry, - writeRunnerCacheMetadata, } from '../runner-cache.ts'; -import { ensureXctestrunArtifact, xctestrunReferencesProjectRoot } from '../runner-artifact.ts'; import { - createRunnerPhaseBudget, - markRunnerXctestrunArtifactBadForRun, resolveExpectedRunnerCacheMetadata, resolveRunnerDerivedPath, } from '../runner-xctestrun.ts'; +import { stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; +import { + REPO_ROOT_FOR_TEST, + makeScratchDir, + withoutRunnerDerivedPathEnv, +} from './runner-xctestrun.fixtures.ts'; const iosSimulator: DeviceInfo = { platform: 'apple', @@ -94,148 +69,11 @@ const macOsDevice: DeviceInfo = { booted: true, }; -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../..'); +const repoRoot = REPO_ROOT_FOR_TEST; -async function makeTmpDir(): Promise { - const tmpDir = await mkdtempForTest('agent-device-xctestrun-'); - onTestFinished(async () => { - await fs.promises.rm(tmpDir, { recursive: true, force: true }); - }); - return tmpDir; -} - -async function makeProjectTmpDir(): Promise { - const tmpRoot = path.join(repoRoot, '.tmp'); - await fs.promises.mkdir(tmpRoot, { recursive: true }); - const tmpDir = await fs.promises.mkdtemp(path.join(tmpRoot, 'agent-device-xctestrun-')); - onTestFinished(async () => { - await fs.promises.rm(tmpDir, { recursive: true, force: true }); - }); - return tmpDir; -} - -function writeXctestrunFixture( - xctestrunPath: string, - options: { projectRoot: string; productRelativePaths: string[] }, -): void { - const entries = options.productRelativePaths - .map((relativePath) => ` __TESTROOT__/${relativePath}`) - .join('\n'); - fs.mkdirSync(path.dirname(xctestrunPath), { recursive: true }); - fs.writeFileSync( - xctestrunPath, - ` - - - - ProjectRootHint - ${options.projectRoot} - ProductPaths - -${entries} - - -`, - 'utf8', - ); -} - -function withRunnerDerivedPathEnv(derivedPath: string): void { - const previousDerivedPath = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; - process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = derivedPath; - onTestFinished(() => { - restoreEnvVar('AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH', previousDerivedPath); - }); -} - -function withoutRunnerDerivedPathEnv(): void { - const previousDerivedPath = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; - delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; - onTestFinished(() => { - restoreEnvVar('AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH', previousDerivedPath); - }); -} - -function restoreEnvVar(name: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[name]; - return; - } - process.env[name] = value; -} - -function stripRunnerCacheArtifacts(metadata: Record): Record { - const { artifacts: _artifacts, ...rest } = metadata; - return rest; -} - -function writeRunnerCacheMetadataWithArtifacts(params: { - derivedPath: string; - device: DeviceInfo; - xctestrunPath: string; - productPaths: string[]; -}): void { - fs.writeFileSync( - resolveRunnerCacheMetadataPath(params.derivedPath), - JSON.stringify( - { - ...resolveExpectedRunnerCacheMetadata(params.device, repoRoot), - artifacts: { - xctestrunPath: params.xctestrunPath, - xctestrunMtimeMs: Math.trunc(fs.statSync(params.xctestrunPath).mtimeMs), - xctestrunSize: fs.statSync(params.xctestrunPath).size, - productPaths: params.productPaths.map((productPath) => ({ - path: productPath, - mtimeMs: Math.trunc(fs.statSync(productPath).mtimeMs), - size: fs.statSync(productPath).size, - })), - }, - }, - null, - 2, - ), - ); -} - -async function makeCachedRunnerXctestrun(): Promise<{ - derivedPath: string; - existingXctestrunPath: string; -}> { - const tmpDir = await makeProjectTmpDir(); - const derivedPath = path.join(tmpDir, 'custom-derived'); - const existingXctestrunPath = path.join(derivedPath, 'existing.xctestrun'); - await fs.promises.mkdir(derivedPath, { recursive: true }); - await fs.promises.mkdir(path.join(derivedPath, 'Runner.app'), { recursive: true }); - writeXctestrunFixture(existingXctestrunPath, { - projectRoot: repoRoot, - productRelativePaths: ['Runner.app'], - }); - writeRunnerCacheMetadata(derivedPath, resolveExpectedRunnerCacheMetadata(macOsDevice, repoRoot)); - return { derivedPath, existingXctestrunPath }; -} - -beforeEach(() => { - vi.resetAllMocks(); - appleRunnerTestHost.update({ - runCmdStreaming: mockRunCmdStreaming, - runCmdSync: mockRunCmdSync, - readProcessStartTime: () => 'test-process-start', - }); - mockRunCmdStreaming.mockResolvedValue(undefined); - mockRunCmdSync.mockImplementation((command: string, args: string[]) => { - if (command === 'xcodebuild' && args[0] === '-version') { - return { exitCode: 0, stdout: 'Xcode 26.2\nBuild version 17C52\n', stderr: '' }; - } - if (command === 'xcrun' && args.includes('--show-sdk-version')) { - return { exitCode: 0, stdout: '26.2\n', stderr: '' }; - } - if (command === 'xcrun' && args.includes('--show-sdk-build-version')) { - return { exitCode: 0, stdout: '23C53\n', stderr: '' }; - } - throw new Error(`Unexpected Apple fingerprint command: ${command} ${args.join(' ')}`); - }); - mockRepairMacOsRunnerProductsIfNeeded.mockResolvedValue(undefined); -}); +// These cases key the cache without building it, so they answer the cache's toolchain probes +// from a fixed Xcode instead of the host's. +stubAppleToolchainProbes(); test('resolveRunnerDestination uses simulator destination for simulators', () => { assert.equal(resolveRunnerDestination(iosSimulator), 'platform=iOS Simulator,id=sim-1'); @@ -326,25 +164,6 @@ test('assertSafeDerivedCleanup allows cleaning override path under project .tmp' }); }); -test('xctestrunReferencesProjectRoot rejects stale worktree artifacts', async () => { - const tmpDir = await makeTmpDir(); - const xctestrunPath = path.join(tmpDir, 'AgentDeviceRunner.xctestrun'); - fs.writeFileSync( - xctestrunPath, - 'SourceFilesCommonPathPrefix/tmp/other-worktree/agent-device/apple/runner/AgentDeviceRunner', - 'utf8', - ); - - assert.equal( - xctestrunReferencesProjectRoot(xctestrunPath, '/tmp/current-worktree/agent-device'), - false, - ); - assert.equal( - xctestrunReferencesProjectRoot(xctestrunPath, '/tmp/other-worktree/agent-device'), - true, - ); -}); - test('resolveRunnerDerivedPath keys default cache by runner metadata', () => { withoutRunnerDerivedPathEnv(); const metadata = resolveExpectedRunnerCacheMetadata(iosSimulator, repoRoot); @@ -378,7 +197,7 @@ test('resolveRunnerDerivedPath keys default cache by runner metadata', () => { test('resolveRunnerDerivedPath reuses cache path for identical runner source fingerprints', async () => { withoutRunnerDerivedPathEnv(); - const tmpDir = await makeTmpDir(); + const tmpDir = await makeScratchDir(); const firstRoot = path.join(tmpDir, 'first'); const secondRoot = path.join(tmpDir, 'second'); const runnerRelativePath = path.join( @@ -429,7 +248,7 @@ test('acquireRunnerXctestrunCacheLock serializes cache access across acquirers', onTestFinished(() => { vi.useRealTimers(); }); - const tmpDir = await makeTmpDir(); + const tmpDir = await makeScratchDir(); const derivedPath = path.join(tmpDir, 'derived'); const releaseFirst = await acquireRunnerXctestrunCacheLock(derivedPath); let secondAcquired = false; @@ -445,499 +264,6 @@ test('acquireRunnerXctestrunCacheLock serializes cache access across acquirers', assert.equal(secondAcquired, true); }); -test('ensureXctestrunArtifact reuses matching manifest artifacts from another project root', async () => { - const tmpDir = await makeTmpDir(); - const derivedPath = path.join(tmpDir, 'custom-derived'); - const productPath = path.join(derivedPath, 'Runner.app'); - const xctestrunPath = path.join(derivedPath, 'manifest.xctestrun'); - await fs.promises.mkdir(productPath, { recursive: true }); - writeXctestrunFixture(xctestrunPath, { - projectRoot: '/tmp/other-agent-device-worktree', - productRelativePaths: ['Runner.app'], - }); - writeRunnerCacheMetadataWithArtifacts({ - derivedPath, - device: macOsDevice, - xctestrunPath, - productPaths: [productPath], - }); - withRunnerDerivedPathEnv(derivedPath); - - const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath; - - assert.equal(result, xctestrunPath); - assert.equal(mockRunCmdStreaming.mock.calls.length, 0); - assert.deepEqual(mockRepairMacOsRunnerProductsIfNeeded.mock.calls[0]?.[1], [productPath]); -}); - -test('ensureXctestrunArtifact rebuilds foreign artifacts when metadata does not match', async () => { - const projectRoot = repoRoot; - const tmpDir = await makeProjectTmpDir(); - const derivedPath = path.join(tmpDir, 'custom-derived'); - const productPath = path.join(derivedPath, 'Runner.app'); - const foreignXctestrunPath = path.join(derivedPath, 'foreign.xctestrun'); - const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); - await fs.promises.mkdir(productPath, { recursive: true }); - writeXctestrunFixture(foreignXctestrunPath, { - projectRoot: '/tmp/other-agent-device-worktree', - productRelativePaths: ['Runner.app'], - }); - writeRunnerCacheMetadataWithArtifacts({ - derivedPath, - device: macOsDevice, - xctestrunPath: foreignXctestrunPath, - productPaths: [productPath], - }); - const metadataPath = resolveRunnerCacheMetadataPath(derivedPath); - const staleMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); - staleMetadata.runnerSandboxBuildArgs = staleMetadata.runnerSandboxBuildArgs.map((arg: string) => - arg.startsWith('OTHER_SWIFT_FLAGS=') - ? 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS' - : arg, - ); - fs.writeFileSync(metadataPath, JSON.stringify(staleMetadata, null, 2)); - withRunnerDerivedPathEnv(derivedPath); - - mockRunCmdStreaming.mockImplementation(async () => { - await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true }); - writeXctestrunFixture(rebuiltXctestrunPath, { - projectRoot, - productRelativePaths: ['Runner.app'], - }); - }); - - const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath; - - assert.equal(result, rebuiltXctestrunPath); - assert.equal(mockRunCmdStreaming.mock.calls.length, 1); - assert.equal(fs.existsSync(foreignXctestrunPath), false); -}); - -test('ensureXctestrunArtifact ignores manifest artifacts outside the cache root', async () => { - const projectRoot = repoRoot; - const tmpDir = await makeProjectTmpDir(); - const derivedPath = path.join(tmpDir, 'custom-derived'); - const externalDir = path.join(tmpDir, 'external'); - const externalProductPath = path.join(externalDir, 'Runner.app'); - const externalXctestrunPath = path.join(externalDir, 'external.xctestrun'); - const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); - await fs.promises.mkdir(externalProductPath, { recursive: true }); - writeXctestrunFixture(externalXctestrunPath, { - projectRoot, - productRelativePaths: ['Runner.app'], - }); - await fs.promises.mkdir(derivedPath, { recursive: true }); - writeRunnerCacheMetadataWithArtifacts({ - derivedPath, - device: macOsDevice, - xctestrunPath: externalXctestrunPath, - productPaths: [externalProductPath], - }); - withRunnerDerivedPathEnv(derivedPath); - - mockRunCmdStreaming.mockImplementation(async () => { - await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true }); - writeXctestrunFixture(rebuiltXctestrunPath, { - projectRoot, - productRelativePaths: ['Runner.app'], - }); - }); - - const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath; - - assert.equal(result, rebuiltXctestrunPath); - assert.equal(mockRunCmdStreaming.mock.calls.length, 1); -}); - -test('ensureXctestrunArtifact aborts only the disconnected request build and preserves concurrent unrelated builds', async () => { - // The request AbortSignal must reach the xctestrun build (killProcessTree via - // runCmdStreaming); removing global abort must not orphan a disconnected prep. - // Request-scoped: aborting one request's build leaves an unrelated concurrent - // build (different device -> different derived, different signal) untouched. - withoutRunnerDerivedPathEnv(); - const canceledDevice = iosSimulator; - const survivorDevice = macOsDevice; - for (const device of [canceledDevice, survivorDevice]) { - const derived = resolveRunnerDerivedPath( - device, - resolveExpectedRunnerCacheMetadata(device, repoRoot), - ); - onTestFinished(async () => { - await fs.promises.rm(derived, { recursive: true, force: true }); - }); - } - - const canceledController = new AbortController(); - const survivorController = new AbortController(); - const deferred = (): { promise: Promise; resolve: (value: T) => void } => { - let resolve!: (value: T) => void; - const promise = new Promise((res) => { - resolve = res; - }); - return { promise, resolve }; - }; - const waitForAbort = (signal: AbortSignal): Promise => - signal.aborted - ? Promise.resolve() - : new Promise((resolve) => - signal.addEventListener('abort', () => resolve(), { - once: true, - }), - ); - const canceledBuildStarted = deferred(); - const survivorBuildStarted = deferred(); - const releaseSurvivor = deferred(); - const cancellationError = createRequestCanceledError(); - - mockRunCmdStreaming.mockImplementation(async (_cmd, args, options) => { - const derived = args[args.indexOf('-derivedDataPath') + 1]; - if (options?.signal === canceledController.signal) { - canceledBuildStarted.resolve(); - await waitForAbort(options.signal); - throw cancellationError; - } - survivorBuildStarted.resolve(); - await releaseSurvivor.promise; - await fs.promises.mkdir(path.join(derived, 'rebuilt', 'Runner.app'), { recursive: true }); - writeXctestrunFixture(path.join(derived, 'rebuilt', 'rebuilt.xctestrun'), { - projectRoot: repoRoot, - productRelativePaths: ['Runner.app'], - }); - }); - - const canceledPromise = ensureXctestrunArtifact(canceledDevice, { - budget: createRunnerPhaseBudget(undefined, canceledController.signal), - }); - const survivorPromise = ensureXctestrunArtifact(survivorDevice, { - budget: createRunnerPhaseBudget(undefined, survivorController.signal), - }); - - await Promise.all([canceledBuildStarted.promise, survivorBuildStarted.promise]); - - canceledController.abort(); - await assert.rejects(canceledPromise, (error: unknown) => { - assert.equal(error, cancellationError); - assert.ok(isRequestCanceledError(error)); - return true; - }); - // The unrelated concurrent build's signal was never aborted. - assert.equal(survivorController.signal.aborted, false); - - releaseSurvivor.resolve(); - const survivorResult = await survivorPromise; - assert.ok(survivorResult.xctestrunPath.endsWith('rebuilt.xctestrun')); - - const canceledCall = mockRunCmdStreaming.mock.calls.find( - (call) => call[2]?.signal === canceledController.signal, - ); - const survivorCall = mockRunCmdStreaming.mock.calls.find( - (call) => call[2]?.signal === survivorController.signal, - ); - assert.ok(canceledCall, 'canceled build received its request signal'); - assert.ok(survivorCall, 'survivor build received its request signal'); -}); - -test('ensureXctestrunArtifact rebuilds after cached macOS runner repair failure', async () => { - // Cached runner artifacts can look reusable until ad-hoc repair fails; ensure we clean once, - // rebuild, and return the repaired rebuilt xctestrun instead of looping on stale cache state. - const projectRoot = repoRoot; - const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun(); - const projectPath = path.join( - projectRoot, - 'apple', - 'runner', - 'AgentDeviceRunner', - 'AgentDeviceRunner.xcodeproj', - ); - - const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); - - withRunnerDerivedPathEnv(derivedPath); - - const repairedPaths: string[] = []; - - mockRepairMacOsRunnerProductsIfNeeded.mockImplementation( - async (_device, _productPaths, xctestrunPath) => { - repairedPaths.push(xctestrunPath); - if (xctestrunPath === existingXctestrunPath) { - throw new AppError('COMMAND_FAILED', 'cached runner is damaged', { - reason: 'RUNNER_PRODUCT_REPAIR_FAILED', - }); - } - }, - ); - mockRunCmdStreaming.mockImplementation(async (command, args) => { - assert.equal(command, 'xcodebuild'); - assert.ok(Array.isArray(args)); - assert.equal(args[args.indexOf('-project') + 1], projectPath); - assert.equal(args[args.indexOf('-derivedDataPath') + 1], derivedPath); - await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true }); - writeXctestrunFixture(rebuiltXctestrunPath, { - projectRoot, - productRelativePaths: ['Runner.app'], - }); - }); - - const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath; - - assert.equal(result, rebuiltXctestrunPath); - assert.equal(mockRunCmdStreaming.mock.calls.length, 1); - assert.equal(fs.existsSync(existingXctestrunPath), false); - assert.deepEqual(repairedPaths, [existingXctestrunPath, rebuiltXctestrunPath]); -}); - -test('ensureXctestrunArtifact prefers validated cache manifest over recursive scan', async () => { - const tmpDir = await makeTmpDir(); - const derivedPath = path.join(tmpDir, 'custom-derived'); - const manifestProductPath = path.join(derivedPath, 'ManifestRunner.app'); - const manifestXctestrunPath = path.join(derivedPath, 'manifest.xctestrun'); - const newerProductPath = path.join(derivedPath, 'NewerRunner.app'); - const newerXctestrunPath = path.join(derivedPath, 'newer.xctestrun'); - await fs.promises.mkdir(manifestProductPath, { recursive: true }); - await fs.promises.mkdir(newerProductPath, { recursive: true }); - writeXctestrunFixture(manifestXctestrunPath, { - projectRoot: repoRoot, - productRelativePaths: ['ManifestRunner.app'], - }); - writeXctestrunFixture(newerXctestrunPath, { - projectRoot: repoRoot, - productRelativePaths: ['NewerRunner.app'], - }); - const now = new Date(); - fs.utimesSync(manifestXctestrunPath, now, now); - fs.utimesSync( - newerXctestrunPath, - new Date(now.getTime() + 5_000), - new Date(now.getTime() + 5_000), - ); - writeRunnerCacheMetadataWithArtifacts({ - derivedPath, - device: macOsDevice, - xctestrunPath: manifestXctestrunPath, - productPaths: [manifestProductPath], - }); - withRunnerDerivedPathEnv(derivedPath); - - const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath; - - assert.equal(result, manifestXctestrunPath); - assert.equal(mockRunCmdStreaming.mock.calls.length, 0); - assert.deepEqual(mockRepairMacOsRunnerProductsIfNeeded.mock.calls[0]?.[1], [manifestProductPath]); -}); - -test('ensureXctestrunArtifact falls back to scan when cache manifest is stale', async () => { - const tmpDir = await makeTmpDir(); - const derivedPath = path.join(tmpDir, 'custom-derived'); - const manifestProductPath = path.join(derivedPath, 'ManifestRunner.app'); - const manifestXctestrunPath = path.join(derivedPath, 'manifest.xctestrun'); - const newerProductPath = path.join(derivedPath, 'NewerRunner.app'); - const newerXctestrunPath = path.join(derivedPath, 'newer.xctestrun'); - await fs.promises.mkdir(manifestProductPath, { recursive: true }); - await fs.promises.mkdir(newerProductPath, { recursive: true }); - writeXctestrunFixture(manifestXctestrunPath, { - projectRoot: repoRoot, - productRelativePaths: ['ManifestRunner.app'], - }); - writeXctestrunFixture(newerXctestrunPath, { - projectRoot: repoRoot, - productRelativePaths: ['NewerRunner.app'], - }); - const now = new Date(); - fs.utimesSync(manifestXctestrunPath, now, now); - fs.utimesSync( - newerXctestrunPath, - new Date(now.getTime() + 5_000), - new Date(now.getTime() + 5_000), - ); - writeRunnerCacheMetadataWithArtifacts({ - derivedPath, - device: macOsDevice, - xctestrunPath: manifestXctestrunPath, - productPaths: [manifestProductPath], - }); - fs.utimesSync( - manifestProductPath, - new Date(now.getTime() + 10_000), - new Date(now.getTime() + 10_000), - ); - withRunnerDerivedPathEnv(derivedPath); - - const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath; - - assert.equal(result, newerXctestrunPath); - assert.equal(mockRunCmdStreaming.mock.calls.length, 0); - assert.deepEqual(mockRepairMacOsRunnerProductsIfNeeded.mock.calls[0]?.[1], [newerProductPath]); -}); - -test('ensureXctestrunArtifact rebuilds cached runner when Swift build flags mismatch', async () => { - const projectRoot = repoRoot; - const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun(); - const metadataPath = resolveRunnerCacheMetadataPath(derivedPath); - const expectedMetadata = resolveExpectedRunnerCacheMetadata(macOsDevice, repoRoot); - const staleMetadata = { - ...expectedMetadata, - runnerSandboxBuildArgs: expectedMetadata.runnerSandboxBuildArgs.map((arg) => - arg.startsWith('OTHER_SWIFT_FLAGS=') - ? 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS' - : arg, - ), - }; - fs.writeFileSync(metadataPath, JSON.stringify(staleMetadata, null, 2)); - - const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); - - withRunnerDerivedPathEnv(derivedPath); - - mockRunCmdStreaming.mockImplementation(async () => { - await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true }); - writeXctestrunFixture(rebuiltXctestrunPath, { - projectRoot, - productRelativePaths: ['Runner.app'], - }); - }); - - const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath; - - assert.equal(result, rebuiltXctestrunPath); - assert.equal(mockRunCmdStreaming.mock.calls.length, 1); - assert.equal(fs.existsSync(existingXctestrunPath), false); - const rebuiltMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); - assert.deepEqual( - stripRunnerCacheArtifacts(rebuiltMetadata), - resolveExpectedRunnerCacheMetadata(macOsDevice, repoRoot), - ); - assert.equal(rebuiltMetadata.artifacts?.xctestrunPath, rebuiltXctestrunPath); -}); - -test('ensureXctestrunArtifact passes sandbox-disabling settings to xcodebuild', async () => { - const projectRoot = repoRoot; - const tmpDir = await makeProjectTmpDir(); - const derivedPath = path.join(tmpDir, 'custom-derived'); - const rebuiltXctestrunPath = path.join(derivedPath, 'Build', 'Products', 'rebuilt.xctestrun'); - - withRunnerDerivedPathEnv(derivedPath); - - mockRunCmdStreaming.mockImplementationOnce(async () => { - await fs.promises.mkdir(path.join(derivedPath, 'Build', 'Products', 'Runner.app'), { - recursive: true, - }); - writeXctestrunFixture(rebuiltXctestrunPath, { - projectRoot, - productRelativePaths: ['Runner.app'], - }); - }); - - const result = await ensureXctestrunArtifact(iosSimulator, { - forceRunnerXctestrunRebuild: true, - }); - - assert.equal(result.xctestrunPath, rebuiltXctestrunPath); - assert.equal(mockRunCmdStreaming.mock.calls.length, 1); - const args = mockRunCmdStreaming.mock.calls[0]?.[1] ?? []; - assert.equal(args.includes('-IDEPackageSupportDisableManifestSandbox=1'), true); - assert.equal(args.includes('-IDEPackageSupportDisablePluginExecutionSandbox=1'), true); - assert.equal(args.includes('ENABLE_USER_SCRIPT_SANDBOXING=NO'), true); - assert.equal(args.includes('OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox'), true); -}); - -test('ensureXctestrunArtifact emits build progress on cache miss', async () => { - const projectRoot = repoRoot; - const tmpDir = await makeProjectTmpDir(); - const derivedPath = path.join(tmpDir, 'custom-derived'); - const rebuiltXctestrunPath = path.join(derivedPath, 'Build', 'Products', 'rebuilt.xctestrun'); - const events: RequestProgressEvent[] = []; - - withRunnerDerivedPathEnv(derivedPath); - - mockRunCmdStreaming.mockImplementationOnce(async () => { - await fs.promises.mkdir(path.join(derivedPath, 'Build', 'Products', 'Runner.app'), { - recursive: true, - }); - writeXctestrunFixture(rebuiltXctestrunPath, { - projectRoot, - productRelativePaths: ['Runner.app'], - }); - }); - - appleRunnerTestHost.update({ emitRequestProgress: (event) => events.push(event) }); - - const result = await ensureXctestrunArtifact(iosSimulator, { - forceRunnerXctestrunRebuild: true, - }); - - assert.equal(result.xctestrunPath, rebuiltXctestrunPath); - assert.deepEqual(events, [ - { - type: 'command', - status: 'progress', - message: 'Building Apple runner...', - }, - ]); -}); - -test('ensureXctestrunArtifact stress-recovers after a bad restored artifact', async () => { - const projectRoot = repoRoot; - const tmpDir = await makeProjectTmpDir(); - const derivedPath = path.join(tmpDir, 'custom-derived'); - const productPath = path.join(derivedPath, 'Runner.app'); - const cachedXctestrunPath = path.join(derivedPath, 'cached.xctestrun'); - await fs.promises.mkdir(productPath, { recursive: true }); - writeXctestrunFixture(cachedXctestrunPath, { - projectRoot, - productRelativePaths: ['Runner.app'], - }); - writeRunnerCacheMetadataWithArtifacts({ - derivedPath, - device: macOsDevice, - xctestrunPath: cachedXctestrunPath, - productPaths: [productPath], - }); - withRunnerDerivedPathEnv(derivedPath); - - const hit = await ensureXctestrunArtifact(macOsDevice, {}); - - assert.equal(hit.xctestrunPath, cachedXctestrunPath); - assert.equal(hit.cache, 'exact'); - assert.equal(hit.artifact, 'valid'); - assert.equal(hit.buildMs, 0); - assert.equal(mockRunCmdStreaming.mock.calls.length, 0); - - await markRunnerXctestrunArtifactBadForRun(hit, 'stress health failed'); - assert.equal(fs.existsSync(cachedXctestrunPath), false); - - const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); - mockRunCmdStreaming.mockImplementationOnce(async () => { - await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true }); - writeXctestrunFixture(rebuiltXctestrunPath, { - projectRoot, - productRelativePaths: ['Runner.app'], - }); - }); - - const rebuilt = await ensureXctestrunArtifact(macOsDevice, { - budget: createRunnerPhaseBudget(300_000, undefined), - }); - - assert.equal(rebuilt.xctestrunPath, rebuiltXctestrunPath); - assert.equal(rebuilt.cache, 'miss'); - assert.equal(rebuilt.artifact, 'rebuilt'); - assert.equal(rebuilt.reason, 'missing_xctestrun'); - assert.equal(mockRunCmdStreaming.mock.calls.length, 1); - assert.equal(Math.ceil(Number(mockRunCmdStreaming.mock.calls[0]?.[2]?.timeoutMs) / 1e3), 300); // phase remainder (#2422) -}); - -test('ensureXctestrunArtifact rethrows unexpected cached macOS runner repair errors', async () => { - const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun(); - - withRunnerDerivedPathEnv(derivedPath); - - mockRepairMacOsRunnerProductsIfNeeded.mockRejectedValue(new Error('permission denied')); - - await assert.rejects(ensureXctestrunArtifact(macOsDevice, {}), /permission denied/); - assert.equal(mockRunCmdStreaming.mock.calls.length, 0); - assert.equal(fs.existsSync(existingXctestrunPath), true); -}); - test('shouldDeleteRunnerDerivedRootEntry only removes known xcode transient entries', () => { assert.equal(shouldDeleteRunnerDerivedRootEntry('Build'), true); assert.equal(shouldDeleteRunnerDerivedRootEntry('Logs'), true); diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts index e1eb003d7e..140d1eff22 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts @@ -87,7 +87,7 @@ test('prepareIosRunner marks a bad restored artifact and rebuilds once after hea test('prepareIosRunner invalidates rebuilt sessions when bad-cache recovery health fails', async () => { const restoredArtifact = makeRunnerArtifact({ xctestrunPath: '/tmp/restored.xctestrun', - cache: 'restore-key', + cache: 'exact', artifact: 'valid', }); const rebuiltArtifact = makeRunnerArtifact({ diff --git a/packages/platform-apple/src/runner/__tests__/runner-source.test.ts b/packages/platform-apple/src/runner/__tests__/runner-source.test.ts index 116a51748e..a9222f265c 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-source.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-source.test.ts @@ -111,6 +111,69 @@ test('computeRunnerSourceFingerprint ignores development-only SwiftPM trees but assert.notEqual(computeRunnerSourceFingerprint(root), afterIgnoredChanges); }); +test('computeRunnerSourceFingerprint covers Xcode project, scheme, and workspace files', () => { + const root = makeTempRoot(); + const projectRoot = path.join(root, 'apple', 'runner', 'AgentDeviceRunner'); + const projectPackage = path.join(projectRoot, 'AgentDeviceRunner.xcodeproj'); + const scheme = path.join( + projectPackage, + 'xcshareddata', + 'xcschemes', + 'AgentDeviceRunner.xcscheme', + ); + const workspaceData = path.join( + projectPackage, + 'project.xcworkspace', + 'contents.xcworkspacedata', + ); + const pbxproj = path.join(projectPackage, 'project.pbxproj'); + fs.mkdirSync(path.dirname(scheme), { recursive: true }); + fs.mkdirSync(path.dirname(workspaceData), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'Runner.swift'), 'runner\n'); + fs.writeFileSync(pbxproj, '// Begin project\n'); + fs.writeFileSync(scheme, '\n'); + fs.writeFileSync(workspaceData, '\n'); + + const before = computeRunnerSourceFingerprint(root); + + fs.writeFileSync(pbxproj, '// Begin project\n// membership changed\n'); + const afterPbxproj = computeRunnerSourceFingerprint(root); + assert.notEqual(afterPbxproj, before); + + fs.writeFileSync(scheme, `\n`); + const afterScheme = computeRunnerSourceFingerprint(root); + assert.notEqual(afterScheme, afterPbxproj); + + fs.writeFileSync(workspaceData, `\n`); + assert.notEqual(computeRunnerSourceFingerprint(root), afterScheme); +}); + +test('computeRunnerSourceFingerprint ignores per-user Xcode state', () => { + const root = makeTempRoot(); + const projectPackage = path.join( + root, + 'apple', + 'runner', + 'AgentDeviceRunner', + 'AgentDeviceRunner.xcodeproj', + ); + const userScheme = path.join( + projectPackage, + 'xcuserdata', + 'someone.xcuserdatad', + 'xcschemes', + 'AgentDeviceRunner.xcscheme', + ); + fs.mkdirSync(path.dirname(userScheme), { recursive: true }); + fs.writeFileSync(path.join(projectPackage, 'project.pbxproj'), '// project\n'); + fs.writeFileSync(userScheme, '\n'); + + const before = computeRunnerSourceFingerprint(root); + fs.writeFileSync(userScheme, `\n`); + + assert.equal(computeRunnerSourceFingerprint(root), before); +}); + const IGNORED_SOURCE_DIRECTORY_NAMES = [ 'Tests', 'SnapshotPresentationConformance', diff --git a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts new file mode 100644 index 0000000000..de4b9afa40 --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts @@ -0,0 +1,152 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { onTestFinished } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { mkdtempForTest } from './tmp-dir.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { writeRunnerCacheMetadataForArtifacts } from '../runner-cache.ts'; +import { resolveExpectedRunnerCacheMetadata } from '../runner-xctestrun.ts'; + +// Scratch trees and certified runner products shared by the tests that exercise +// cache reuse: a case needs an `.xctestrun` naming a product bundle whose bytes +// a manifest certifies, which is more than `writeXctestrunFixture` alone gives. + +export const REPO_ROOT_FOR_TEST = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../../..', +); + +/** A scratch directory removed when the calling test finishes. */ +export async function makeScratchDir(): Promise { + const tmpDir = await mkdtempForTest('agent-device-xctestrun-'); + onTestFinished(async () => { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); + return tmpDir; +} + +/** + * A scratch directory under the repository's own `.tmp`, for cases whose build must resolve + * the runner project — `ensureXctestrunArtifact` refuses to start one without it. + */ +export async function makeProjectScratchDir(): Promise { + const tmpRoot = path.join(REPO_ROOT_FOR_TEST, '.tmp'); + await fs.promises.mkdir(tmpRoot, { recursive: true }); + const tmpDir = await fs.promises.mkdtemp(path.join(tmpRoot, 'agent-device-xctestrun-')); + onTestFinished(async () => { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); + return tmpDir; +} + +export function writeXctestrunFixture( + xctestrunPath: string, + options: { projectRoot: string; productRelativePaths: string[] }, +): void { + const entries = options.productRelativePaths + .map((relativePath) => ` __TESTROOT__/${relativePath}`) + .join('\n'); + fs.mkdirSync(path.dirname(xctestrunPath), { recursive: true }); + fs.writeFileSync( + xctestrunPath, + ` + + + + ProjectRootHint + ${options.projectRoot} + ProductPaths + +${entries} + + +`, + 'utf8', + ); +} + +export function withRunnerDerivedPathEnv(derivedPath: string): void { + const previousDerivedPath = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; + process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = derivedPath; + onTestFinished(() => { + restoreEnvVar('AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH', previousDerivedPath); + }); +} + +export function withoutRunnerDerivedPathEnv(): void { + const previousDerivedPath = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; + delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; + onTestFinished(() => { + restoreEnvVar('AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH', previousDerivedPath); + }); +} + +export function restoreEnvVar(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + +export function stripRunnerCacheArtifacts( + metadata: Record, +): Record { + const { artifacts: _artifacts, ...rest } = metadata; + return rest; +} + +/** + * A content manifest certifies bytes, so a fixture product needs some. Mirrors what a build + * leaves behind: a bundle directory holding an executable. + */ +export async function seedRunnerProductBundle(bundlePath: string): Promise { + await fs.promises.mkdir(bundlePath, { recursive: true }); + await fs.promises.writeFile( + path.join(bundlePath, path.basename(bundlePath, '.app')), + Buffer.from('runner-executable\n'), + { mode: 0o755 }, + ); +} + +/** Publishes the metadata the production writer would, so a fixture tree is really certified. */ +export function writeRunnerCacheMetadataWithArtifacts(params: { + derivedPath: string; + device: DeviceInfo; + xctestrunPath: string; + productPaths: string[]; +}): void { + writeRunnerCacheMetadataForArtifacts( + params.derivedPath, + resolveExpectedRunnerCacheMetadata(params.device, REPO_ROOT_FOR_TEST), + params.xctestrunPath, + params.productPaths, + ); +} + +/** + * A macOS runner product tree that already carries a valid manifest, the shape a restored + * cache arrives in. Under the repository's `.tmp` so the build it can fall back to resolves + * the runner project. + */ +export async function makeCachedRunnerXctestrun(device: DeviceInfo): Promise<{ + derivedPath: string; + existingXctestrunPath: string; +}> { + const tmpDir = await makeProjectScratchDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const existingXctestrunPath = path.join(derivedPath, 'existing.xctestrun'); + await fs.promises.mkdir(derivedPath, { recursive: true }); + await seedRunnerProductBundle(path.join(derivedPath, 'Runner.app')); + writeXctestrunFixture(existingXctestrunPath, { + projectRoot: REPO_ROOT_FOR_TEST, + productRelativePaths: ['Runner.app'], + }); + writeRunnerCacheMetadataWithArtifacts({ + derivedPath, + device, + xctestrunPath: existingXctestrunPath, + productPaths: [path.join(derivedPath, 'Runner.app')], + }); + return { derivedPath, existingXctestrunPath }; +} diff --git a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts index be8a4d2ba6..cdc50f898f 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts @@ -1,10 +1,11 @@ import { test, vi, beforeEach } from 'vitest'; import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; // oxlint-disable-next-line no-restricted-imports -- mirrors production's os.tmpdir xctestrun path import os from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; import { mkdtempForTestSync } from './tmp-dir.ts'; import { buildRunnerSessionXctestrunPathCleanupPattern, @@ -13,9 +14,8 @@ import { import { appleRunnerTestHost } from '../test-host.ts'; import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command'; -// This script runs outside the package's host abstraction (it is invoked directly via -// dynamic import in the "setup metadata script" test below) and calls node:child_process -// execFileSync itself, so it cannot be faked through a host override; the module mock stays. +// The toolchain probes reach the exec layer through `execFileSync`, which the package's host +// seam does not wrap; the module mock keeps a test from shelling out to a real Xcode. const { mockExecFileSync } = vi.hoisted(() => ({ mockExecFileSync: vi.fn(), })); @@ -29,6 +29,8 @@ const mockRunCmdSync = vi.fn(); import type { DeviceInfo } from '@agent-device/kernel/device'; import { findXctestrun, scoreXctestrunCandidate } from '../runner-artifact.ts'; +import { evaluateExistingXctestrun } from '../runner-cache.ts'; +import type { RunnerXctestrunCacheArtifacts } from '../runner-cache-metadata.ts'; import { ensureXctestrunArtifact, markRunnerXctestrunArtifactBadForRun, @@ -37,6 +39,8 @@ import { resolveRunnerDerivedPath, } from '../runner-xctestrun.ts'; +const repoRoot = process.cwd(); + const iosSimulator: DeviceInfo = { platform: 'apple', id: 'sim-1', @@ -205,73 +209,284 @@ test('scoreXctestrunCandidate penalizes macos and env xctestrun files for simula assert.ok(simulatorScore > macosEnvScore); }); -test('setup metadata script matches expected iOS simulator cache metadata', async () => { +/** + * The build script and the metadata writer are separate processes with one contract: the recipe + * `xcuitest-build-settings.ts` hands `xcodebuild` is the recipe the published identity records, + * and the manifest certifies the bytes that build left on disk. Both scripts are driven the way + * `build-xcuitest-apple.sh` drives them — real processes, a stand-in toolchain on PATH, and a + * build log echoing the settings it was handed — because the scripts import the composition + * root, which cannot share a process with this suite's test host. + */ +test('the build script and the metadata writer publish one iOS simulator identity', async () => { await withTempDir('runner-cache-metadata-', async (root) => { - const repoRoot = process.cwd(); - const scriptPath = path.join(repoRoot, 'scripts', 'write-xcuitest-cache-metadata.mjs'); - const projectRoot = path.join(root, 'project'); - const derivedRoot = path.join(root, 'derived'); - fs.mkdirSync(derivedRoot, { recursive: true }); - fs.mkdirSync(path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner'), { - recursive: true, - }); - fs.writeFileSync(path.join(projectRoot, 'package.json'), '{"version":"0.19.0"}\n'); - fs.writeFileSync( - path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner', 'Runner.swift'), - 'final class Runner {}\n', + const project = seedRunnerBuildFixture(root); + const buildSettings = runBuildSettings(project); + fs.writeFileSync(project.buildLogPath, xcodebuildLogWithBuildSettings(buildSettings)); + + const written = runScript(project.root, 'write-xcuitest-cache-metadata.ts', [ + 'ios', + project.derivedPath, + project.destination, + project.buildLogPath, + project.bin, + ]); + assert.equal(written.status, 0, written.stderr); + + const published = readRunnerCacheManifest(project.derivedPath); + const { artifacts: _artifacts, ...publishedIdentity } = published; + assert.deepEqual( + stripVolatile(publishedIdentity), + stripVolatile(resolveExpectedRunnerCacheMetadata(iosSimulator, project.root)), ); - const runnerUnitTest = path.join( - projectRoot, - 'apple', - 'runner', - 'AgentDeviceRunner', - 'AgentDeviceRunnerUITests', - 'UnitTests', - 'Invariant.swift', + + assert.ok(artifactsOf(published).xctestrunDigest); + const entries = new Map( + artifactsOf(published).entries.map((entry: any) => [entry.path, entry]), ); - fs.mkdirSync(path.dirname(runnerUnitTest), { recursive: true }); - fs.writeFileSync(runnerUnitTest, 'unit-one\n'); - const ignoredSharedSource = path.join( - projectRoot, - 'apple', - 'snapshot-presentation', - 'Tests', - 'Ignored.swift', + const executableEntry = entries.get( + path.relative(project.derivedPath, project.executablePath).replaceAll(path.sep, '/'), ); - fs.mkdirSync(path.dirname(ignoredSharedSource), { recursive: true }); - fs.writeFileSync(ignoredSharedSource, 'ignored-one\n'); - const { writeXcuitestCacheMetadata } = await import( - `${pathToFileURL(scriptPath).href}?case=${Date.now()}` + assert.ok(executableEntry, 'the manifest must list the runner executable'); + assert.equal(executableEntry!.mode, 0o755); + assert.equal( + executableEntry!.digest, + crypto.createHash('sha256').update(fs.readFileSync(project.executablePath)).digest('hex'), ); - const firstMetadata = writeXcuitestCacheMetadata( - ['ios', derivedRoot, 'generic/platform=iOS Simulator'], - projectRoot, + + // What the daemon will do with a restored tree: the manifest it just read must certify it. + const state = await evaluateExistingXctestrun({ + derived: project.derivedPath, + expectedCacheMetadata: resolveExpectedRunnerCacheMetadata(iosSimulator, project.root), + }); + assert.equal(state.reason, 'reuse_ready'); + + // The same files must keep reporting one fingerprint while only ignored sources change. + fs.writeFileSync(project.ignoredSharedSource, 'ignored-two\n'); + assert.equal( + resolveExpectedRunnerCacheMetadata(iosSimulator, project.root).runnerSourceFingerprint, + published.runnerSourceFingerprint, + ); + fs.writeFileSync(project.runnerUnitTest, 'unit-two\n'); + assert.notEqual( + resolveExpectedRunnerCacheMetadata(iosSimulator, project.root).runnerSourceFingerprint, + published.runnerSourceFingerprint, ); + }); +}, 120_000); - const actual = JSON.parse( - fs.readFileSync(path.join(derivedRoot, '.agent-device-runner-cache.json'), 'utf8'), +test('the metadata writer refuses a build log whose recipe it did not record', async () => { + await withTempDir('runner-cache-metadata-', async (root) => { + const project = seedRunnerBuildFixture(root); + const drifted = runBuildSettings(project).filter( + (setting) => !setting.startsWith('ONLY_ACTIVE_ARCH='), + ); + fs.writeFileSync(project.buildLogPath, xcodebuildLogWithBuildSettings(drifted)); + + const written = runScript(project.root, 'write-xcuitest-cache-metadata.ts', [ + 'ios', + project.derivedPath, + project.destination, + project.buildLogPath, + project.bin, + ]); + assert.notEqual(written.status, 0); + assert.match(written.stderr, /did not use the settings its cache identity records/); + assert.equal( + fs.existsSync(path.join(project.derivedPath, '.agent-device-runner-cache.json')), + false, ); - const { artifacts: _actualArtifacts, ...actualComparable } = actual; - const { artifacts: _expectedArtifacts, ...expectedComparable } = - resolveExpectedRunnerCacheMetadata(iosSimulator, projectRoot); + }); +}, 120_000); + +type RunnerBuildFixture = { + root: string; + bin: string; + derivedPath: string; + buildLogPath: string; + destination: string; + executablePath: string; + runnerUnitTest: string; + ignoredSharedSource: string; +}; + +/** + * A project root plus the DerivedData a successful `build-for-testing` would leave behind: + * an `.xctestrun` naming a product bundle whose executable is on disk. + */ +function seedRunnerBuildFixture(root: string): RunnerBuildFixture { + const projectRoot = path.join(root, 'project'); + const derivedPath = path.join(root, 'derived'); + const productsRoot = path.join(derivedPath, 'Build', 'Products'); + const runnerAppPath = path.join(productsRoot, 'Debug-iphonesimulator', 'Runner-Runner.app'); + const executablePath = path.join(runnerAppPath, 'Runner'); + const xctestrunPath = path.join( + productsRoot, + 'AgentDeviceRunner_AgentDeviceRunnerUITests_iphonesimulator26.2-arm64.xctestrun', + ); + fs.mkdirSync(runnerAppPath, { recursive: true }); + fs.writeFileSync(executablePath, Buffer.from('runner-executable\n'), { mode: 0o755 }); + fs.mkdirSync(path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'package.json'), '{"version":"0.19.0"}\n'); + fs.writeFileSync( + path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner', 'Runner.swift'), + 'final class Runner {}\n', + ); + const runnerUnitTest = path.join( + projectRoot, + 'apple', + 'runner', + 'AgentDeviceRunner', + 'AgentDeviceRunnerUITests', + 'UnitTests', + 'Invariant.swift', + ); + fs.mkdirSync(path.dirname(runnerUnitTest), { recursive: true }); + fs.writeFileSync(runnerUnitTest, 'unit-one\n'); + const ignoredSharedSource = path.join( + projectRoot, + 'apple', + 'snapshot-presentation', + 'Tests', + 'Ignored.swift', + ); + fs.mkdirSync(path.dirname(ignoredSharedSource), { recursive: true }); + fs.writeFileSync(ignoredSharedSource, 'ignored-one\n'); + fs.writeFileSync( + xctestrunPath, + ` + + + + ProjectRootHint + ${projectRoot} + ProductPaths + + __TESTROOT__/Debug-iphonesimulator/Runner-Runner.app + + +`, + ); + const buildLogPath = path.join(derivedPath, 'Logs', 'agent-device-build-for-testing.log'); + fs.mkdirSync(path.dirname(buildLogPath), { recursive: true }); + return { + root: projectRoot, + bin: fakeAppleToolchainBin(root), + derivedPath, + buildLogPath, + destination: 'generic/platform=iOS Simulator', + executablePath, + runnerUnitTest, + ignoredSharedSource, + }; +} - assert.deepEqual(actualComparable, expectedComparable); +/** + * The three tools the scripts exec: `xcodebuild -version` and two `xcrun --sdk` probes, answering + * exactly what this suite's fingerprint stub reports so the child and parent identities agree. + */ +function fakeAppleToolchainBin(root: string): string { + const bin = path.join(root, 'bin'); + fs.mkdirSync(bin, { recursive: true }); + writeFakeTool( + bin, + 'xcodebuild', + String.raw`{ printf 'Xcode 26.2 +'; printf 'Build version 17C52 +'; }`, + ); + writeFakeTool( + bin, + 'xcrun', + String.raw`{ for arg in "$@"; do + if [ "$arg" = "--show-sdk-build-version" ]; then printf "23C53 +"; exit 0; fi +done +printf "26.2 +"; }`, + ); + return bin; +} - fs.writeFileSync(ignoredSharedSource, 'ignored-two\n'); - const secondMetadata = writeXcuitestCacheMetadata( - ['ios', derivedRoot, 'generic/platform=iOS Simulator'], - projectRoot, - ); - assert.equal(secondMetadata.runnerSourceFingerprint, firstMetadata.runnerSourceFingerprint); +function writeFakeTool(bin: string, name: string, body: string): void { + fs.writeFileSync(path.join(bin, name), `#!/bin/sh\n${body}\n`); + fs.chmodSync(path.join(bin, name), 0o755); +} - fs.writeFileSync(runnerUnitTest, 'unit-two\n'); - const thirdMetadata = writeXcuitestCacheMetadata( - ['ios', derivedRoot, 'generic/platform=iOS Simulator'], - projectRoot, - ); - assert.notEqual(thirdMetadata.runnerSourceFingerprint, secondMetadata.runnerSourceFingerprint); +/** The recipe the emitter hands `xcodebuild`, read the way the build script reads it. */ +function runBuildSettings(project: RunnerBuildFixture): string[] { + const emitted = runScript(project.root, 'xcuitest-build-settings.ts', [ + 'ios', + project.destination, + project.bin, + ]); + assert.equal(emitted.status, 0, emitted.stderr); + return emitted.stdout.split('\n').filter((line: string) => line !== ''); +} + +/** Runs one repository script in a real process, the way the build script does. */ +function runScript( + cwd: string, + script: string, + args: readonly string[], +): { + status: number; + stdout: string; + stderr: string; +} { + const fakeBin = args.at(-1)!; + const rest = args.slice(0, -1); + const result = spawnSync( + process.execPath, + ['--experimental-strip-types', path.join(repoRoot, 'scripts', script), ...rest], + { + cwd, + encoding: 'utf8', + env: { ...process.env, PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}` }, + }, + ); + return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr }; +} + +/** + * `xcodebuild`'s own echo of the recipe it was handed: build settings under their own header, + * and the `-I` flags it does not treat as settings on the invocation line. + */ +function xcodebuildLogWithBuildSettings(settings: readonly string[]): string { + const isSetting = (setting: string) => /^[A-Z][A-Z0-9_]*=/.test(setting); + const buildSettings = settings.filter(isSetting).map((setting) => { + const index = setting.indexOf('='); + return ` ${setting.slice(0, index)} = ${setting.slice(index + 1)}`; }); -}, 15_000); + const flags = settings.filter((setting) => !isSetting(setting)); + return [ + 'Command line invocation:', + ` /usr/bin/xcodebuild build-for-testing ${flags.join(' ')}`.trimEnd(), + '', + 'Build settings from command line:', + ...buildSettings, + '', + 'Resolve Package Graph', + '', + ].join('\n'); +} + +function readRunnerCacheManifest(derivedPath: string): any { + return JSON.parse( + fs.readFileSync(path.join(derivedPath, '.agent-device-runner-cache.json'), 'utf8'), + ); +} + +function artifactsOf(manifest: any): RunnerXctestrunCacheArtifacts { + if (!manifest.artifacts) { + throw new Error('The writer must publish an artifact manifest alongside the identity.'); + } + return manifest.artifacts as RunnerXctestrunCacheArtifacts; +} + +function stripVolatile(metadata: Record): Record { + const { packageVersion: _packageVersion, ...rest } = metadata; + return rest; +} test('runner cache key ignores package version but honors toolchain and SDK changes', () => { const metadata = resolveExpectedRunnerCacheMetadata(iosSimulator); diff --git a/packages/platform-apple/src/runner/apple-runner-platform.ts b/packages/platform-apple/src/runner/apple-runner-platform.ts index 5bf365141f..bc259656d9 100644 --- a/packages/platform-apple/src/runner/apple-runner-platform.ts +++ b/packages/platform-apple/src/runner/apple-runner-platform.ts @@ -127,6 +127,51 @@ const RUNNER_PLATFORM_PROFILES: Record +> = { + ios: 'mobile', + macos: 'desktop', + tvos: 'tv', + visionos: 'mobile', +}; + +const RUNNER_SCRIPT_APPLE_OS: Record< + RunnerXcuitestScriptPlatform, + NonNullable +> = { + ios: 'ios', + macos: 'macos', + tvos: 'tvos', + visionos: 'visionos', +}; + +/** + * The device a build-script invocation stands in for: the script names its platform as a + * literal and a destination string, and the cache metadata owner speaks `DeviceInfo`. + * Resolving identity through this one mapping keeps a script-written manifest comparable to + * the metadata a daemon resolves for the same build. + */ +export function resolveRunnerScriptDevice( + platform: RunnerXcuitestScriptPlatform, + destination: string, +): DeviceInfo { + const kind: DeviceInfo['kind'] = + platform === 'macos' || !destination.includes('Simulator') ? 'device' : 'simulator'; + return { + platform: 'apple', + id: `runner-script-${platform}`, + name: `Apple runner build script (${platform})`, + kind, + target: RUNNER_SCRIPT_TARGET[platform], + appleOs: RUNNER_SCRIPT_APPLE_OS[platform], + }; +} + export function resolveRunnerPlatformName(device: DeviceInfo): RunnerApplePlatformName { if (!isApplePlatform(device.platform)) { throw new AppError( diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index 9b724b57c8..edb546bb1c 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -30,6 +30,7 @@ import { evaluateExistingXctestrun, requireRunnerPhaseRemainingMs, resolveExpectedRunnerCacheMetadata, + resolveRunnerArchBuildSettings, resolveRunnerBundleBuildSettings, resolveRunnerDerivedPath, resolveRunnerMaxConcurrentDestinationsFlag, @@ -66,7 +67,7 @@ export type RunnerXctestrunArtifact = { cache: RunnerXctestrunCacheKind; artifact: RunnerXctestrunArtifactState; buildMs: number; - xctestrunPathSource: 'manifest' | 'scan' | 'build' | 'external'; + xctestrunPathSource: 'manifest' | 'build' | 'external'; reason?: string; }; @@ -174,17 +175,11 @@ async function ensureXctestrunUnderCacheLock(params: { }): Promise { const { device, options, projectRoot, expectedCacheMetadata, derived } = params; cleanRunnerDerivedBeforeEvaluation(derived, params.forceRebuild); - const existing = await evaluateExistingXctestrunForDevice({ - device, + const existing = await evaluateExistingXctestrun({ derived, - projectRoot, expectedCacheMetadata, }); - const cache = - existing.reason === 'reuse_ready' ? 'exact' : existing.xctestrunPath ? 'restore-key' : 'miss'; - if (existing.reason !== 'reuse_ready') { - emitRunnerXctestrunRebuildDecision(existing, derived); - } + const cache = existing.reason === 'reuse_ready' ? 'exact' : 'miss'; const reusable = await resolveReusableXctestrunArtifact({ device, derived, @@ -193,7 +188,13 @@ async function ensureXctestrunUnderCacheLock(params: { cache, }); if (reusable) return reusable; - if (existing.xctestrunPath) { + if (existing.reason !== 'reuse_ready') { + emitRunnerXctestrunRebuildDecision(existing, derived); + } + // Nothing survived evaluation — a certified state that failed repair, or one the manifest + // refuses — so the tree is discarded before the rebuild. A missing manifest is not ours to + // delete: that directory is either a first build or one the caller laid out itself. + if (existing.reason !== 'cache_metadata_missing') { assertSafeDerivedCleanup(derived); cleanRunnerDerivedArtifacts(derived); } @@ -230,7 +231,7 @@ async function resolveReusableXctestrunArtifact(params: { cache, artifact: 'valid', buildMs: 0, - xctestrunPathSource: existing.source, + xctestrunPathSource: 'manifest', }; } @@ -273,6 +274,7 @@ async function buildXctestrunArtifact(params: { await repairMacOsRunnerProductsIfNeeded(device, builtProductPaths, built); // Release/dev script builds patch the synthesized XCTest runner app in scripts/. // This covers direct local xcodebuilds triggered by ensureXctestrunArtifact on cache miss. + // The manifest is written last so it certifies the bytes that actually run. await applyXctestRunnerAppIcon(builtProductPaths); writeRunnerCacheMetadataForArtifacts(derived, expectedCacheMetadata, built, builtProductPaths); emitRunnerXctestrunDecision('build', 'built_new', { @@ -322,44 +324,23 @@ async function tryReuseExistingXctestrun( } // Cache probe for preflight surfaces (doctor): runs the same no-build reuse -// evaluation as the ensure path (cache metadata + product-path validation), -// so a partial or stale cache never reports as ready. Resolving the expected -// metadata stats the runner sources and reads tool versions (~100ms, cached -// per process) but never builds. +// evaluation as the ensure path (cache metadata + content-manifest validation), +// so a partial, restored, or tampered cache never reports as ready. Resolving +// the expected metadata stats the runner sources and reads tool versions +// (~100ms, cached per process) and the manifest digests the products (tens of +// ms) but never builds. export async function hasCachedAppleRunnerArtifact(device: DeviceInfo): Promise { try { const projectRoot = findProjectRoot(); const expectedCacheMetadata = resolveExpectedRunnerCacheMetadata(device, projectRoot); const derived = resolveRunnerDerivedPath(device, expectedCacheMetadata); - const existing = await evaluateExistingXctestrunForDevice({ - device, - derived, - projectRoot, - expectedCacheMetadata, - }); + const existing = await evaluateExistingXctestrun({ derived, expectedCacheMetadata }); return existing.reason === 'reuse_ready'; } catch { return false; } } -function evaluateExistingXctestrunForDevice(params: { - device: DeviceInfo; - derived: string; - projectRoot: string; - expectedCacheMetadata: RunnerXctestrunCacheMetadata; -}): Promise { - const { device, derived, projectRoot, expectedCacheMetadata } = params; - return evaluateExistingXctestrun({ - derived, - projectRoot, - expectedCacheMetadata, - findXctestrun: (root) => findXctestrun(root, device), - xctestrunReferencesProjectRoot, - resolveExistingXctestrunProductPaths, - }); -} - type XctestrunCandidate = { path: string; mtimeMs: number; @@ -438,27 +419,6 @@ export function scoreXctestrunCandidate(candidatePath: string, device: DeviceInf return score; } -export function xctestrunReferencesProjectRoot( - xctestrunPath: string, - projectRoot: string, -): boolean { - try { - const contents = fs.readFileSync(xctestrunPath, 'utf8'); - const candidateRoots = new Set([projectRoot]); - try { - candidateRoots.add(fs.realpathSync(projectRoot)); - } catch {} - for (const root of candidateRoots) { - if (contents.includes(root)) { - return true; - } - } - return false; - } catch { - return false; - } -} - async function buildRunnerXctestrun( device: DeviceInfo, projectPath: string, @@ -475,6 +435,7 @@ async function buildRunnerXctestrun( ); const provisioningArgs = device.kind === 'device' ? ['-allowProvisioningUpdates'] : []; const performanceBuildSettings = resolveRunnerPerformanceBuildSettings(); + const archBuildSettings = resolveRunnerArchBuildSettings(process.env); const sandboxBuildArgs = resolveRunnerSandboxBuildArgs(); try { await runCmdStreaming( @@ -493,6 +454,7 @@ async function buildRunnerXctestrun( '-derivedDataPath', derived, ...performanceBuildSettings, + ...archBuildSettings, ...sandboxBuildArgs, ...runnerBundleBuildSettings, ...provisioningArgs, diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts index af48309f62..c4ae6eca71 100644 --- a/packages/platform-apple/src/runner/runner-cache-metadata.ts +++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; @@ -29,7 +30,7 @@ import { computeRunnerSourceFingerprint } from './runner-source.ts'; const DEFAULT_IOS_RUNNER_APP_BUNDLE_ID = 'com.callstack.agentdevice.runner'; const RUNNER_DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'apple-runner'); export const RUNNER_CACHE_METADATA_FILE = '.agent-device-runner-cache.json'; -const RUNNER_CACHE_SCHEMA_VERSION = 2; +const RUNNER_CACHE_SCHEMA_VERSION = 3; const RUNNER_CACHE_METADATA_VALUE_MAX_LENGTH = 300; /** @@ -49,9 +50,14 @@ const RUNNER_SANDBOX_BUILD_ARGS = [ '-IDEPackageSupportDisablePluginExecutionSandbox=1', 'ENABLE_USER_SCRIPT_SANDBOXING=NO', ] as const; -const RUNNER_RUNTIME_SWIFT_FLAGS = '$(inherited) -disable-sandbox'; -const RUNNER_UNIT_TEST_SWIFT_FLAGS = - '$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS'; +/** + * The isolation-scan canary compiles in every runner build, whether a build came from + * `scripts/build-xcuitest-apple.sh` or from `ensureXctestrunArtifact`, so the metadata's + * recorded Swift flags describe what the compiler actually received on both paths. + */ +const RUNNER_RUNTIME_SWIFT_FLAGS = + '$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY'; +const RUNNER_UNIT_TEST_SWIFT_FLAGS = `${RUNNER_RUNTIME_SWIFT_FLAGS} -D AGENT_DEVICE_RUNNER_UNIT_TESTS`; /** Toolchain half of the runner cache key. Every field is a probed value. */ export type RunnerToolchainFingerprint = { @@ -169,23 +175,41 @@ export type RunnerXctestrunCacheMetadata = RunnerToolchainFingerprint & { runnerBundleBuildSettings: string[]; runnerSigningBuildSettings: string[]; runnerPerformanceBuildSettings: string[]; + runnerArchBuildSettings: string[]; runnerSandboxBuildArgs: string[]; artifacts?: RunnerXctestrunCacheArtifacts; }; export type RunnerXctestrunCacheArtifacts = { xctestrunPath: string; - xctestrunMtimeMs: number; xctestrunSize: number; - productPaths: RunnerXctestrunCacheProductArtifact[]; + xctestrunDigest: string; + productPaths: string[]; + /** Paths are relative to the cache root the manifest was written under. */ + entries: RunnerCacheArtifactEntry[]; }; -export type RunnerXctestrunCacheProductArtifact = { +/** + * One file inside a cached product bundle: its bytes hashed, its permission bits, and the + * size that makes an equal-size rewrite with a stale mtime visible as a digest mismatch. + */ +export type RunnerCacheArtifactFileEntry = { path: string; - mtimeMs: number; size: number; + mode: number; + digest: string; +}; + +/** One symlink inside a cached product bundle, recorded as its raw target string. */ +export type RunnerCacheArtifactSymlinkEntry = { + path: string; + symlink: string; }; +export type RunnerCacheArtifactEntry = + | RunnerCacheArtifactFileEntry + | RunnerCacheArtifactSymlinkEntry; + function normalizeBundleId(value: string | undefined): string { return value?.trim() ?? ''; } @@ -245,6 +269,7 @@ export function resolveExpectedRunnerCacheMetadata( device, ), runnerPerformanceBuildSettings: resolveRunnerPerformanceBuildSettings(), + runnerArchBuildSettings: resolveRunnerArchBuildSettings(process.env), runnerSandboxBuildArgs: resolveRunnerSandboxBuildArgs(), }; } @@ -587,6 +612,17 @@ export function resolveRunnerPerformanceBuildSettings(): string[] { ]; } +/** + * The architecture an explicit `AGENT_DEVICE_XCUITEST_ARCHS` pins. A generic simulator + * destination leaves the active arch undefined and Xcode picks one per version, so the + * override changes the bytes on disk and must reach both the `xcodebuild` arguments and the + * cache identity from this one resolver. + */ +export function resolveRunnerArchBuildSettings(env: NodeJS.ProcessEnv = process.env): string[] { + const archs = env.AGENT_DEVICE_XCUITEST_ARCHS?.trim(); + return archs ? [`ARCHS=${archs}`] : []; +} + export function resolveRunnerSandboxBuildArgs(): string[] { return [ ...RUNNER_SANDBOX_BUILD_ARGS, @@ -599,3 +635,112 @@ function resolveRunnerSwiftFlags(env: NodeJS.ProcessEnv): string { ? RUNNER_UNIT_TEST_SWIFT_FLAGS : RUNNER_RUNTIME_SWIFT_FLAGS; } + +const BUILD_SETTINGS_HEADER = /^\s*Build settings from command line:\s*$/; +const BUILD_SETTING_LINE = /^\s+([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/; +const RECORDED_BUILD_SETTING = /^([A-Z][A-Z0-9_]*)=(.*)$/; + +export type RunnerBuildSettingEvidence = { + key: string; + expected: string; + actual: string; +}; + +/** + * The build settings `xcodebuild` echoed back, or null when the log holds no such block. That + * echo is the build's own record of the recipe it was handed. + */ +function readRunnerBuildSettingsFromBuildLog(logPath: string): Map | null { + let contents: string; + try { + contents = fs.readFileSync(logPath, 'utf8'); + } catch { + return null; + } + const settings = new Map(); + let inBlock = false; + for (const line of contents.split('\n')) { + if (BUILD_SETTINGS_HEADER.test(line)) { + inBlock = true; + continue; + } + if (!inBlock) continue; + const setting = BUILD_SETTING_LINE.exec(line); + if (!setting) break; + settings.set(setting[1]!, setting[2]!.trimEnd()); + } + return inBlock ? settings : null; +} + +function recordedRunnerBuildSettings( + metadata: RunnerXctestrunCacheMetadata, +): Record { + const recorded: Record = {}; + for (const arg of [ + ...metadata.runnerBundleBuildSettings, + ...metadata.runnerSigningBuildSettings, + ...metadata.runnerPerformanceBuildSettings, + ...metadata.runnerArchBuildSettings, + ...metadata.runnerSandboxBuildArgs, + ]) { + const setting = RECORDED_BUILD_SETTING.exec(arg); + if (setting) { + recorded[setting[1]!] = setting[2]!; + } + } + return recorded; +} + +/** + * The recorded settings a build log shows `xcodebuild` did not receive exactly as recorded. An + * empty recorded value matches an absent report, which is how `CODE_SIGN_IDENTITY=` arrives. + */ +function diffRunnerBuildSettingsAgainstBuildLog( + metadata: RunnerXctestrunCacheMetadata, + logPath: string, +): RunnerBuildSettingEvidence[] { + const reported = readRunnerBuildSettingsFromBuildLog(logPath); + if (!reported) { + return [ + { + key: '(build log)', + expected: 'a "Build settings from command line:" block', + actual: 'missing or unreadable log', + }, + ]; + } + return Object.entries(recordedRunnerBuildSettings(metadata)) + .filter(([key, expected]) => { + const actual = reported.get(key); + return actual === undefined ? expected !== '' : actual !== expected; + }) + .map(([key, expected]) => ({ + key, + expected, + actual: reported.get(key) ?? '(absent)', + })); +} + +/** + * Fails when a build log shows a recipe other than the one `metadata` records, so a caller that + * drifted from this identity cannot have its products certified under it. + */ +export function requireRunnerBuildSettingsMatchBuildLog( + metadata: RunnerXctestrunCacheMetadata, + logPath: string, +): void { + const differences = diffRunnerBuildSettingsAgainstBuildLog(metadata, logPath); + if (differences.length === 0) { + return; + } + throw new AppError( + 'COMMAND_FAILED', + 'The Apple runner build did not use the settings its cache identity records', + { + reason: 'runner_build_settings_mismatch', + buildLogPath: logPath, + differences, + hint: 'Align the build invocation with the runner cache identity resolvers, or rebuild without the cache.', + }, + ); +} diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts index 501458404c..eebf1aa321 100644 --- a/packages/platform-apple/src/runner/runner-cache.ts +++ b/packages/platform-apple/src/runner/runner-cache.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; @@ -14,14 +15,17 @@ import { comparableRunnerCacheMetadata, diffComparableRunnerCacheMetadata, stableJsonStringify, + type RunnerCacheArtifactEntry, + type RunnerCacheArtifactFileEntry, + type RunnerCacheArtifactSymlinkEntry, type RunnerCacheMetadataDifference, type RunnerXctestrunCacheArtifacts, type RunnerXctestrunCacheMetadata, - type RunnerXctestrunCacheProductArtifact, } from './runner-cache-metadata.ts'; export { requireRunnerPhaseRemainingMs, resolveExpectedRunnerCacheMetadata, + resolveRunnerArchBuildSettings, resolveRunnerBundleBuildSettings, resolveRunnerDerivedPath, resolveRunnerMaxConcurrentDestinationsFlag, @@ -36,36 +40,52 @@ const RUNNER_XCTESTRUN_CACHE_LOCK_TIMEOUT_MS = 10 * 60_000; const RUNNER_XCTESTRUN_CACHE_LOCK_POLL_MS = 100; const RUNNER_XCTESTRUN_CACHE_LOCK_OWNER_GRACE_MS = 5_000; +/** Ceiling on one digested artifact file. Runner products are tens of MB at most. */ +const RUNNER_CACHE_ARTIFACT_MAX_FILE_BYTES = 128 * 1024 * 1024; +const RUNNER_CACHE_ARTIFACT_MODE_BITS = 0o7777; + const badRunnerArtifactsForRun = new Set(); -export type RunnerXctestrunCacheKind = 'exact' | 'restore-key' | 'miss' | 'external'; +export type RunnerXctestrunCacheKind = 'exact' | 'miss' | 'external'; export type ExistingXctestrunState = - | { - reason: 'missing_xctestrun'; - xctestrunPath: null; - } | { reason: 'reuse_ready'; xctestrunPath: string; productPaths: string[]; - source: 'manifest' | 'scan'; } | { - reason: 'project_root_mismatch' | 'missing_products' | 'cache_metadata_missing'; - xctestrunPath: string; + reason: 'cache_metadata_missing' | 'artifact_manifest_missing'; + xctestrunPath: string | null; productPaths: string[]; - source: 'manifest' | 'scan'; + } + | { + reason: 'artifact_content_mismatch'; + xctestrunPath: string | null; + productPaths: string[]; + /** The first entry whose bytes, kind, or mode disagree with the manifest. */ + mismatch: RunnerCacheArtifactMismatch; } | { reason: 'cache_metadata_mismatch'; - xctestrunPath: string; + xctestrunPath: string | null; productPaths: string[]; - source: 'manifest' | 'scan'; /** Which comparable keys differ, so a rebuild names its cause. */ metadataDifferences: RunnerCacheMetadataDifference[]; }; +/** Why a content manifest does not certify the products on disk. */ +export type RunnerCacheArtifactMismatch = + | { path: string; reason: 'missing' } + | { path: string; reason: 'kind_changed' } + | { path: string; reason: 'size_changed'; expected: number; actual: number } + | { path: string; reason: 'digest_mismatch' } + | { path: string; reason: 'mode_changed'; expected: number; actual: number } + | { path: string; reason: 'symlink_target_changed'; expected: string; actual: string } + | { path: string; reason: 'escaping_symlink'; target: string } + | { path: string; reason: 'undeclared_entry' } + | { path: string; reason: 'file_too_large'; size: number }; + type RunnerXctestrunArtifactIdentity = { cache: RunnerXctestrunCacheKind; derived: string; @@ -148,16 +168,19 @@ export function cleanRunnerDerivedBeforeEvaluation(derived: string, forceRebuild badRunnerArtifactsForRun.delete(derived); } +/** + * Writes cache metadata whose `artifacts` manifest digests the exact bytes of the + * `.xctestrun` and every file and symlink under the referenced product paths, keyed + * relative to the cache root. Reuse is authorized from this manifest alone. + */ export function writeRunnerCacheMetadataForArtifacts( derived: string, metadata: RunnerXctestrunCacheMetadata, xctestrunPath: string, - productPaths: string[], + productPaths: readonly string[], ): void { - writeRunnerCacheMetadata( - derived, - withRunnerCacheArtifacts(metadata, xctestrunPath, productPaths), - ); + const artifacts = buildRunnerCacheArtifacts(derived, xctestrunPath, productPaths); + writeRunnerCacheMetadata(derived, artifacts ? { ...metadata, artifacts } : metadata); } export function cleanRunnerDerivedArtifacts(derived: string): void { @@ -235,69 +258,330 @@ function evaluateRunnerCacheMetadata( return { ok: true, metadata: actual }; } -function withRunnerCacheArtifacts( - metadata: RunnerXctestrunCacheMetadata, - xctestrunPath: string, - productPaths: readonly string[], -): RunnerXctestrunCacheMetadata { - const artifacts = buildRunnerCacheArtifacts(xctestrunPath, productPaths); - return artifacts ? { ...metadata, artifacts } : metadata; -} - function buildRunnerCacheArtifacts( + cacheRoot: string, xctestrunPath: string, productPaths: readonly string[], ): RunnerXctestrunCacheArtifacts | null { - const xctestrunStats = readPathSignature(xctestrunPath); - if (xctestrunStats === null || productPaths.length === 0) { + if (productPaths.length === 0) { + return null; + } + if ( + !isPathInsideDirectory(xctestrunPath, cacheRoot) || + !productPaths.every((productPath) => isPathInsideDirectory(productPath, cacheRoot)) + ) { return null; } - const productArtifacts: RunnerXctestrunCacheProductArtifact[] = []; - for (const productPath of productPaths) { - const stats = readPathSignature(productPath); - if (stats === null) { + const xctestrunDigest = digestFile(xctestrunPath); + if (!xctestrunDigest) { + return null; + } + const entries: RunnerCacheArtifactEntry[] = []; + for (const productPath of dedupeNestedPaths(productPaths)) { + const collected = collectRunnerCacheArtifactEntries(cacheRoot, productPath); + if (!collected) { return null; } - productArtifacts.push({ path: productPath, ...stats }); + entries.push(...collected); + } + if (entries.length === 0) { + return null; } + entries.sort((left, right) => left.path.localeCompare(right.path)); return { xctestrunPath, - xctestrunMtimeMs: xctestrunStats.mtimeMs, - xctestrunSize: xctestrunStats.size, - productPaths: productArtifacts, + xctestrunSize: fs.statSync(xctestrunPath).size, + xctestrunDigest: xctestrunDigest.digest, + productPaths: [...productPaths], + entries, }; } -function readValidatedRunnerCacheArtifacts( - derived: string, - metadata: RunnerXctestrunCacheMetadata | null, -): { xctestrunPath: string; productPaths: string[] } | null { - const artifacts = metadata?.artifacts; - if (!isRunnerCacheArtifacts(artifacts)) { +/** Drop paths whose ancestor is already walked, so no subtree is collected twice. */ +function dedupeNestedPaths(productPaths: readonly string[]): string[] { + const sorted = [...new Set(productPaths.map((target) => path.resolve(target)))].sort(); + const kept: string[] = []; + for (const candidate of sorted) { + if (kept.some((keptPath) => isPathInsideDirectory(candidate, keptPath))) continue; + kept.push(candidate); + } + return kept; +} + +/** + * One leaf of a cached product: where it lives in the manifest, where it lives on disk, and + * what `lstat` says about it. The writer digests these; the reader compares them to the manifest. + */ +type RunnerCacheArtifactLeaf = { + relativePath: string; + fullPath: string; + stat: fs.Stats; +}; + +/** + * The single traversal both cache sides share: every file and symlink under one product root, + * in manifest-relative form. A kind the manifest cannot represent — a socket, a device, an + * escaping symlink — or an unreadable directory makes the whole product uncertifiable. + */ +// fallow-ignore-next-line complexity +function walkRunnerCacheArtifactLeaves( + cacheRoot: string, + root: string, +): RunnerCacheArtifactLeaf[] | null { + const leaves: RunnerCacheArtifactLeaf[] = []; + const stack: string[] = [root]; + while (stack.length > 0) { + const directory = stack.pop()!; + let names: string[]; + try { + names = fs.readdirSync(directory); + } catch { + return null; + } + for (const name of names) { + const fullPath = path.join(directory, name); + const relativePath = toManifestPath(cacheRoot, fullPath); + if (relativePath === null) { + return null; + } + let stat: fs.Stats; + try { + stat = fs.lstatSync(fullPath); + } catch { + return null; + } + if (!isManifestDescribingEntry(stat)) { + return null; + } + if (!stat.isDirectory()) { + leaves.push({ relativePath, fullPath, stat }); + } else { + stack.push(fullPath); + } + } + } + return leaves; +} + +/** + * Whether one directory entry is something a content manifest can describe at all: a directory + * to descend into, a regular file, or a symlink. A socket or a device makes the product + * uncertifiable. Whether a symlink stays inside the tree is decided per side: the writer + * refuses to certify one that escapes, the reader reports which entry started escaping. + */ +function isManifestDescribingEntry(stat: fs.Stats): boolean { + return stat.isDirectory() || stat.isFile() || stat.isSymbolicLink(); +} + +function collectRunnerCacheArtifactEntries( + cacheRoot: string, + root: string, +): RunnerCacheArtifactEntry[] | null { + const leaves = walkRunnerCacheArtifactLeaves(cacheRoot, root); + if (!leaves) { return null; } - if (!isPathInsideDirectory(artifacts.xctestrunPath, derived)) { + const entries: RunnerCacheArtifactEntry[] = []; + for (const leaf of leaves) { + if (leaf.stat.isSymbolicLink()) { + const target = fs.readlinkSync(leaf.fullPath); + if (!isSymlinkContained(cacheRoot, leaf.fullPath, target)) { + return null; + } + entries.push({ path: leaf.relativePath, symlink: target }); + continue; + } + if (leaf.stat.size > RUNNER_CACHE_ARTIFACT_MAX_FILE_BYTES) { + return null; + } + const digest = digestFile(leaf.fullPath); + if (!digest) { + return null; + } + entries.push({ + path: leaf.relativePath, + size: digest.size, + mode: leaf.stat.mode & RUNNER_CACHE_ARTIFACT_MODE_BITS, + digest: digest.digest, + }); + } + return entries; +} + +function toManifestPath(cacheRoot: string, fullPath: string): string | null { + const relativePath = path.relative(cacheRoot, fullPath); + if (relativePath === '' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return null; + } + return relativePath; +} + +function isSymlinkContained(cacheRoot: string, linkPath: string, target: string): boolean { + if (path.isAbsolute(target)) { + return isPathInsideDirectory(target, cacheRoot); + } + return isPathInsideDirectory(path.resolve(path.dirname(linkPath), target), cacheRoot); +} + +function digestFile(filePath: string): { digest: string; size: number } | null { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size > RUNNER_CACHE_ARTIFACT_MAX_FILE_BYTES) { + return null; + } + const hash = crypto.createHash('sha256'); + hash.update(fs.readFileSync(filePath)); + return { digest: hash.digest('hex'), size: stat.size }; + } catch { return null; } +} + +type RunnerCacheArtifactValidation = + | { ok: true; xctestrunPath: string; productPaths: string[] } + | { ok: false; mismatch: RunnerCacheArtifactMismatch | null }; + +function readValidatedRunnerCacheArtifacts( + derived: string, + metadata: RunnerXctestrunCacheMetadata, +): RunnerCacheArtifactValidation { + const artifacts = metadata.artifacts; + if (!isRunnerCacheArtifacts(artifacts)) { + return { ok: false, mismatch: null }; + } if ( - !pathSignatureMatches(artifacts.xctestrunPath, { - mtimeMs: artifacts.xctestrunMtimeMs, - size: artifacts.xctestrunSize, - }) + !isPathInsideDirectory(artifacts.xctestrunPath, derived) || + !artifacts.productPaths.every((productPath) => isPathInsideDirectory(productPath, derived)) ) { - return null; + return { ok: false, mismatch: null }; } - const productPaths: string[] = []; - for (const product of artifacts.productPaths) { - if (!isPathInsideDirectory(product.path, derived)) { - return null; + const declared = new Map(artifacts.entries.map((entry) => [entry.path, entry])); + const xctestrunMismatch = validateManifestedFile( + artifacts.xctestrunPath, + artifacts.xctestrunSize, + artifacts.xctestrunDigest, + ); + if (xctestrunMismatch) { + return { ok: false, mismatch: xctestrunMismatch }; + } + for (const productPath of dedupeNestedPaths(artifacts.productPaths)) { + const mismatch = validateProductAgainstManifest(derived, productPath, declared); + if (mismatch) { + return { ok: false, mismatch }; } - if (!pathSignatureMatches(product.path, product)) { - return null; + } + // Anything still declared never came back from the walk. + const unlisted = declared.keys().next(); + if (!unlisted.done) { + return { ok: false, mismatch: { path: unlisted.value, reason: 'missing' } }; + } + return { + ok: true, + xctestrunPath: artifacts.xctestrunPath, + productPaths: [...artifacts.productPaths], + }; +} + +/** + * Walks one product with the same traversal the writer used and removes each leaf it can + * certify from `declared`. A leaf on disk that the manifest never names is unaccounted for. + */ +function validateProductAgainstManifest( + derived: string, + productPath: string, + declared: Map, +): RunnerCacheArtifactMismatch | null { + const leaves = walkRunnerCacheArtifactLeaves(derived, productPath); + if (!leaves) { + return { path: productPath, reason: 'missing' }; + } + for (const leaf of leaves) { + const entry = declared.get(leaf.relativePath); + if (!entry) { + return { path: leaf.relativePath, reason: 'undeclared_entry' }; + } + const mismatch = validateManifestEntry(derived, leaf.fullPath, leaf.relativePath, entry); + if (mismatch) { + return mismatch; + } + declared.delete(leaf.relativePath); + } + return null; +} + +function validateManifestEntry( + cacheRoot: string, + fullPath: string, + relativePath: string, + entry: RunnerCacheArtifactEntry, +): RunnerCacheArtifactMismatch | null { + if ('symlink' in entry) { + let actual: string; + try { + actual = fs.readlinkSync(fullPath); + } catch { + return { path: relativePath, reason: 'missing' }; + } + if (actual !== entry.symlink) { + return { + path: relativePath, + reason: 'symlink_target_changed', + expected: entry.symlink, + actual, + }; + } + if (!isSymlinkContained(cacheRoot, fullPath, actual)) { + return { path: relativePath, reason: 'escaping_symlink', target: actual }; } - productPaths.push(product.path); + return null; } - return { xctestrunPath: artifacts.xctestrunPath, productPaths }; + return validateManifestedFile(fullPath, entry.size, entry.digest, entry.mode, relativePath); +} + +function validateManifestedFile( + fullPath: string, + expectedSize: number, + expectedDigest: string, + expectedMode?: number, + relativePath?: string, +): RunnerCacheArtifactMismatch | null { + const reportedPath = relativePath ?? fullPath; + let stat: fs.Stats; + try { + stat = fs.lstatSync(fullPath); + } catch { + return { path: reportedPath, reason: 'missing' }; + } + if (!stat.isFile()) { + return { path: reportedPath, reason: 'kind_changed' }; + } + if (stat.size !== expectedSize) { + return { + path: reportedPath, + reason: 'size_changed', + expected: expectedSize, + actual: stat.size, + }; + } + if ( + expectedMode !== undefined && + (stat.mode & RUNNER_CACHE_ARTIFACT_MODE_BITS) !== expectedMode + ) { + return { + path: reportedPath, + reason: 'mode_changed', + expected: expectedMode, + actual: stat.mode & RUNNER_CACHE_ARTIFACT_MODE_BITS, + }; + } + const digested = digestFile(fullPath); + if (!digested) { + return { path: reportedPath, reason: 'file_too_large', size: stat.size }; + } + if (digested.digest !== expectedDigest) { + return { path: reportedPath, reason: 'digest_mismatch' }; + } + return null; } function isRunnerCacheArtifacts(value: unknown): value is RunnerXctestrunCacheArtifacts { @@ -307,43 +591,52 @@ function isRunnerCacheArtifacts(value: unknown): value is RunnerXctestrunCacheAr const artifacts = value as Partial; return ( typeof artifacts.xctestrunPath === 'string' && - Number.isInteger(artifacts.xctestrunMtimeMs) && - Number.isInteger(artifacts.xctestrunSize) && - Array.isArray(artifacts.productPaths) && - artifacts.productPaths.length > 0 && - artifacts.productPaths.every(isRunnerCacheProductArtifact) + isNonNegativeInteger(artifacts.xctestrunSize) && + typeof artifacts.xctestrunDigest === 'string' && + isNonEmptyStringArray(artifacts.productPaths) && + isNonEmptyArray(artifacts.entries) && + artifacts.entries.every(isRunnerCacheArtifactEntry) ); } -function isRunnerCacheProductArtifact( - value: unknown, -): value is RunnerXctestrunCacheProductArtifact { +function isRunnerCacheArtifactEntry(value: unknown): value is RunnerCacheArtifactEntry { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false; } - const product = value as Partial; + const entry = value as Partial & + Partial; + if (typeof entry.path !== 'string' || !isManifestRelativePath(entry.path)) { + return false; + } + if (typeof entry.symlink === 'string') { + return entry.digest === undefined; + } return ( - typeof product.path === 'string' && - Number.isInteger(product.mtimeMs) && - Number.isInteger(product.size) + typeof entry.digest === 'string' && + isNonNegativeInteger(entry.size) && + typeof entry.mode === 'number' ); } -function readPathSignature(filePath: string): { mtimeMs: number; size: number } | null { - try { - const stat = fs.statSync(filePath); - return { mtimeMs: Math.trunc(stat.mtimeMs), size: stat.size }; - } catch { - return null; - } +/** A manifest path must stay inside the cache root it was written under. */ +function isManifestRelativePath(relativePath: string): boolean { + return ( + !relativePath.startsWith('/') && + !relativePath.startsWith('..') && + !path.isAbsolute(relativePath) + ); +} + +function isNonNegativeInteger(value: unknown): value is number { + return Number.isInteger(value) && (value as number) >= 0; } -function pathSignatureMatches( - filePath: string, - expected: { mtimeMs: number; size: number }, -): boolean { - const actual = readPathSignature(filePath); - return actual?.mtimeMs === expected.mtimeMs && actual.size === expected.size; +function isNonEmptyStringArray(value: unknown): value is string[] { + return isNonEmptyArray(value) && value.every((item) => typeof item === 'string'); +} + +function isNonEmptyArray(value: unknown): value is Item[] { + return Array.isArray(value) && value.length > 0; } function isPathInsideDirectory(targetPath: string, directoryPath: string): boolean { @@ -382,54 +675,42 @@ function isPathInsideProjectTmp(targetPath: string): boolean { return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath); } -// fallow-ignore-next-line complexity export async function evaluateExistingXctestrun(options: { derived: string; - projectRoot: string; expectedCacheMetadata: RunnerXctestrunCacheMetadata; - findXctestrun: (root: string) => string | null; - xctestrunReferencesProjectRoot: (xctestrunPath: string, projectRoot: string) => boolean; - resolveExistingXctestrunProductPaths: (xctestrunPath: string) => Promise; }): Promise { const cacheMetadata = evaluateRunnerCacheMetadata(options.derived, options.expectedCacheMetadata); - const manifest = cacheMetadata.ok - ? readValidatedRunnerCacheArtifacts(options.derived, cacheMetadata.metadata) - : null; - const xctestrunPath = manifest?.xctestrunPath ?? options.findXctestrun(options.derived); - if (!xctestrunPath) { - return { reason: 'missing_xctestrun', xctestrunPath: null }; - } - const hasValidatedManifest = manifest?.xctestrunPath === xctestrunPath; - const source = hasValidatedManifest ? 'manifest' : 'scan'; - const productPaths = hasValidatedManifest - ? manifest.productPaths - : await options.resolveExistingXctestrunProductPaths(xctestrunPath); - if (!productPaths) { - return { reason: 'missing_products', xctestrunPath, productPaths: [], source }; - } - if ( - !options.xctestrunReferencesProjectRoot(xctestrunPath, options.projectRoot) && - !hasValidatedManifest - ) { - return { reason: 'project_root_mismatch', xctestrunPath, productPaths, source }; - } if (!cacheMetadata.ok) { return cacheMetadata.reason === 'cache_metadata_mismatch' ? { reason: cacheMetadata.reason, - xctestrunPath, - productPaths, - source, + xctestrunPath: null, + productPaths: [], metadataDifferences: cacheMetadata.differences, } - : { reason: cacheMetadata.reason, xctestrunPath, productPaths, source }; + : { reason: cacheMetadata.reason, xctestrunPath: null, productPaths: [] }; + } + const artifacts = readValidatedRunnerCacheArtifacts(options.derived, cacheMetadata.metadata); + if (!artifacts.ok) { + return artifacts.mismatch + ? { + reason: 'artifact_content_mismatch', + xctestrunPath: null, + productPaths: [], + mismatch: artifacts.mismatch, + } + : { reason: 'artifact_manifest_missing', xctestrunPath: null, productPaths: [] }; } - return { reason: 'reuse_ready', xctestrunPath, productPaths, source }; + return { + reason: 'reuse_ready', + xctestrunPath: artifacts.xctestrunPath, + productPaths: artifacts.productPaths, + }; } /** * Reports why a cache state cannot be reused, naming the differing keys when the - * cause is a metadata mismatch. + * cause is a metadata mismatch and the failing entry when the cause is content. */ export function emitRunnerXctestrunRebuildDecision( existing: Exclude, @@ -437,10 +718,10 @@ export function emitRunnerXctestrunRebuildDecision( ): void { emitRunnerXctestrunDecision('rebuild', existing.reason, { derived, - xctestrunPath: existing.xctestrunPath, ...(existing.reason === 'cache_metadata_mismatch' ? { metadataDifferences: existing.metadataDifferences } : {}), + ...(existing.reason === 'artifact_content_mismatch' ? { mismatch: existing.mismatch } : {}), }); } @@ -448,9 +729,8 @@ export function emitRunnerXctestrunDecision( action: 'clean' | 'reuse' | 'rebuild' | 'build' | 'preserve', reason: | 'forced_clean' - | 'missing_xctestrun' - | 'project_root_mismatch' - | 'missing_products' + | 'artifact_manifest_missing' + | 'artifact_content_mismatch' | 'cache_metadata_missing' | 'cache_metadata_mismatch' | 'repair_failed' diff --git a/packages/platform-apple/src/runner/runner-source.ts b/packages/platform-apple/src/runner/runner-source.ts index 2512e25130..5483a6a394 100644 --- a/packages/platform-apple/src/runner/runner-source.ts +++ b/packages/platform-apple/src/runner/runner-source.ts @@ -116,44 +116,61 @@ function collectRunnerSourceFilesUnderRoot( ignoredDirectoryNames: ReadonlySet, ): string[] { return fs.existsSync(root) - ? collectRunnerSourceFilesInDirectory(root, ignoredDirectoryNames) + ? collectRunnerSourceFilesInDirectory(root, ignoredDirectoryNames, false) : []; } function collectRunnerSourceFilesInDirectory( directory: string, ignoredDirectoryNames: ReadonlySet, + includeEveryFile: boolean, ): string[] { const files: string[] = []; for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const fullPath = path.join(directory, entry.name); if (entry.isDirectory()) { - if (!ignoredDirectoryNames.has(entry.name)) { - files.push(...collectRunnerSourceFilesInDirectory(fullPath, ignoredDirectoryNames)); - } - } else if (entry.isFile() && isRunnerSourceFile(entry.name, fullPath)) { + if (ignoredDirectoryNames.has(entry.name)) continue; + const nestedIncludeEveryFile = includeEveryFile || isXcodePackageDirectory(entry.name); + files.push( + ...collectRunnerSourceFilesInDirectory( + fullPath, + ignoredDirectoryNames, + nestedIncludeEveryFile, + ), + ); + } else if (entry.isFile() && (includeEveryFile || isRunnerSourceFile(entry.name))) { files.push(fullPath); } } return files; } -function isRunnerSourceFile(fileName: string, filePath: string): boolean { - if (fileName === 'project.pbxproj') { - return filePath.includes(`${path.sep}.xcodeproj${path.sep}`); - } - return [ - '.jpg', - '.json', - '.png', - '.swift', - '.m', - '.h', - '.plist', - '.entitlements', - '.xctestplan', - '.xcconfig', - '.storyboard', - '.xib', - ].includes(path.extname(fileName)); +/** + * Xcode owns the contents of a project or workspace package — `project.pbxproj`, shared + * schemes, and workspace data all change what a build produces — so every file inside one + * is build input, not just the ones with a recognizable extension. + */ +function isXcodePackageDirectory(directoryName: string): boolean { + return directoryName.endsWith('.xcodeproj') || directoryName.endsWith('.xcworkspace'); +} + +const RUNNER_SOURCE_FILE_EXTENSIONS = new Set([ + '.jpg', + '.json', + '.png', + '.swift', + '.m', + '.h', + '.plist', + '.entitlements', + '.xctestplan', + '.xcconfig', + '.storyboard', + '.xib', + '.xcscheme', + '.xcworkspacedata', +]); + +function isRunnerSourceFile(fileName: string): boolean { + return RUNNER_SOURCE_FILE_EXTENSIONS.has(path.extname(fileName)); } diff --git a/scripts/__tests__/apple-ci-impact.test.ts b/scripts/__tests__/apple-ci-impact.test.ts index e6a00d9dce..0151acde50 100644 --- a/scripts/__tests__/apple-ci-impact.test.ts +++ b/scripts/__tests__/apple-ci-impact.test.ts @@ -95,6 +95,9 @@ test('Apple runner build cache uses only declared source and schema hashes', () 'scripts/build-xcuitest-apple.sh', ]); expect(steps[restoreIndex]?.with?.key).toContain('steps.cache-schema.outputs.value'); + // Restoring under a prefix would hand a job products whose content manifest belongs to + // another identity, so the cache is exact-match only and the source hash must be in the key. + expect(steps[restoreIndex]?.with?.key).toContain('steps.source-hash.outputs.value'); expect(steps[restoreIndex]?.with?.['restore-keys']).toBeUndefined(); expect(cacheInputs(text)).not.toContain('scripts/patch-xcuitest-runner-icon.ts'); }); @@ -107,6 +110,9 @@ test('restored native products are rebuilt before caching and icon patching', () ); const saveIndex = steps.findIndex((step) => step.name === 'Save Apple runner build cache'); const patchIndex = steps.findIndex((step) => step.name === 'Patch XCTest runner icon'); + const republishIndex = steps.findIndex( + (step) => step.name === 'Re-publish Apple runner cache metadata', + ); expect(buildIndex).toBeGreaterThan(restoreIndex); expect(steps[buildIndex]?.if).toBeUndefined(); expect(steps[buildIndex]?.env?.AGENT_DEVICE_XCUITEST_SKIP_ICON_PATCH).toBe('1'); @@ -115,6 +121,11 @@ test('restored native products are rebuilt before caching and icon patching', () expect(patchIndex).toBeGreaterThan(saveIndex); expect(steps[patchIndex]?.if).toBeUndefined(); expect(steps[patchIndex]?.run).toContain('scripts/patch-xcuitest-runner-icon.ts'); + // The cache is saved unpatched, so the manifest that ships inside it certifies unpatched + // bytes. The tree that actually runs is republished after the patch rewrote those bytes. + expect(republishIndex).toBeGreaterThan(patchIndex); + expect(steps[republishIndex]?.run).toContain('scripts/write-xcuitest-cache-metadata.ts'); + expect(steps[republishIndex]?.run).toContain('agent-device-build-for-testing.log'); expect(fs.readFileSync(path.join(repoRoot, 'scripts/build-xcuitest-apple.sh'), 'utf8')).toContain( 'if ! is_truthy "${AGENT_DEVICE_XCUITEST_SKIP_ICON_PATCH:-}"; then', ); diff --git a/scripts/build-xcuitest-apple.sh b/scripts/build-xcuitest-apple.sh index 6d93bcc0fd..78887ddd7d 100644 --- a/scripts/build-xcuitest-apple.sh +++ b/scripts/build-xcuitest-apple.sh @@ -4,7 +4,6 @@ set -eu PLATFORM="${AGENT_DEVICE_XCUITEST_PLATFORM:-}" PROJECT_PATH="apple/runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj" SCHEME="AgentDeviceRunner" -DEFAULT_IOS_RUNNER_APP_BUNDLE_ID="com.callstack.agentdevice.runner" if [ -z "$PLATFORM" ]; then echo "AGENT_DEVICE_XCUITEST_PLATFORM is required (ios, macos, tvos, visionos)" >&2 @@ -121,50 +120,31 @@ resolve_clean_path() { DESTINATION="${AGENT_DEVICE_XCUITEST_DESTINATION:-$(resolve_default_destination)}" DERIVED_PATH="${AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH:-$(resolve_default_derived_path)}" CLEAN_PATH="$(resolve_clean_path)" -RUNNER_APP_BUNDLE_ID="${AGENT_DEVICE_IOS_BUNDLE_ID:-${AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID:-$DEFAULT_IOS_RUNNER_APP_BUNDLE_ID}}" -RUNNER_TEST_BUNDLE_ID="${AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID:-$RUNNER_APP_BUNDLE_ID.uitests}" -SIGNING_BUILD_SETTINGS="" - -if [ "$PLATFORM" = "macos" ]; then - SIGNING_BUILD_SETTINGS="CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY= DEVELOPMENT_TEAM=" -fi if is_truthy "${AGENT_DEVICE_IOS_CLEAN_DERIVED:-}"; then rm -rf "$CLEAN_PATH" fi -SWIFT_FLAGS='$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY' -if is_truthy "${AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS:-}"; then - SWIFT_FLAGS="$SWIFT_FLAGS -D AGENT_DEVICE_RUNNER_UNIT_TESTS" -fi - -# Optional arch override. A generic simulator destination leaves the active arch -# undefined; Xcode versions differ on the default (26.6 picks x86_64, which runs -# under Rosetta on arm64 hosts). Set AGENT_DEVICE_XCUITEST_ARCHS=arm64 to pin it. -ARCH_BUILD_SETTINGS="" -if [ -n "${AGENT_DEVICE_XCUITEST_ARCHS:-}" ]; then - ARCH_BUILD_SETTINGS="ARCHS=$AGENT_DEVICE_XCUITEST_ARCHS" -fi +# The compiler recipe and the cache identity come from one owner: this build passes exactly the +# build settings scripts/write-xcuitest-cache-metadata.ts records, so a manifest can never +# certify products compiled under settings the cache key does not name. +BUILD_SETTINGS_FILE="$DERIVED_PATH/Logs/agent-device-build-settings.txt" +mkdir -p "$DERIVED_PATH/Logs" +node --experimental-strip-types scripts/xcuitest-build-settings.ts "$PLATFORM" "$DESTINATION" \ + > "$BUILD_SETTINGS_FILE" build_for_testing() { + set -- + while IFS= read -r build_setting; do + [ -n "$build_setting" ] || continue + set -- "$@" "$build_setting" + done < "$BUILD_SETTINGS_FILE" node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts xcodebuild build-for-testing \ -project "$PROJECT_PATH" \ -scheme "$SCHEME" \ -destination "$DESTINATION" \ -derivedDataPath "$DERIVED_PATH" \ - AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID="$RUNNER_APP_BUNDLE_ID" \ - AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID="$RUNNER_TEST_BUNDLE_ID" \ - COMPILER_INDEX_STORE_ENABLE=NO \ - ENABLE_CODE_COVERAGE=NO \ - ONLY_ACTIVE_ARCH=YES \ - ENABLE_PREVIEWS=NO \ - ENABLE_DEBUG_DYLIB=NO \ - -IDEPackageSupportDisableManifestSandbox=1 \ - -IDEPackageSupportDisablePluginExecutionSandbox=1 \ - ENABLE_USER_SCRIPT_SANDBOXING=NO \ - OTHER_SWIFT_FLAGS="$SWIFT_FLAGS" \ - $ARCH_BUILD_SETTINGS \ - $SIGNING_BUILD_SETTINGS + "$@" } # The isolation scan reads the compiler diagnostics in the build log, and an incremental build @@ -199,4 +179,4 @@ fi if ! is_truthy "${AGENT_DEVICE_XCUITEST_SKIP_ICON_PATCH:-}"; then node --experimental-strip-types scripts/patch-xcuitest-runner-icon.ts "$DERIVED_PATH" fi -node scripts/write-xcuitest-cache-metadata.mjs "$PLATFORM" "$DERIVED_PATH" "$DESTINATION" +node --experimental-strip-types scripts/write-xcuitest-cache-metadata.ts "$PLATFORM" "$DERIVED_PATH" "$DESTINATION" "$BUILD_LOG" diff --git a/scripts/write-xcuitest-cache-metadata.mjs b/scripts/write-xcuitest-cache-metadata.mjs deleted file mode 100644 index 18c94ec693..0000000000 --- a/scripts/write-xcuitest-cache-metadata.mjs +++ /dev/null @@ -1,527 +0,0 @@ -#!/usr/bin/env node -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import { execFileSync } from 'node:child_process'; -import { pathToFileURL } from 'node:url'; - -let platform = ''; -let derivedPath = ''; -let destination = ''; -let projectRoot = ''; -let metadataPath = ''; - -const USAGE = - 'Usage: write-xcuitest-cache-metadata.mjs '; - -const DEFAULT_IOS_RUNNER_APP_BUNDLE_ID = 'com.callstack.agentdevice.runner'; -const RUNNER_SOURCE_IGNORED_DIR_NAMES = new Set(['.build', '.swiftpm', 'xcuserdata']); -const SNAPSHOT_PRESENTATION_SOURCE_IGNORED_DIR_NAMES = new Set([ - '.build', - '.swiftpm', - 'SnapshotPresentationConformance', - 'Tests', - 'xcuserdata', -]); - -function isTruthy(value) { - return ['1', 'true', 'TRUE', 'yes', 'YES', 'on', 'ON'].includes(String(value ?? '')); -} - -function readPackageVersion() { - try { - const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); - return typeof pkg.version === 'string' ? pkg.version : '0.0.0'; - } catch { - return '0.0.0'; - } -} - -function normalizeBundleId(value) { - return typeof value === 'string' ? value.trim() : ''; -} - -function resolveRunnerAppBundleId() { - return ( - normalizeBundleId(process.env.AGENT_DEVICE_IOS_BUNDLE_ID) || - normalizeBundleId(process.env.AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID) || - DEFAULT_IOS_RUNNER_APP_BUNDLE_ID - ); -} - -function resolveRunnerTestBundleId() { - return ( - normalizeBundleId(process.env.AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID) || - `${resolveRunnerAppBundleId()}.uitests` - ); -} - -function computeRunnerSourceFingerprint() { - const sourceRoots = [ - { - path: path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner'), - ignoredDirectoryNames: RUNNER_SOURCE_IGNORED_DIR_NAMES, - }, - { - path: path.join(projectRoot, 'apple', 'snapshot-presentation'), - ignoredDirectoryNames: SNAPSHOT_PRESENTATION_SOURCE_IGNORED_DIR_NAMES, - }, - ]; - const files = collectRunnerSourceFiles(sourceRoots); - const hash = crypto.createHash('sha256'); - for (const file of files) { - hash.update(path.relative(projectRoot, file)); - hash.update('\0'); - hash.update(fs.readFileSync(file)); - hash.update('\0'); - } - return hash.digest('hex'); -} - -function collectRunnerSourceFiles(roots) { - const files = []; - for (const { path: root, ignoredDirectoryNames } of roots) { - if (!fs.existsSync(root)) { - continue; - } - const stack = [root]; - while (stack.length > 0) { - const current = stack.pop(); - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const fullPath = path.join(current, entry.name); - if (entry.isDirectory()) { - if (ignoredDirectoryNames.has(entry.name)) continue; - stack.push(fullPath); - continue; - } - if (entry.isFile() && isRunnerSourceFile(entry.name, fullPath)) { - files.push(fullPath); - } - } - } - } - return [...new Set(files)].sort((a, b) => a.localeCompare(b)); -} - -function isRunnerSourceFile(fileName, filePath) { - if (fileName === 'project.pbxproj') { - return filePath.includes(`${path.sep}.xcodeproj${path.sep}`); - } - return [ - '.jpg', - '.json', - '.png', - '.swift', - '.m', - '.h', - '.plist', - '.entitlements', - '.xctestplan', - '.xcconfig', - '.storyboard', - '.xib', - ].includes(path.extname(fileName)); -} - -function resolvePlatformName() { - if (platform === 'ios') return 'iOS'; - if (platform === 'tvos') return 'tvOS'; - if (platform === 'macos') return 'macOS'; - if (platform === 'visionos') return 'visionOS'; - throw new Error(`Unsupported platform: ${platform}`); -} - -function resolveDeviceKind() { - if (platform === 'macos') return 'device'; - return destination.includes('Simulator') ? 'simulator' : 'device'; -} - -function resolveTarget() { - if (platform === 'macos') return 'desktop'; - if (platform === 'tvos') return 'tv'; - return 'mobile'; -} - -function resolveMacRunnerArch() { - return process.arch === 'arm64' ? 'arm64' : 'x86_64'; -} - -function resolveBuildDestinationFamily() { - const platformName = resolvePlatformName(); - if (platformName === 'macOS') { - return `platform=macOS,arch=${resolveMacRunnerArch()}`; - } - if (resolveDeviceKind() === 'simulator') { - return `generic/platform=${platformName} Simulator`; - } - return `generic/platform=${platformName}`; -} - -function resolveRunnerSdkName() { - const platformName = resolvePlatformName(); - if (platformName === 'macOS') return 'macosx'; - if (platformName === 'tvOS') { - return resolveDeviceKind() === 'simulator' ? 'appletvsimulator' : 'appletvos'; - } - if (platformName === 'visionOS') { - return resolveDeviceKind() === 'simulator' ? 'xrsimulator' : 'xros'; - } - return resolveDeviceKind() === 'simulator' ? 'iphonesimulator' : 'iphoneos'; -} - -function runAppleToolFingerprintCommand(command, args) { - const probe = [command, ...args].join(' '); - let output; - try { - output = execFileSync(command, args, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - timeout: 5000, - maxBuffer: 128 * 1024, - }).trim(); - } catch (error) { - throw new Error(`Apple toolchain probe failed: ${probe} (${error?.message ?? error})`); - } - if (!output) { - throw new Error(`Apple toolchain probe produced no output: ${probe}`); - } - return output; -} - -function parseXcodeVersionOutput(output) { - const version = output.match(/^Xcode\s+(.+)$/m)?.[1]?.trim(); - const buildVersion = output.match(/^Build version\s+(.+)$/m)?.[1]?.trim(); - if (!version || !buildVersion) { - throw new Error('Apple toolchain probe produced unrecognized output: xcodebuild -version'); - } - return { version, buildVersion }; -} - -function resolveRunnerToolchainFingerprint() { - const xcode = parseXcodeVersionOutput(runAppleToolFingerprintCommand('xcodebuild', ['-version'])); - const sdkName = resolveRunnerSdkName(); - return { - xcodeVersion: xcode.version, - xcodeBuildVersion: xcode.buildVersion, - sdkName, - sdkVersion: runAppleToolFingerprintCommand('xcrun', ['--sdk', sdkName, '--show-sdk-version']), - sdkBuildVersion: runAppleToolFingerprintCommand('xcrun', [ - '--sdk', - sdkName, - '--show-sdk-build-version', - ]), - }; -} - -function resolveSigningBuildSettings() { - if (platform !== 'macos') { - return []; - } - return [ - 'CODE_SIGNING_ALLOWED=NO', - 'CODE_SIGNING_REQUIRED=NO', - 'CODE_SIGN_IDENTITY=', - 'DEVELOPMENT_TEAM=', - ]; -} - -function resolveSandboxBuildArgs() { - const swiftFlags = isTruthy(process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS) - ? '$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS' - : '$(inherited) -disable-sandbox'; - return [ - '-IDEPackageSupportDisableManifestSandbox=1', - '-IDEPackageSupportDisablePluginExecutionSandbox=1', - 'ENABLE_USER_SCRIPT_SANDBOXING=NO', - `OTHER_SWIFT_FLAGS=${swiftFlags}`, - ]; -} - -export function writeXcuitestCacheMetadata(args = process.argv.slice(2), cwd = process.cwd()) { - const [nextPlatform, nextDerivedPath, nextDestination] = args; - if (!nextPlatform || !nextDerivedPath || !nextDestination) { - throw new Error(USAGE); - } - - platform = nextPlatform; - derivedPath = nextDerivedPath; - destination = nextDestination; - projectRoot = cwd; - metadataPath = path.join(derivedPath, '.agent-device-runner-cache.json'); - - const appBundleId = resolveRunnerAppBundleId(); - const testBundleId = resolveRunnerTestBundleId(); - const metadata = { - schemaVersion: 2, - packageVersion: readPackageVersion(), - runnerSourceFingerprint: computeRunnerSourceFingerprint(), - ...resolveRunnerToolchainFingerprint(), - platformName: resolvePlatformName(), - deviceKind: resolveDeviceKind(), - target: resolveTarget(), - buildDestinationFamily: resolveBuildDestinationFamily(), - runnerBundleBuildSettings: [ - `AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=${appBundleId}`, - `AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=${testBundleId}`, - ], - runnerSigningBuildSettings: resolveSigningBuildSettings(), - runnerPerformanceBuildSettings: [ - 'COMPILER_INDEX_STORE_ENABLE=NO', - 'ENABLE_CODE_COVERAGE=NO', - 'ONLY_ACTIVE_ARCH=YES', - 'ENABLE_PREVIEWS=NO', - 'ENABLE_DEBUG_DYLIB=NO', - ], - runnerSandboxBuildArgs: resolveSandboxBuildArgs(), - }; - - const artifacts = resolveRunnerCacheArtifacts(); - if (artifacts) { - metadata.artifacts = artifacts; - } - - fs.mkdirSync(path.dirname(metadataPath), { recursive: true }); - fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); - return metadata; -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - try { - writeXcuitestCacheMetadata(); - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); - } -} - -function resolveRunnerCacheArtifacts() { - const xctestrunPath = findXctestrun(derivedPath); - if (!xctestrunPath) return null; - const productPaths = resolveExistingXctestrunProductPaths(xctestrunPath); - if (!productPaths || productPaths.length === 0) return null; - const xctestrunMtimeMs = readFileMtimeMs(xctestrunPath); - const xctestrunSize = readFileSize(xctestrunPath); - if (xctestrunMtimeMs === null || xctestrunSize === null) return null; - const productArtifacts = []; - for (const productPath of productPaths) { - const mtimeMs = readFileMtimeMs(productPath); - const size = readFileSize(productPath); - if (mtimeMs === null || size === null) return null; - productArtifacts.push({ path: productPath, mtimeMs, size }); - } - return { xctestrunPath, xctestrunMtimeMs, xctestrunSize, productPaths: productArtifacts }; -} - -function findXctestrun(root) { - if (!fs.existsSync(root)) return null; - const candidates = []; - const stack = [root]; - while (stack.length > 0) { - const current = stack.pop(); - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const fullPath = path.join(current, entry.name); - if (entry.isDirectory()) { - stack.push(fullPath); - continue; - } - if (!entry.isFile() || !entry.name.endsWith('.xctestrun')) { - continue; - } - try { - candidates.push({ path: fullPath, mtimeMs: fs.statSync(fullPath).mtimeMs }); - } catch {} - } - } - if (candidates.length === 0) return null; - candidates.sort((left, right) => { - const scoreDiff = scoreXctestrunCandidate(right.path) - scoreXctestrunCandidate(left.path); - return scoreDiff || right.mtimeMs - left.mtimeMs || left.path.localeCompare(right.path); - }); - return candidates[0]?.path ?? null; -} - -function scoreXctestrunCandidate(candidatePath) { - const basename = path.basename(candidatePath); - let score = 0; - if (basename.includes('.env.')) score -= 50; - if (platform === 'ios') { - score += destination.includes('Simulator') - ? basename.includes('iphonesimulator') - ? 100 - : 0 - : basename.includes('iphoneos') - ? 100 - : 0; - } else if (platform === 'tvos') { - score += destination.includes('Simulator') - ? basename.includes('appletvsimulator') - ? 100 - : 0 - : basename.includes('appletvos') - ? 100 - : 0; - } else if (platform === 'macos') { - score += - basename.includes('macos') || candidatePath.includes(`${path.sep}macos${path.sep}`) ? 100 : 0; - } else if (platform === 'visionos') { - score += destination.includes('Simulator') - ? basename.includes('xrsimulator') - ? 100 - : 0 - : basename.includes('xros') - ? 100 - : 0; - } - return score; -} - -function resolveExistingXctestrunProductPaths(xctestrunPath) { - const values = resolveXctestrunProductReferences(xctestrunPath); - if (!values || values.length === 0) return null; - const testRoot = path.dirname(xctestrunPath); - const resolvedPaths = new Set(); - const products = collectResolvedTestHostProducts(values, testRoot); - - for (const resolvedPath of products.testRootPaths) { - if (!fs.existsSync(resolvedPath)) return null; - resolvedPaths.add(resolvedPath); - } - - for (const resolvedPath of resolveTestHostRelativePaths(products)) { - if (!resolvedPath) return null; - resolvedPaths.add(resolvedPath); - } - - return Array.from(resolvedPaths); -} - -function resolveXctestrunProductReferences(xctestrunPath) { - let parsed; - try { - parsed = JSON.parse( - execFileSync('plutil', ['-convert', 'json', '-o', '-', xctestrunPath], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }), - ); - } catch { - return null; - } - return resolveXctestrunProductReferencesFromJson(parsed); -} - -function resolveXctestrunProductReferencesFromJson(parsed) { - const values = new Set(); - for (const target of collectXctestrunProductReferenceTargets(parsed)) { - for (const value of collectXctestrunProductReferenceValuesFromTarget(target)) { - values.add(value); - } - } - return Array.from(values); -} - -function collectXctestrunProductReferenceTargets(parsed) { - return [parsed, ...collectConfiguredTestTargets(parsed), ...collectLegacyTestTargets(parsed)]; -} - -function collectConfiguredTestTargets(parsed) { - const testConfigurations = parsed?.TestConfigurations; - if (!Array.isArray(testConfigurations)) return []; - const targets = []; - for (const config of testConfigurations) { - if (!isRecord(config) || !Array.isArray(config.TestTargets)) { - continue; - } - targets.push(...config.TestTargets.filter(isRecord)); - } - return targets; -} - -function collectLegacyTestTargets(parsed) { - if (!isRecord(parsed)) return []; - return Object.values(parsed).filter((value) => isRecord(value) && 'TestBundlePath' in value); -} - -function isRecord(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function collectXctestrunProductReferenceValuesFromTarget(target) { - const values = new Set(); - const productReferenceKeys = new Set([ - 'ProductPaths', - 'DependentProductPaths', - 'TestHostPath', - 'TestBundlePath', - 'UITargetAppPath', - ]); - for (const [key, value] of Object.entries(target)) { - if (!productReferenceKeys.has(key)) continue; - if (typeof value === 'string') { - values.add(value); - continue; - } - if (!Array.isArray(value)) continue; - for (const item of value) { - if (typeof item === 'string') values.add(item); - } - } - return Array.from(values); -} - -function collectResolvedTestHostProducts(values, testRoot) { - const testRootPaths = []; - const hostRoots = new Set(); - const hostRelativePaths = []; - - for (const value of values) { - if (value.startsWith('__TESTHOST__/')) { - hostRelativePaths.push(value.slice('__TESTHOST__/'.length)); - continue; - } - if (!value.startsWith('__TESTROOT__/')) continue; - const relativePath = value.slice('__TESTROOT__/'.length); - testRootPaths.push(path.join(testRoot, relativePath)); - const appBundleRoot = extractAppBundleRoot(relativePath); - if (appBundleRoot) { - hostRoots.add(path.join(testRoot, appBundleRoot)); - } - } - - return { - testRootPaths, - hostRoots: Array.from(hostRoots), - hostRelativePaths, - }; -} - -function resolveTestHostRelativePaths(products) { - return products.hostRelativePaths.map((relativePath) => { - const resolvedHostRoot = products.hostRoots.find((hostRoot) => - fs.existsSync(path.join(hostRoot, relativePath)), - ); - return resolvedHostRoot ? path.join(resolvedHostRoot, relativePath) : null; - }); -} - -function extractAppBundleRoot(relativePath) { - const match = /\.app(?:\/|$)/.exec(relativePath); - if (!match || match.index === undefined) return null; - return relativePath.slice(0, match.index + '.app'.length); -} - -function readFileMtimeMs(filePath) { - try { - return Math.trunc(fs.statSync(filePath).mtimeMs); - } catch { - return null; - } -} - -function readFileSize(filePath) { - try { - return fs.statSync(filePath).size; - } catch { - return null; - } -} diff --git a/scripts/write-xcuitest-cache-metadata.ts b/scripts/write-xcuitest-cache-metadata.ts new file mode 100644 index 0000000000..118a205fd4 --- /dev/null +++ b/scripts/write-xcuitest-cache-metadata.ts @@ -0,0 +1,79 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { RunnerXctestrunCacheMetadata } from '@agent-device/platform-apple/runner/operations'; +import { + findRunnerXctestrun, + requireRunnerBuildSettingsMatchBuildLog, + resolveExistingRunnerProductPaths, + resolveExpectedRunnerCacheMetadata, + resolveRunnerScriptDevice, + writeRunnerCacheMetadataForArtifacts, +} from '@agent-device/platform-apple/runner/operations'; + +type XcuitestCachePlatform = 'ios' | 'macos' | 'tvos' | 'visionos'; + +const USAGE = + 'Usage: write-xcuitest-cache-metadata.ts '; + +function isScriptPlatform(value: string): value is XcuitestCachePlatform { + return value === 'ios' || value === 'macos' || value === 'tvos' || value === 'visionos'; +} + +/** + * Publishes the cache metadata for a `scripts/build-xcuitest-apple.sh` build. The identity half + * resolves through the same owner the daemon uses; the recorded recipe is then checked against + * what `xcodebuild` echoed in the build log, so a script that drifted from that identity cannot + * have its products certified. The artifact half digests the bytes on disk now, after the + * isolation scan succeeded, so a manifest exists exactly for builds that passed the scan. + */ +type WriterInvocation = { + device: ReturnType; + derivedPath: string; + buildLogPath: string; +}; + +// fallow-ignore-next-line complexity +function parseWriterInvocation(args: readonly string[]): WriterInvocation { + const [platform, derivedPath, destination, buildLogPath] = args; + if (!platform || !derivedPath || !destination || !buildLogPath) { + throw new Error(USAGE); + } + if (!isScriptPlatform(platform)) { + throw new Error(`Unsupported platform: ${platform}`); + } + return { + device: resolveRunnerScriptDevice(platform, destination), + derivedPath: path.resolve(derivedPath), + buildLogPath: path.resolve(buildLogPath), + }; +} + +async function writeXcuitestCacheMetadata( + args: readonly string[], + cwd: string, +): Promise { + const { device, derivedPath, buildLogPath } = parseWriterInvocation(args); + const metadata = resolveExpectedRunnerCacheMetadata(device, cwd); + requireRunnerBuildSettingsMatchBuildLog(metadata, buildLogPath); + + const xctestrunPath = findRunnerXctestrun(derivedPath, device); + if (!xctestrunPath) { + throw new Error(`No .xctestrun found under ${derivedPath}`); + } + const productPaths = await resolveExistingRunnerProductPaths(xctestrunPath); + if (!productPaths || productPaths.length === 0) { + throw new Error(`Runner products referenced by ${xctestrunPath} are missing`); + } + writeRunnerCacheMetadataForArtifacts(derivedPath, metadata, xctestrunPath, productPaths); + return metadata; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + await writeXcuitestCacheMetadata(process.argv.slice(2), process.cwd()); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/xcuitest-build-settings.ts b/scripts/xcuitest-build-settings.ts new file mode 100644 index 0000000000..1503bf9db6 --- /dev/null +++ b/scripts/xcuitest-build-settings.ts @@ -0,0 +1,42 @@ +// The build-recipe half of the Apple runner cache identity, in the words `xcodebuild` takes. +// `scripts/build-xcuitest-apple.sh` passes what this prints, and +// `scripts/write-xcuitest-cache-metadata.ts` records what the same resolvers return, so the +// compiler invocation and the cache identity cannot drift apart. +import { pathToFileURL } from 'node:url'; +import { + resolveRunnerArchBuildSettings, + resolveRunnerBundleBuildSettings, + resolveRunnerPerformanceBuildSettings, + resolveRunnerSandboxBuildArgs, + resolveRunnerScriptDevice, + resolveRunnerSigningBuildSettings, +} from '@agent-device/platform-apple/runner/operations'; + +const USAGE = 'Usage: xcuitest-build-settings.ts '; + +/** The `xcodebuild` build settings `scripts/build-xcuitest-apple.sh` is about to run with. */ +function resolveXcuitestBuildSettings( + platform: string, + destination: string, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const device = resolveRunnerScriptDevice(platform, destination); + return [ + ...resolveRunnerPerformanceBuildSettings(), + ...resolveRunnerArchBuildSettings(env), + ...resolveRunnerSandboxBuildArgs(), + ...resolveRunnerBundleBuildSettings(env), + ...resolveRunnerSigningBuildSettings(env, device.kind === 'device', device), + ]; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [platform, destination] = process.argv.slice(2); + if (!platform || !destination) { + console.error(USAGE); + process.exit(1); + } + for (const setting of resolveXcuitestBuildSettings(platform, destination)) { + process.stdout.write(`${setting}\n`); + } +} diff --git a/src/commands/schema/cli-help-topics.test.ts b/src/commands/schema/cli-help-topics.test.ts index 212235fd3e..fa942556d6 100644 --- a/src/commands/schema/cli-help-topics.test.ts +++ b/src/commands/schema/cli-help-topics.test.ts @@ -577,7 +577,7 @@ test('usageForCommand resolves validate help topic', async () => { assert.match(help, /Use the settled diff as evidence/); assert.match(help, /Close sessions and release leases/); assert.match(help, /exact key that includes the agent-device package and Xcode version/); - assert.match(help, /Avoid broad restore-key fallbacks/); + assert.match(help, /Runner reuse is authorized only by the cache metadata's content manifest/); }); test('usageForCommand resolves macos help topic', async () => { diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index c3fd791ac7..54967bf782 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -1039,7 +1039,7 @@ Required freshness gate before device verification: Before local Android verification, run pnpm build:android before pnpm clean:daemon so the bundled helpers match current source. For an Apple runner change, run pnpm build:xcuitest and avoid inherited retained runners from older source. Do not build the Apple runner for TypeScript-only changes. Use open --relaunch when startup state matters. Use a purpose-specific --session for multi-step validation. - CI may cache ~/.agent-device/apple-runner/derived with an exact key that includes the agent-device package and Xcode version. Avoid broad restore-key fallbacks; prepare ios-runner already recovers bad restored runner artifacts and one retryable non-connecting runner launch. + CI may cache ~/.agent-device/apple-runner/derived with an exact key that includes the agent-device package and Xcode version. Runner reuse is authorized only by the cache metadata's content manifest, so a restore that fails validation rebuilds; prepare ios-runner already recovers one retryable non-connecting runner launch. Loop: 1. Build or prepare the changed surface with the repo command that owns it. diff --git a/src/mcp/command-output-schemas.ts b/src/mcp/command-output-schemas.ts index 8909079f82..742a5835db 100644 --- a/src/mcp/command-output-schemas.ts +++ b/src/mcp/command-output-schemas.ts @@ -596,7 +596,7 @@ const BASE_COMMAND_OUTPUT_SCHEMAS = { kind: enumSchema(DEVICE_KINDS), durationMs: numberSchema(), runner: objectSchema({}, []), - cache: enumSchema(['exact', 'restore-key', 'miss', 'external']), + cache: enumSchema(['exact', 'miss', 'external']), artifact: enumSchema(['valid', 'rebuilt']), buildMs: numberSchema(), connectMs: numberSchema(), diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index ca8a246e10..73254c53ec 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -270,7 +270,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. -- Avoid broad `restore-keys` fallbacks for runner caches. Reusing runner artifacts across Agent Device or Xcode versions can restore stale `.xctestrun` products; `prepare ios-runner` already handles bad exact-cache artifacts and one retryable non-connecting runner launch. +- 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. - Runner build/start output is written to the session's `runner.log`. The top-level `daemon.log` is reserved for daemon lifecycle/startup issues. ## TV targets From 19226336c5f9593440b2e15ac646a9d76909b0f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 26 Sep 2026 08:13:36 +0200 Subject: [PATCH 2/7] chore(gates): serve the runner cache manifest from the scripts that publish it Declare the TypeScript build-settings emitter and cache-metadata writer as entries, replacing the ignore that hid the deleted .mjs writer from dead-code analysis. --- .fallowrc.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.fallowrc.json b/.fallowrc.json index 7c3e938e4d..3463937209 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -19,6 +19,8 @@ "src/daemon.ts", "packages/capture-kit/src/png-worker.ts", "scripts/patch-xcuitest-runner-icon.ts", + "scripts/write-xcuitest-cache-metadata.ts", + "scripts/xcuitest-build-settings.ts", "packages/capture-kit/src/ios-snapshot-engine/replay.ts", // #1596 regression fixture: runs as a real `node --experimental-strip-types` // subprocess (test/integration/daemon-replace-exit-flush.test.ts), so @@ -41,7 +43,6 @@ "scripts/di-seams/**", "scripts/maestro-conformance/corpus/**", "apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan", - "scripts/write-xcuitest-cache-metadata.mjs", "scripts/help-conformance-sample-outputs.d.mts", "scripts/help-conformance-runner-output.d.mts" ], From 10f4db885871f289da9debf8cc2e98e80721de0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 26 Sep 2026 12:22:55 +0200 Subject: [PATCH 3/7] fix(ios-runner): certify symlink containment through the filesystem The manifest trusted a lexical containment check, so a symlink whose target reached the cache root by `..` cancellation certified bytes the walk never digested, and a product root that was itself a link out of the cache was walked as though it were inside. Containment now resolves through the filesystem: a symlink is certified only when its target resolves inside one of the walked product roots, and a root that resolves outside the canonical cache root refuses certification outright. - report refusals on typed reasons (`root_escapes_cache`, `symlink_escapes_cache`, `root_unusable`) and surface them as a failed build instead of a silently uncertifiable publish; - validate the destination's platform clause by clause instead of scanning the whole destination for the substring `simulator`, and require the CI action's platform and destination inputs; - verify recorded build arguments against the invocation line in the build log and drop `.DS_Store` / `.xcuserstate` from the source fingerprint; - split the certification engine into `runner-artifact-manifest.ts` so the cache lifecycle module stays under the size ceiling. --- .../setup-apple-runner-build/action.yml | 10 +- .../platform-apple/src/core/runner-client.ts | 10 +- .../src/runner-operations-facade.ts | 2 + .../__tests__/apple-runner-platform.test.ts | 43 ++ .../runner-artifact-manifest.test.ts | 433 ++++++++++++ .../__tests__/runner-artifact-reuse.test.ts | 63 +- .../__tests__/runner-build-log.fixtures.ts | 24 + .../__tests__/runner-cache-metadata.test.ts | 68 ++ .../runner/__tests__/runner-cache.fixtures.ts | 59 ++ .../src/runner/__tests__/runner-cache.test.ts | 302 +------- .../runner/__tests__/runner-source.test.ts | 2 + .../__tests__/runner-xctestrun.fixtures.ts | 34 +- .../runner/__tests__/runner-xctestrun.test.ts | 55 +- .../src/runner/apple-runner-platform.ts | 51 +- .../src/runner/runner-artifact-manifest.ts | 655 ++++++++++++++++++ .../src/runner/runner-artifact.ts | 17 +- .../src/runner/runner-cache-metadata.ts | 74 +- .../platform-apple/src/runner/runner-cache.ts | 476 ++----------- .../src/runner/runner-source.ts | 22 +- scripts/__tests__/apple-ci-impact.test.ts | 25 +- scripts/write-xcuitest-cache-metadata.ts | 15 +- scripts/xcuitest-build-settings.ts | 4 + 22 files changed, 1659 insertions(+), 785 deletions(-) create mode 100644 packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts create mode 100644 packages/platform-apple/src/runner/__tests__/runner-build-log.fixtures.ts create mode 100644 packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts create mode 100644 packages/platform-apple/src/runner/runner-artifact-manifest.ts diff --git a/.github/actions/setup-apple-runner-build/action.yml b/.github/actions/setup-apple-runner-build/action.yml index 9c53d8f312..e637ad3bc2 100644 --- a/.github/actions/setup-apple-runner-build/action.yml +++ b/.github/actions/setup-apple-runner-build/action.yml @@ -16,13 +16,11 @@ inputs: description: 'Registered check id built through `pnpm gate`' required: true xcuitest-platform: - description: 'Optional AGENT_DEVICE_XCUITEST_PLATFORM value' - required: false - default: '' + description: 'AGENT_DEVICE_XCUITEST_PLATFORM value the runner build and its cache metadata are keyed by' + required: true xcuitest-destination: - description: 'Optional AGENT_DEVICE_XCUITEST_DESTINATION value' - required: false - default: '' + description: 'AGENT_DEVICE_XCUITEST_DESTINATION value, which must carry a platform= token' + required: true outputs: cache-hit: description: 'Whether an exact Apple runner build cache was restored' diff --git a/packages/platform-apple/src/core/runner-client.ts b/packages/platform-apple/src/core/runner-client.ts index 41585fa780..5aff792e8d 100644 --- a/packages/platform-apple/src/core/runner-client.ts +++ b/packages/platform-apple/src/core/runner-client.ts @@ -49,5 +49,11 @@ export { resolveRunnerSandboxBuildArgs, resolveRunnerSigningBuildSettings, } from '../runner/runner-cache-metadata.ts'; -export { resolveRunnerScriptDevice } from '../runner/apple-runner-platform.ts'; -export { writeRunnerCacheMetadataForArtifacts } from '../runner/runner-cache.ts'; +export { + isRunnerXcuitestScriptPlatform, + resolveRunnerScriptDevice, +} from '../runner/apple-runner-platform.ts'; +export { + requireCertifiedRunnerCacheArtifacts, + writeRunnerCacheMetadataForArtifacts, +} from '../runner/runner-cache.ts'; diff --git a/packages/platform-apple/src/runner-operations-facade.ts b/packages/platform-apple/src/runner-operations-facade.ts index 10277ef81d..eb5d1f999f 100644 --- a/packages/platform-apple/src/runner-operations-facade.ts +++ b/packages/platform-apple/src/runner-operations-facade.ts @@ -18,8 +18,10 @@ export { resolveRunnerBundleBuildSettings, resolveRunnerPerformanceBuildSettings, resolveRunnerSandboxBuildArgs, + isRunnerXcuitestScriptPlatform, resolveRunnerScriptDevice, resolveRunnerSigningBuildSettings, + requireCertifiedRunnerCacheArtifacts, requireRunnerBuildSettingsMatchBuildLog, runAppleRunnerCommand, stopAllIosRunnerSessions, diff --git a/packages/platform-apple/src/runner/__tests__/apple-runner-platform.test.ts b/packages/platform-apple/src/runner/__tests__/apple-runner-platform.test.ts index 537f300afa..de95f834c1 100644 --- a/packages/platform-apple/src/runner/__tests__/apple-runner-platform.test.ts +++ b/packages/platform-apple/src/runner/__tests__/apple-runner-platform.test.ts @@ -1,7 +1,9 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { + isRunnerXcuitestScriptPlatform, resolveRunnerDestination, + resolveRunnerScriptDevice, resolveRunnerHandoffTarget, resolveRunnerPlatformName, resolveRunnerSdkName, @@ -168,6 +170,47 @@ test('the usbmux-only xctest backend is refused while coredevice is named explic }); }); +test('resolveRunnerScriptDevice reads the simulator kind from the destination platform token', () => { + const udid = '5AF10197-87C1-4799-835E-3C6CBF9F3163'; + + assert.equal( + resolveRunnerScriptDevice('ios', `platform=iOS Simulator,id=${udid}`).kind, + 'simulator', + ); + // xcodebuild matches the platform token case-insensitively, so a lowercase spelling still + // builds the simulator SDK and must not be certified as a physical-device runner. + assert.equal( + resolveRunnerScriptDevice('ios', `platform=iOS simulator,id=${udid}`).kind, + 'simulator', + ); + assert.equal( + resolveRunnerScriptDevice('ios', 'generic/platform=iOS Simulator').kind, + 'simulator', + ); + assert.equal(resolveRunnerScriptDevice('ios', 'generic/platform=iOS').kind, 'device'); + assert.equal(resolveRunnerScriptDevice('tvos', 'platform=tvOS Simulator,id=x').kind, 'simulator'); +}); + +test('resolveRunnerScriptDevice records a macOS build as the host device', () => { + assert.equal(resolveRunnerScriptDevice('macos', 'platform=macOS,arch=arm64').kind, 'device'); + assert.equal(resolveRunnerScriptDevice('macos', 'platform=macOS,arch=arm64').target, 'desktop'); +}); + +test('resolveRunnerScriptDevice refuses a destination that leaves the SDK to the scheme', () => { + assert.throws( + () => resolveRunnerScriptDevice('ios', 'id=5AF10197-87C1-4799-835E-3C6CBF9F3163'), + /must name its platform/, + ); +}); + +test('isRunnerXcuitestScriptPlatform accepts only the platforms the build script knows', () => { + assert.equal(isRunnerXcuitestScriptPlatform('ios'), true); + assert.equal(isRunnerXcuitestScriptPlatform('visionos'), true); + assert.equal(isRunnerXcuitestScriptPlatform('watchos'), false); + assert.equal(isRunnerXcuitestScriptPlatform('iOS'), false); + assert.equal(isRunnerXcuitestScriptPlatform(''), false); +}); + test('a non-Apple target is refused instead of defaulting into the physical lane', () => { assert.deepEqual( resolveRunnerHandoffTarget({ diff --git a/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts b/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts new file mode 100644 index 0000000000..cc6574774e --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts @@ -0,0 +1,433 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { onTestFinished, test } from 'vitest'; +import { + evaluateExistingXctestrun, + resolveRunnerCacheMetadataPath, + writeRunnerCacheMetadata, + writeRunnerCacheMetadataForArtifacts, +} from '../runner-cache.ts'; +import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; +import { stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; +import { + EXECUTABLE_BYTES, + digest, + makeCachedRunnerBuild, + mismatchOf, + publishedXctestrun, +} from './runner-cache.fixtures.ts'; + +stubAppleToolchainProbes(); + +test('a manifest-certified build is reused', async () => { + const { derived, xctestrunPath, executablePath, expected } = makeCachedRunnerBuild(); + + const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); + + assert.equal(state.reason, 'reuse_ready'); + assert.equal(state.reason === 'reuse_ready' ? state.xctestrunPath : null, xctestrunPath); + assert.deepEqual(state.reason === 'reuse_ready' ? state.productPaths : null, [ + path.join(derived, 'Build', 'Products', 'Debug-iphonesimulator', 'Runner-Runner.app'), + ]); + assert.ok(fs.existsSync(executablePath)); +}); + +test('executable bytes rewritten with equal size and preserved stats break reuse', async () => { + const { derived, executablePath, expected } = makeCachedRunnerBuild(); + const stat = fs.statSync(executablePath); + + fs.writeFileSync(executablePath, Buffer.alloc(EXECUTABLE_BYTES.length, 9), { mode: 0o755 }); + fs.utimesSync(executablePath, stat.atime, stat.mtime); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.deepEqual(state.mismatch, { + path: path.relative(derived, executablePath), + reason: 'digest_mismatch', + }); +}); + +test('a size change breaks reuse', async () => { + const { derived, executablePath, expected } = makeCachedRunnerBuild(); + + fs.appendFileSync(executablePath, 'x'); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.equal(state.mismatch.reason, 'size_changed'); +}); + +test('a lost permission bit breaks reuse', async () => { + const { derived, executablePath, expected } = makeCachedRunnerBuild(); + + fs.chmodSync(executablePath, 0o644); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.equal(state.mismatch.reason, 'mode_changed'); +}); + +test('a missing executable breaks reuse', async () => { + const { derived, executablePath, expected } = makeCachedRunnerBuild(); + + fs.rmSync(executablePath); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.equal(state.mismatch.reason, 'missing'); +}); + +test('a certified executable replaced by a directory breaks reuse', async () => { + const { derived, executablePath, expected } = makeCachedRunnerBuild(); + + fs.rmSync(executablePath); + fs.mkdirSync(executablePath); + + // The manifest names a file at that path; the tree now holds no file there at all. + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.equal(state.mismatch.reason, 'missing'); +}); + +test('a file added under a certified product breaks reuse', async () => { + const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); + + fs.writeFileSync(path.join(runnerAppPath, 'injected.dylib'), 'injected'); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.equal(state.mismatch.reason, 'undeclared_entry'); +}); + +test('a certified symlink that starts escaping the cache root breaks reuse', async () => { + const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); + onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); + const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); + const linkPath = path.join(runnerAppPath, 'Frameworks'); + fs.mkdirSync(path.join(runnerAppPath, 'FrameworksInside'), { recursive: true }); + fs.symlinkSync('FrameworksInside', linkPath); + writeRunnerCacheMetadataForArtifacts(derived, expected, publishedXctestrun(derived), [ + runnerAppPath, + ]); + + fs.rmSync(linkPath); + fs.symlinkSync(path.join(outside, 'evil'), linkPath); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.equal(state.mismatch.reason, 'symlink_escapes_cache'); + assert.equal(state.mismatch.target, path.join(outside, 'evil')); +}); + +test('a symlink that changes target but stays inside the cache is a target change', async () => { + const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); + const linkPath = path.join(runnerAppPath, 'Frameworks'); + fs.mkdirSync(path.join(runnerAppPath, 'FrameworksInside'), { recursive: true }); + fs.mkdirSync(path.join(runnerAppPath, 'FrameworksMoved'), { recursive: true }); + fs.symlinkSync('FrameworksInside', linkPath); + writeRunnerCacheMetadataForArtifacts(derived, expected, publishedXctestrun(derived), [ + runnerAppPath, + ]); + + fs.rmSync(linkPath); + fs.symlinkSync('FrameworksMoved', linkPath); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.equal(state.mismatch.reason, 'symlink_target_changed'); +}); + +test('a product root that is a symlink out of the cache is never certified', async () => { + const derived = mkdtempForTestSync('agent-device-runner-cache-symlink-root-'); + const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); + onTestFinished(() => { + fs.rmSync(derived, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + }); + const realProduct = path.join(outside, 'Runner-Runner.app'); + fs.mkdirSync(realProduct, { recursive: true }); + fs.writeFileSync(path.join(realProduct, 'Runner'), EXECUTABLE_BYTES, { mode: 0o755 }); + const productsPath = path.join(derived, 'Build', 'Products'); + const linkedProduct = path.join(productsPath, 'Debug-iphonesimulator', 'Runner-Runner.app'); + fs.mkdirSync(path.dirname(linkedProduct), { recursive: true }); + fs.symlinkSync(realProduct, linkedProduct); + const xctestrunPath = path.join(productsPath, 'Runner_iphonesimulator26.2-arm64.xctestrun'); + fs.writeFileSync(xctestrunPath, 'xctestrun'); + const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + + writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [linkedProduct]); + + const published = JSON.parse( + fs.readFileSync(resolveRunnerCacheMetadataPath(derived), 'utf8'), + ) as { artifacts?: unknown }; + assert.equal(published.artifacts, undefined); +}); + +test('a symlinked product root that stays inside the cache is certified', async () => { + const derived = mkdtempForTestSync('agent-device-runner-cache-symlink-inside-'); + onTestFinished(() => fs.rmSync(derived, { recursive: true, force: true })); + const productsPath = path.join(derived, 'Build', 'Products'); + const stagedProduct = path.join(derived, 'staged', 'Runner-Runner.app'); + fs.mkdirSync(stagedProduct, { recursive: true }); + fs.writeFileSync(path.join(stagedProduct, 'Runner'), EXECUTABLE_BYTES, { mode: 0o755 }); + const linkedProduct = path.join(productsPath, 'Debug-iphonesimulator', 'Runner-Runner.app'); + fs.mkdirSync(path.dirname(linkedProduct), { recursive: true }); + fs.symlinkSync(path.join(derived, 'staged', 'Runner-Runner.app'), linkedProduct); + const xctestrunPath = path.join(productsPath, 'Runner_iphonesimulator26.2-arm64.xctestrun'); + fs.writeFileSync(xctestrunPath, 'xctestrun'); + const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + + writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [linkedProduct]); + + // Names describe where the bytes actually live, not the link that reached them, so re-pointing + // the in-cache symlink at another bundle cannot make reuse read an uncertified tree. + const published = JSON.parse( + fs.readFileSync(resolveRunnerCacheMetadataPath(derived), 'utf8'), + ) as { artifacts?: { entries: Array<{ path: string }> } }; + assert.deepEqual( + published.artifacts?.entries.map((entry) => entry.path), + [path.join('staged', 'Runner-Runner.app', 'Runner')], + ); + + fs.rmSync(linkedProduct); + const swapped = path.join(derived, 'staged', 'Other.app'); + fs.mkdirSync(swapped, { recursive: true }); + fs.writeFileSync(path.join(swapped, 'Runner'), Buffer.alloc(4096, 9), { mode: 0o755 }); + fs.symlinkSync(swapped, linkedProduct); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + // The walk follows the re-pointed root, and the bundle it now reaches was never certified. + assert.equal(state.mismatch.reason, 'undeclared_entry'); +}); + +test('a manifest that certifies an escaping symlink refuses reuse', async () => { + const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); + onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); + const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); + const escapingTarget = path.join(outside, 'Frameworks'); + fs.symlinkSync(escapingTarget, path.join(runnerAppPath, 'Frameworks')); + writeRunnerCacheMetadata(derived, { + ...expected, + artifacts: { + xctestrunPath: publishedXctestrun(derived), + xctestrunSize: fs.statSync(publishedXctestrun(derived)).size, + xctestrunDigest: digest(publishedXctestrun(derived)), + productPaths: [runnerAppPath], + entries: [ + { + path: path + .relative(derived, path.join(runnerAppPath, 'Frameworks')) + .replaceAll(path.sep, '/'), + symlink: escapingTarget, + }, + { + path: path + .relative(derived, path.join(runnerAppPath, 'Runner')) + .replaceAll(path.sep, '/'), + size: EXECUTABLE_BYTES.length, + mode: 0o755, + digest: digest(path.join(runnerAppPath, 'Runner')), + }, + ], + }, + }); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + assert.equal(state.mismatch.reason, 'symlink_escapes_cache'); +}); + +test('a build holding an escaping symlink publishes no manifest at all', async () => { + const { derived, runnerAppPath, xctestrunPath, expected } = makeCachedRunnerBuild(); + fs.symlinkSync('../../../../../outside', path.join(runnerAppPath, 'Frameworks')); + + writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]); + + const published = JSON.parse( + fs.readFileSync(resolveRunnerCacheMetadataPath(derived), 'utf8'), + ) as { artifacts?: unknown }; + assert.equal(published.artifacts, undefined); +}); + +test('an unreadable product subtree is refused and reported, not silently uncertified', async () => { + const { derived, runnerAppPath, expected, xctestrunPath } = makeCachedRunnerBuild(); + const sealed = path.join(runnerAppPath, 'Sealed.framework'); + fs.mkdirSync(sealed, { recursive: true }); + fs.writeFileSync(path.join(sealed, 'Binary'), 'binary'); + fs.chmodSync(sealed, 0o000); + onTestFinished(() => { + fs.chmodSync(sealed, 0o755); + }); + + const refusal = writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [ + runnerAppPath, + ]); + + assert.deepEqual(refusal, { + reason: 'root_unusable', + path: path.relative(derived, sealed), + }); + const published = JSON.parse( + fs.readFileSync(resolveRunnerCacheMetadataPath(derived), 'utf8'), + ) as { artifacts?: unknown }; + assert.equal(published.artifacts, undefined); +}); + +test('a manifest whose .xctestrun was replaced by a symlink refuses reuse', async () => { + const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); + onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); + const { derived, expected, runnerAppPath } = makeCachedRunnerBuild(); + const realXctestrun = path.join(outside, 'real.xctestrun'); + fs.writeFileSync(realXctestrun, fs.readFileSync(publishedXctestrun(derived))); + const linkedXctestrun = path.join(derived, 'Build', 'Products', 'linked.xctestrun'); + fs.rmSync(publishedXctestrun(derived)); + fs.symlinkSync(realXctestrun, linkedXctestrun); + + writeRunnerCacheMetadata(derived, { + ...expected, + artifacts: { + xctestrunPath: linkedXctestrun, + xctestrunSize: fs.statSync(realXctestrun).size, + xctestrunDigest: digest(realXctestrun), + productPaths: [runnerAppPath], + entries: [ + { + path: path + .relative(derived, path.join(runnerAppPath, 'Runner')) + .replaceAll(path.sep, '/'), + size: EXECUTABLE_BYTES.length, + mode: 0o755, + digest: digest(path.join(runnerAppPath, 'Runner')), + }, + ], + }, + }); + + const state = mismatchOf( + await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), + ); + + // The manifest names a regular file; a symlink is not one, so its bytes are never digested + // from wherever the link happens to point. + assert.equal(state.mismatch.reason, 'kind_changed'); + assert.equal(state.mismatch.path, linkedXctestrun); +}); + +test('a build whose .xctestrun is a symlink out of the cache publishes no manifest', async () => { + const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); + onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); + const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); + const staged = path.join(outside, 'real.xctestrun'); + fs.writeFileSync(staged, 'xctestrun'); + const linkedXctestrun = path.join(derived, 'Build', 'Products', 'linked.xctestrun'); + fs.rmSync(publishedXctestrun(derived)); + fs.symlinkSync(staged, linkedXctestrun); + + const refusal = writeRunnerCacheMetadataForArtifacts(derived, expected, linkedXctestrun, [ + runnerAppPath, + ]); + + assert.equal(refusal?.reason, 'root_escapes_cache'); + const published = JSON.parse( + fs.readFileSync(resolveRunnerCacheMetadataPath(derived), 'utf8'), + ) as { artifacts?: unknown }; + assert.equal(published.artifacts, undefined); +}); + +test('products without a content manifest are a miss, never a reuse', async () => { + const { derived, expected } = makeCachedRunnerBuild(); + + writeRunnerCacheMetadata(derived, expected); + + const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); + + assert.equal(state.reason, 'artifact_manifest_missing'); +}); + +test('a manifest naming paths outside the cache root is a miss', async () => { + const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); + onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); + const { derived, expected } = makeCachedRunnerBuild(); + const foreignXctestrun = path.join(outside, 'foreign.xctestrun'); + fs.writeFileSync(foreignXctestrun, 'xctestrun'); + writeRunnerCacheMetadata(derived, { + ...expected, + artifacts: { + xctestrunPath: foreignXctestrun, + xctestrunSize: 1, + xctestrunDigest: '0'.repeat(64), + productPaths: [path.join(outside, 'Runner-Runner.app')], + entries: [{ path: 'Runner', size: 1, mode: 0o755, digest: '0'.repeat(64) }], + }, + }); + + const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); + + assert.equal(state.reason, 'artifact_manifest_missing'); +}); + +test('a manifest written for a foreign cache root certifies nothing', async () => { + const { derived, expected } = makeCachedRunnerBuild(); + writeRunnerCacheMetadataForArtifacts( + derived, + expected, + path.join(derived, 'Build', 'Products', 'other.xctestrun'), + [path.join(derived, 'Build', 'Products', 'Debug-iphonesimulator', 'Runner-Runner.app')], + ); + + const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); + + assert.equal(state.reason, 'artifact_manifest_missing'); +}); + +test('a cache root reached through a symlinked ancestor is certified and reused', async () => { + // macOS reports TMPDIR under /var, which is a symlink to /private/var. A cache whose root is + // named through such a link must still certify, or every daemon on such a host rebuilds. + const root = mkdtempForTestSync('agent-device-runner-cache-ancestor-link-'); + onTestFinished(() => fs.rmSync(root, { recursive: true, force: true })); + const realRoot = path.join(root, 'real'); + const linkedRoot = path.join(root, 'link'); + fs.mkdirSync(realRoot, { recursive: true }); + fs.symlinkSync(realRoot, linkedRoot); + const derived = path.join(linkedRoot, 'derived'); + const productsPath = path.join(derived, 'Build', 'Products'); + const runnerAppPath = path.join(productsPath, 'Debug-iphonesimulator', 'Runner-Runner.app'); + fs.mkdirSync(runnerAppPath, { recursive: true }); + fs.writeFileSync(path.join(runnerAppPath, 'Runner'), EXECUTABLE_BYTES, { mode: 0o755 }); + const xctestrunPath = path.join(productsPath, 'Runner_iphonesimulator26.2-arm64.xctestrun'); + fs.writeFileSync(xctestrunPath, 'xctestrun'); + const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + + assert.equal( + writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]), + null, + ); + + const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); + + assert.equal(state.reason, 'reuse_ready'); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts b/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts index 05aa0c3917..7e192debc7 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts @@ -23,6 +23,7 @@ import { makeCachedRunnerXctestrun, makeProjectScratchDir, makeScratchDir, + RUNNER_FIXTURE_EXECUTABLE_BYTES, seedRunnerProductBundle, stripRunnerCacheArtifacts, withRunnerDerivedPathEnv, @@ -31,6 +32,16 @@ import { writeXctestrunFixture, } from './runner-xctestrun.fixtures.ts'; +/** Certifies a fixture tree the way the production writer would, and proves it worked. */ +function writeCertifiedRunnerMetadata(params: { + derivedPath: string; + device: DeviceInfo; + xctestrunPath: string; + productPaths: string[]; +}): void { + assert.equal(writeRunnerCacheMetadataWithArtifacts(params), null); +} + const mockRunCmdStreaming = vi.fn(); const { mockRepairMacOsRunnerProductsIfNeeded } = vi.hoisted(() => ({ mockRepairMacOsRunnerProductsIfNeeded: vi.fn(), @@ -93,7 +104,7 @@ test('ensureXctestrunArtifact reuses matching manifest artifacts from another pr projectRoot: '/tmp/other-agent-device-worktree', productRelativePaths: ['Runner.app'], }); - writeRunnerCacheMetadataWithArtifacts({ + writeCertifiedRunnerMetadata({ derivedPath, device: macOsDevice, xctestrunPath, @@ -115,12 +126,12 @@ test('ensureXctestrunArtifact rebuilds foreign artifacts when metadata does not const productPath = path.join(derivedPath, 'Runner.app'); const foreignXctestrunPath = path.join(derivedPath, 'foreign.xctestrun'); const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); - await fs.promises.mkdir(productPath, { recursive: true }); + await seedRunnerProductBundle(productPath); writeXctestrunFixture(foreignXctestrunPath, { projectRoot: '/tmp/other-agent-device-worktree', productRelativePaths: ['Runner.app'], }); - writeRunnerCacheMetadataWithArtifacts({ + writeCertifiedRunnerMetadata({ derivedPath, device: macOsDevice, xctestrunPath: foreignXctestrunPath, @@ -159,18 +170,27 @@ test('ensureXctestrunArtifact ignores manifest artifacts outside the cache root' const externalProductPath = path.join(externalDir, 'Runner.app'); const externalXctestrunPath = path.join(externalDir, 'external.xctestrun'); const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun'); - await fs.promises.mkdir(externalProductPath, { recursive: true }); + await seedRunnerProductBundle(externalProductPath); writeXctestrunFixture(externalXctestrunPath, { projectRoot, productRelativePaths: ['Runner.app'], }); await fs.promises.mkdir(derivedPath, { recursive: true }); - writeRunnerCacheMetadataWithArtifacts({ - derivedPath, - device: macOsDevice, - xctestrunPath: externalXctestrunPath, - productPaths: [externalProductPath], - }); + // A manifest naming paths outside its own cache root cannot have been written for this tree, + // so the reader declines it. The production writer refuses to publish one, hence the hand-off. + fs.writeFileSync( + resolveRunnerCacheMetadataPath(derivedPath), + JSON.stringify({ + ...resolveExpectedRunnerCacheMetadata(macOsDevice, projectRoot), + artifacts: { + xctestrunPath: externalXctestrunPath, + xctestrunSize: fs.statSync(externalXctestrunPath).size, + xctestrunDigest: '0'.repeat(64), + productPaths: [externalProductPath], + entries: [{ path: 'Runner', size: 1, mode: 0o755, digest: '1'.repeat(64) }], + }, + }), + ); withRunnerDerivedPathEnv(derivedPath); mockRunCmdStreaming.mockImplementation(async () => { @@ -348,7 +368,7 @@ test('ensureXctestrunArtifact prefers validated cache manifest over recursive sc new Date(now.getTime() + 5_000), new Date(now.getTime() + 5_000), ); - writeRunnerCacheMetadataWithArtifacts({ + writeCertifiedRunnerMetadata({ derivedPath, device: macOsDevice, xctestrunPath: manifestXctestrunPath, @@ -389,7 +409,7 @@ test('ensureXctestrunArtifact ignores a newer foreign xctestrun beside a certifi new Date(now.getTime() + 5_000), new Date(now.getTime() + 5_000), ); - writeRunnerCacheMetadataWithArtifacts({ + writeCertifiedRunnerMetadata({ derivedPath, device: macOsDevice, xctestrunPath: manifestXctestrunPath, @@ -415,18 +435,21 @@ test('ensureXctestrunArtifact discards and rebuilds a manifest whose bytes no lo projectRoot: repoRoot, productRelativePaths: ['Runner.app'], }); - writeRunnerCacheMetadataWithArtifacts({ + writeCertifiedRunnerMetadata({ derivedPath, device: macOsDevice, xctestrunPath: cachedXctestrunPath, productPaths: [productPath], }); - // The failure the old stat signature could not see: same size, same stats, new bytes. - const stat = fs.statSync(path.join(productPath, 'Runner')); - fs.writeFileSync(path.join(productPath, 'Runner'), Buffer.from('runner-executab\n'), { - mode: 0o755, - }); - fs.utimesSync(path.join(productPath, 'Runner'), stat.atime, stat.mtime); + // The failure the old stat signature could not see: same length, same mtime, new bytes, so + // only a digest can tell. A shorter replacement would trip `size_changed` first. + const executablePath = path.join(productPath, 'Runner'); + const tampered = Buffer.from(RUNNER_FIXTURE_EXECUTABLE_BYTES); + tampered[tampered.length - 2] = 'x'.charCodeAt(0); + assert.equal(tampered.length, RUNNER_FIXTURE_EXECUTABLE_BYTES.length); + const stat = fs.statSync(executablePath); + fs.writeFileSync(executablePath, tampered, { mode: 0o755 }); + fs.utimesSync(executablePath, stat.atime, stat.mtime); withRunnerDerivedPathEnv(derivedPath); mockRunCmdStreaming.mockImplementation(async () => { @@ -563,7 +586,7 @@ test('ensureXctestrunArtifact stress-recovers after a bad restored artifact', as projectRoot, productRelativePaths: ['Runner.app'], }); - writeRunnerCacheMetadataWithArtifacts({ + writeCertifiedRunnerMetadata({ derivedPath, device: macOsDevice, xctestrunPath: cachedXctestrunPath, diff --git a/packages/platform-apple/src/runner/__tests__/runner-build-log.fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-build-log.fixtures.ts new file mode 100644 index 0000000000..3f1b9136a2 --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-build-log.fixtures.ts @@ -0,0 +1,24 @@ +/** + * `xcodebuild`'s own echo of the recipe it was handed: build settings under their own header, and + * the arguments it does not treat as settings — the `-I` package-sandbox flags — on the invocation + * line. Built from the arguments the cache identity records, so a test that renders a log and a + * test that renders a drifted one differ only in the arguments they leave out. + */ +export function xcodebuildLogWithBuildArguments(args: readonly string[]): string { + const isSetting = (arg: string) => /^[A-Z][A-Z0-9_]*=/.test(arg); + const buildSettings = args.filter(isSetting).map((arg) => { + const index = arg.indexOf('='); + return ` ${arg.slice(0, index)} = ${arg.slice(index + 1)}`; + }); + const flags = args.filter((arg) => !isSetting(arg)); + return [ + 'Command line invocation:', + ` /usr/bin/xcodebuild build-for-testing ${flags.join(' ')}`.trimEnd(), + '', + 'Build settings from command line:', + ...buildSettings, + '', + 'Resolve Package Graph', + '', + ].join('\n'); +} diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts index a897aa7b5b..03f06dca58 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache-metadata.test.ts @@ -5,8 +5,11 @@ import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; import { IOS_DEVICE, IOS_SIMULATOR, MACOS_DEVICE } from './device-fixtures.ts'; import { appleRunnerTestHost } from '../test-host.ts'; import type { ExecOptions } from '@agent-device/host-kit/command'; +import fs from 'node:fs'; +import path from 'node:path'; import { createRunnerPhaseBudget, + requireRunnerBuildSettingsMatchBuildLog, diffComparableRunnerCacheMetadata, resolveRunnerBundleBuildSettings, resolveRunnerMaxConcurrentDestinationsFlag, @@ -17,6 +20,8 @@ import { } from '../runner-cache-metadata.ts'; import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../apple-runner-platform.ts'; import { appleToolchainProbeResult, stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; +import { xcodebuildLogWithBuildArguments } from './runner-build-log.fixtures.ts'; const runCmdSync = stubAppleToolchainProbes(); @@ -683,3 +688,66 @@ test('only a complete, parsed toolchain fingerprint is memoized', () => { [first.xcodeVersion, first.xcodeBuildVersion, first.sdkVersion, first.sdkBuildVersion], ); }); + +function recordedRunnerBuildArguments(): string[] { + const metadata = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + return [ + ...metadata.runnerBundleBuildSettings, + ...metadata.runnerSigningBuildSettings, + ...metadata.runnerPerformanceBuildSettings, + ...metadata.runnerArchBuildSettings, + ...metadata.runnerSandboxBuildArgs, + ]; +} + +function writeRunnerBuildLog(root: string, args: readonly string[]): string { + const logPath = path.join(root, 'agent-device-build-for-testing.log'); + fs.writeFileSync(logPath, xcodebuildLogWithBuildArguments(args)); + return logPath; +} + +test('a build log holding the recorded recipe certifies the build', () => { + const root = mkdtempForTestSync('runner-build-log-'); + const metadata = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + const logPath = writeRunnerBuildLog(root, recordedRunnerBuildArguments()); + + assert.doesNotThrow(() => requireRunnerBuildSettingsMatchBuildLog(metadata, logPath)); +}); + +test('a build log missing a recorded package-sandbox flag fails the check', () => { + const root = mkdtempForTestSync('runner-build-log-'); + const metadata = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + // `-I` flags are not build settings, so the settings block would stay complete without them. + const logPath = writeRunnerBuildLog( + root, + recordedRunnerBuildArguments().filter((arg) => !arg.startsWith('-IDEPackageSupport')), + ); + + assert.throws( + () => requireRunnerBuildSettingsMatchBuildLog(metadata, logPath), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.deepEqual( + (error.details as { differences: Array<{ key: string; expected: string }> }).differences + .map((difference) => difference.expected) + .sort(), + [ + '-IDEPackageSupportDisableManifestSandbox=1', + '-IDEPackageSupportDisablePluginExecutionSandbox=1', + ], + ); + return true; + }, + ); +}); + +test('an unreadable or setting-less build log fails the check', () => { + const root = mkdtempForTestSync('runner-build-log-'); + const metadata = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + + assert.throws( + () => + requireRunnerBuildSettingsMatchBuildLog(metadata, path.join(root, 'never-written-build.log')), + /did not use the settings its cache identity records/, + ); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts new file mode 100644 index 0000000000..717019515a --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { onTestFinished } from 'vitest'; +import { writeRunnerCacheMetadataForArtifacts } from '../runner-cache.ts'; +import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts'; +import type { ExistingXctestrunState, RunnerXctestrunCacheMetadata } from '../runner-cache.ts'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; + +/** + * A cache root and a content manifest for the tests that decide what a manifest certifies: the + * bytes must exist before a digest can name them, and the layout must be the one a build leaves. + */ + +export const EXECUTABLE_BYTES = Buffer.alloc(4096, 7); + +export type CachedRunnerBuild = { + derived: string; + xctestrunPath: string; + runnerAppPath: string; + executablePath: string; + expected: RunnerXctestrunCacheMetadata; +}; + +/** + * A cache root laid out the way a build leaves it: the `.xctestrun` under Build/Products, a + * product bundle with an executable, and a manifest certifying both. + */ +export function makeCachedRunnerBuild(): CachedRunnerBuild { + const derived = mkdtempForTestSync('agent-device-runner-cache-eval-'); + onTestFinished(() => fs.rmSync(derived, { recursive: true, force: true })); + const productsPath = path.join(derived, 'Build', 'Products'); + const runnerAppPath = path.join(productsPath, 'Debug-iphonesimulator', 'Runner-Runner.app'); + fs.mkdirSync(runnerAppPath, { recursive: true }); + const xctestrunPath = publishedXctestrun(derived); + fs.writeFileSync(xctestrunPath, 'xctestrun'); + const executablePath = path.join(runnerAppPath, 'Runner'); + fs.writeFileSync(executablePath, EXECUTABLE_BYTES, { mode: 0o755 }); + const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); + writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]); + return { derived, xctestrunPath, runnerAppPath, executablePath, expected }; +} + +export function publishedXctestrun(derived: string): string { + return path.join(derived, 'Build', 'Products', 'Runner_iphonesimulator26.2-arm64.xctestrun'); +} + +export function digest(filePath: string): string { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} + +export function mismatchOf( + state: ExistingXctestrunState, +): Extract { + assert.equal(state.reason, 'artifact_content_mismatch'); + return state; +} diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts index 2fac41e36a..24b0dc8ad3 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts @@ -1,72 +1,15 @@ import assert from 'node:assert/strict'; -import crypto from 'node:crypto'; import fs from 'node:fs'; -import path from 'node:path'; import { onTestFinished, test } from 'vitest'; -import { - evaluateExistingXctestrun, - resolveRunnerCacheMetadataPath, - writeRunnerCacheMetadata, - writeRunnerCacheMetadataForArtifacts, - type ExistingXctestrunState, - type RunnerXctestrunCacheMetadata, -} from '../runner-cache.ts'; +import { evaluateExistingXctestrun, writeRunnerCacheMetadata } from '../runner-cache.ts'; import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts'; import { IOS_SIMULATOR } from './device-fixtures.ts'; -import { mkdtempForTestSync } from './tmp-dir.ts'; import { stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; +import { restoreEnvVar } from './runner-xctestrun.fixtures.ts'; +import { makeCachedRunnerBuild } from './runner-cache.fixtures.ts'; stubAppleToolchainProbes(); - -const EXECUTABLE_BYTES = Buffer.alloc(4096, 7); - -type CachedRunnerBuild = { - derived: string; - xctestrunPath: string; - runnerAppPath: string; - executablePath: string; - expected: RunnerXctestrunCacheMetadata; -}; - -/** - * A cache root laid out the way a build leaves it: the `.xctestrun` under Build/Products, a - * product bundle with an executable, and a manifest certifying both. - */ -function makeCachedRunnerBuild(): CachedRunnerBuild { - const derived = mkdtempForTestSync('agent-device-runner-cache-eval-'); - onTestFinished(() => fs.rmSync(derived, { recursive: true, force: true })); - const productsPath = path.join(derived, 'Build', 'Products'); - const runnerAppPath = path.join(productsPath, 'Debug-iphonesimulator', 'Runner-Runner.app'); - fs.mkdirSync(runnerAppPath, { recursive: true }); - const xctestrunPath = path.join(productsPath, 'Runner_iphonesimulator26.2-arm64.xctestrun'); - fs.writeFileSync(xctestrunPath, 'xctestrun'); - const executablePath = path.join(runnerAppPath, 'Runner'); - fs.writeFileSync(executablePath, EXECUTABLE_BYTES, { mode: 0o755 }); - const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]); - return { derived, xctestrunPath, runnerAppPath, executablePath, expected }; -} - -function mismatchOf( - state: ExistingXctestrunState, -): Extract { - assert.equal(state.reason, 'artifact_content_mismatch'); - return state; -} - -test('a manifest-certified build is reused', async () => { - const { derived, xctestrunPath, executablePath, expected } = makeCachedRunnerBuild(); - - const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); - - assert.equal(state.reason, 'reuse_ready'); - assert.equal(state.reason === 'reuse_ready' ? state.xctestrunPath : null, xctestrunPath); - assert.deepEqual(state.reason === 'reuse_ready' ? state.productPaths : null, [ - path.join(derived, 'Build', 'Products', 'Debug-iphonesimulator', 'Runner-Runner.app'), - ]); - assert.ok(fs.existsSync(executablePath)); -}); - test('reuse ignores the non-comparable package version', async () => { const { derived, expected } = makeCachedRunnerBuild(); @@ -121,218 +64,27 @@ test('an architecture override changes the cache identity', () => { } }); -test('every runner build compiles the isolation canary', () => { - const swiftFlags = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR).runnerSandboxBuildArgs.find( - (arg) => arg.startsWith('OTHER_SWIFT_FLAGS='), - ); - - assert.equal( - swiftFlags, - 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY', - ); -}); - -test('executable bytes rewritten with equal size and preserved stats break reuse', async () => { - const { derived, executablePath, expected } = makeCachedRunnerBuild(); - const stat = fs.statSync(executablePath); - - fs.writeFileSync(executablePath, Buffer.alloc(EXECUTABLE_BYTES.length, 9), { mode: 0o755 }); - fs.utimesSync(executablePath, stat.atime, stat.mtime); - - const state = mismatchOf( - await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), - ); - - assert.deepEqual(state.mismatch, { - path: path.relative(derived, executablePath), - reason: 'digest_mismatch', - }); -}); - -test('a size change breaks reuse', async () => { - const { derived, executablePath, expected } = makeCachedRunnerBuild(); - - fs.appendFileSync(executablePath, 'x'); - - const state = mismatchOf( - await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), - ); - - assert.equal(state.mismatch.reason, 'size_changed'); -}); - -test('a lost permission bit breaks reuse', async () => { - const { derived, executablePath, expected } = makeCachedRunnerBuild(); - - fs.chmodSync(executablePath, 0o644); - - const state = mismatchOf( - await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), - ); - - assert.equal(state.mismatch.reason, 'mode_changed'); -}); - -test('a missing executable breaks reuse', async () => { - const { derived, executablePath, expected } = makeCachedRunnerBuild(); - - fs.rmSync(executablePath); - - const state = mismatchOf( - await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), - ); - - assert.equal(state.mismatch.reason, 'missing'); -}); - -test('a certified executable replaced by a directory breaks reuse', async () => { - const { derived, executablePath, expected } = makeCachedRunnerBuild(); - - fs.rmSync(executablePath); - fs.mkdirSync(executablePath); - - // The manifest names a file at that path; the tree now holds no file there at all. - const state = mismatchOf( - await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), - ); - - assert.equal(state.mismatch.reason, 'missing'); -}); - -test('a file added under a certified product breaks reuse', async () => { - const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); - - fs.writeFileSync(path.join(runnerAppPath, 'injected.dylib'), 'injected'); - - const state = mismatchOf( - await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), - ); - - assert.equal(state.mismatch.reason, 'undeclared_entry'); -}); - -test('a certified symlink that starts escaping the cache root breaks reuse', async () => { - const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); - onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); - const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); - const linkPath = path.join(runnerAppPath, 'Frameworks'); - fs.mkdirSync(path.join(runnerAppPath, 'FrameworksInside'), { recursive: true }); - fs.symlinkSync('FrameworksInside', linkPath); - writeRunnerCacheMetadataForArtifacts(derived, expected, publishedXctestrun(derived), [ - runnerAppPath, - ]); - - fs.rmSync(linkPath); - fs.symlinkSync(path.join(outside, 'evil'), linkPath); - - const state = mismatchOf( - await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), - ); - - assert.equal(state.mismatch.reason, 'symlink_target_changed'); -}); - -test('a manifest that certifies an escaping symlink refuses reuse', async () => { - const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); - onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); - const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); - const escapingTarget = path.join(outside, 'Frameworks'); - fs.symlinkSync(escapingTarget, path.join(runnerAppPath, 'Frameworks')); - writeRunnerCacheMetadata(derived, { - ...expected, - artifacts: { - xctestrunPath: publishedXctestrun(derived), - xctestrunSize: fs.statSync(publishedXctestrun(derived)).size, - xctestrunDigest: digest(publishedXctestrun(derived)), - productPaths: [runnerAppPath], - entries: [ - { - path: path - .relative(derived, path.join(runnerAppPath, 'Frameworks')) - .replaceAll(path.sep, '/'), - symlink: escapingTarget, - }, - { - path: path - .relative(derived, path.join(runnerAppPath, 'Runner')) - .replaceAll(path.sep, '/'), - size: EXECUTABLE_BYTES.length, - mode: 0o755, - digest: digest(path.join(runnerAppPath, 'Runner')), - }, - ], - }, - }); - - const state = mismatchOf( - await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }), - ); - - assert.equal(state.mismatch.reason, 'escaping_symlink'); -}); - -test('a build holding an escaping symlink publishes no manifest at all', async () => { - const { derived, runnerAppPath, xctestrunPath, expected } = makeCachedRunnerBuild(); - fs.symlinkSync('../../../../../outside', path.join(runnerAppPath, 'Frameworks')); - - writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]); - - const published = JSON.parse( - fs.readFileSync(resolveRunnerCacheMetadataPath(derived), 'utf8'), - ) as { artifacts?: unknown }; - assert.equal(published.artifacts, undefined); -}); - -test('products without a content manifest are a miss, never a reuse', async () => { - const { derived, expected } = makeCachedRunnerBuild(); - - writeRunnerCacheMetadata(derived, expected); - - const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); - - assert.equal(state.reason, 'artifact_manifest_missing'); -}); - -test('a manifest naming paths outside the cache root is a miss', async () => { - const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); - onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); - const { derived, expected } = makeCachedRunnerBuild(); - const foreignXctestrun = path.join(outside, 'foreign.xctestrun'); - fs.writeFileSync(foreignXctestrun, 'xctestrun'); - writeRunnerCacheMetadata(derived, { - ...expected, - artifacts: { - xctestrunPath: foreignXctestrun, - xctestrunSize: 1, - xctestrunDigest: '0'.repeat(64), - productPaths: [path.join(outside, 'Runner-Runner.app')], - entries: [{ path: 'Runner', size: 1, mode: 0o755, digest: '0'.repeat(64) }], - }, - }); - - const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); - - assert.equal(state.reason, 'artifact_manifest_missing'); -}); - -test('a manifest written for a foreign cache root certifies nothing', async () => { - const { derived, expected } = makeCachedRunnerBuild(); - writeRunnerCacheMetadataForArtifacts( - derived, - expected, - path.join(derived, 'Build', 'Products', 'other.xctestrun'), - [path.join(derived, 'Build', 'Products', 'Debug-iphonesimulator', 'Runner-Runner.app')], - ); - - const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); - - assert.equal(state.reason, 'artifact_manifest_missing'); +test('every runner build compiles the isolation canary, in both test variants', () => { + const resolveSwiftFlags = () => + resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR).runnerSandboxBuildArgs.find((arg) => + arg.startsWith('OTHER_SWIFT_FLAGS='), + ); + const previous = process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS; + try { + delete process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS; + assert.equal( + resolveSwiftFlags(), + 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY', + ); + + process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS = '1'; + // A build that skipped the canary would pass every other gate while the isolation scan + // silently had no positive control, so the unit-test variant must carry it too. + assert.equal( + resolveSwiftFlags(), + 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY -D AGENT_DEVICE_RUNNER_UNIT_TESTS', + ); + } finally { + restoreEnvVar('AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS', previous); + } }); - -function publishedXctestrun(derived: string): string { - return path.join(derived, 'Build', 'Products', 'Runner_iphonesimulator26.2-arm64.xctestrun'); -} - -function digest(filePath: string): string { - return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); -} diff --git a/packages/platform-apple/src/runner/__tests__/runner-source.test.ts b/packages/platform-apple/src/runner/__tests__/runner-source.test.ts index a9222f265c..8d93d4f0e0 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-source.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-source.test.ts @@ -170,6 +170,8 @@ test('computeRunnerSourceFingerprint ignores per-user Xcode state', () => { const before = computeRunnerSourceFingerprint(root); fs.writeFileSync(userScheme, `\n`); + fs.writeFileSync(path.join(projectPackage, '.DS_Store'), 'finder\n'); + fs.writeFileSync(path.join(projectPackage, 'Workspace.xcuserstate'), 'xcode-user-state\n'); assert.equal(computeRunnerSourceFingerprint(root), before); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts index de4b9afa40..d6293d392e 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts @@ -4,7 +4,7 @@ import { onTestFinished } from 'vitest'; import { fileURLToPath } from 'node:url'; import { mkdtempForTest } from './tmp-dir.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { writeRunnerCacheMetadataForArtifacts } from '../runner-cache.ts'; +import { writeRunnerCacheMetadataForArtifacts, type RunnerCacheRefusal } from '../runner-cache.ts'; import { resolveExpectedRunnerCacheMetadata } from '../runner-xctestrun.ts'; // Scratch trees and certified runner products shared by the tests that exercise @@ -43,8 +43,13 @@ export function writeXctestrunFixture( xctestrunPath: string, options: { projectRoot: string; productRelativePaths: string[] }, ): void { + // Paths reach this plist as text, and a checkout directory can carry an `&` or a `'` in its + // name. Unescaped, the plist would be malformed and the product-path reader would find nothing, + // so the case under test would silently exercise a different path. const entries = options.productRelativePaths - .map((relativePath) => ` __TESTROOT__/${relativePath}`) + .map( + (relativePath) => ` ${escapeXmlText(`__TESTROOT__/${relativePath}`)}`, + ) .join('\n'); fs.mkdirSync(path.dirname(xctestrunPath), { recursive: true }); fs.writeFileSync( @@ -54,7 +59,7 @@ export function writeXctestrunFixture( ProjectRootHint - ${options.projectRoot} + ${escapeXmlText(options.projectRoot)} ProductPaths ${entries} @@ -65,6 +70,15 @@ ${entries} ); } +function escapeXmlText(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + export function withRunnerDerivedPathEnv(derivedPath: string): void { const previousDerivedPath = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = derivedPath; @@ -100,23 +114,29 @@ export function stripRunnerCacheArtifacts( * A content manifest certifies bytes, so a fixture product needs some. Mirrors what a build * leaves behind: a bundle directory holding an executable. */ +/** The bytes a fixture product bundle holds, exported so a tamper can keep the same length. */ +export const RUNNER_FIXTURE_EXECUTABLE_BYTES = Buffer.from('runner-executable\n'); + export async function seedRunnerProductBundle(bundlePath: string): Promise { await fs.promises.mkdir(bundlePath, { recursive: true }); await fs.promises.writeFile( path.join(bundlePath, path.basename(bundlePath, '.app')), - Buffer.from('runner-executable\n'), + RUNNER_FIXTURE_EXECUTABLE_BYTES, { mode: 0o755 }, ); } -/** Publishes the metadata the production writer would, so a fixture tree is really certified. */ +/** + * Publishes the metadata the production writer would, so a fixture tree is really certified. + * Returns the refusal when the tree cannot be certified, which callers assert rather than ignore. + */ export function writeRunnerCacheMetadataWithArtifacts(params: { derivedPath: string; device: DeviceInfo; xctestrunPath: string; productPaths: string[]; -}): void { - writeRunnerCacheMetadataForArtifacts( +}): RunnerCacheRefusal | null { + return writeRunnerCacheMetadataForArtifacts( params.derivedPath, resolveExpectedRunnerCacheMetadata(params.device, REPO_ROOT_FOR_TEST), params.xctestrunPath, diff --git a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts index cdc50f898f..a10054ea65 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.test.ts @@ -11,6 +11,8 @@ import { buildRunnerSessionXctestrunPathCleanupPattern, buildRunnerSessionXctestrunSuffix, } from '../runner-artifact-env.ts'; +import { xcodebuildLogWithBuildArguments } from './runner-build-log.fixtures.ts'; +import { writeXctestrunFixture } from './runner-xctestrun.fixtures.ts'; import { appleRunnerTestHost } from '../test-host.ts'; import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command'; @@ -30,6 +32,7 @@ const mockRunCmdSync = vi.fn(); import type { DeviceInfo } from '@agent-device/kernel/device'; import { findXctestrun, scoreXctestrunCandidate } from '../runner-artifact.ts'; import { evaluateExistingXctestrun } from '../runner-cache.ts'; +import { resolveExistingXctestrunProductPaths } from '../runner-xctestrun-products.ts'; import type { RunnerXctestrunCacheArtifacts } from '../runner-cache-metadata.ts'; import { ensureXctestrunArtifact, @@ -221,7 +224,7 @@ test('the build script and the metadata writer publish one iOS simulator identit await withTempDir('runner-cache-metadata-', async (root) => { const project = seedRunnerBuildFixture(root); const buildSettings = runBuildSettings(project); - fs.writeFileSync(project.buildLogPath, xcodebuildLogWithBuildSettings(buildSettings)); + fs.writeFileSync(project.buildLogPath, xcodebuildLogWithBuildArguments(buildSettings)); const written = runScript(project.root, 'write-xcuitest-cache-metadata.ts', [ 'ios', @@ -274,13 +277,38 @@ test('the build script and the metadata writer publish one iOS simulator identit }); }, 120_000); +test('an .xctestrun fixture holding paths that need XML escaping names those products', async () => { + // CI checkouts and fork names can carry `&` or `'`. Written into the plist unescaped they make + // it malformed, and the reuse cases built on this fixture would silently exercise a rebuild + // instead of the path they name. + const root = mkdtempForTestSync('agent-device-xctestrun-escaping-'); + const projectRoot = path.join(root, "a & b's "); + const derivedPath = path.join(projectRoot, '.tmp', 'derived'); + const bundlePath = path.join( + derivedPath, + 'Build', + 'Products', + 'Debug-iphonesimulator', + "Runner & Co's.app", + ); + await fs.promises.mkdir(bundlePath, { recursive: true }); + await fs.promises.writeFile(path.join(bundlePath, 'Runner'), 'runner', { mode: 0o755 }); + const xctestrunPath = path.join(derivedPath, 'Build', 'Products', 'Runner.xctestrun'); + writeXctestrunFixture(xctestrunPath, { + projectRoot, + productRelativePaths: ["Debug-iphonesimulator/Runner & Co's.app"], + }); + + assert.deepEqual(await resolveExistingXctestrunProductPaths(xctestrunPath), [bundlePath]); +}); + test('the metadata writer refuses a build log whose recipe it did not record', async () => { await withTempDir('runner-cache-metadata-', async (root) => { const project = seedRunnerBuildFixture(root); const drifted = runBuildSettings(project).filter( (setting) => !setting.startsWith('ONLY_ACTIVE_ARCH='), ); - fs.writeFileSync(project.buildLogPath, xcodebuildLogWithBuildSettings(drifted)); + fs.writeFileSync(project.buildLogPath, xcodebuildLogWithBuildArguments(drifted)); const written = runScript(project.root, 'write-xcuitest-cache-metadata.ts', [ 'ios', @@ -447,29 +475,6 @@ function runScript( return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr }; } -/** - * `xcodebuild`'s own echo of the recipe it was handed: build settings under their own header, - * and the `-I` flags it does not treat as settings on the invocation line. - */ -function xcodebuildLogWithBuildSettings(settings: readonly string[]): string { - const isSetting = (setting: string) => /^[A-Z][A-Z0-9_]*=/.test(setting); - const buildSettings = settings.filter(isSetting).map((setting) => { - const index = setting.indexOf('='); - return ` ${setting.slice(0, index)} = ${setting.slice(index + 1)}`; - }); - const flags = settings.filter((setting) => !isSetting(setting)); - return [ - 'Command line invocation:', - ` /usr/bin/xcodebuild build-for-testing ${flags.join(' ')}`.trimEnd(), - '', - 'Build settings from command line:', - ...buildSettings, - '', - 'Resolve Package Graph', - '', - ].join('\n'); -} - function readRunnerCacheManifest(derivedPath: string): any { return JSON.parse( fs.readFileSync(path.join(derivedPath, '.agent-device-runner-cache.json'), 'utf8'), diff --git a/packages/platform-apple/src/runner/apple-runner-platform.ts b/packages/platform-apple/src/runner/apple-runner-platform.ts index bc259656d9..3a52ebe6a0 100644 --- a/packages/platform-apple/src/runner/apple-runner-platform.ts +++ b/packages/platform-apple/src/runner/apple-runner-platform.ts @@ -130,6 +130,12 @@ const RUNNER_PLATFORM_PROFILES: Record @@ -160,18 +166,57 @@ export function resolveRunnerScriptDevice( platform: RunnerXcuitestScriptPlatform, destination: string, ): DeviceInfo { - const kind: DeviceInfo['kind'] = - platform === 'macos' || !destination.includes('Simulator') ? 'device' : 'simulator'; return { platform: 'apple', id: `runner-script-${platform}`, name: `Apple runner build script (${platform})`, - kind, + kind: platform === 'macos' ? 'device' : resolveRunnerScriptDestinationKind(destination), target: RUNNER_SCRIPT_TARGET[platform], appleOs: RUNNER_SCRIPT_APPLE_OS[platform], }; } +/** + * Whether an `xcodebuild -destination` string names a simulator, read from its `platform=` token + * rather than a substring of the whole string: `xcodebuild` accepts `platform=iOS simulator` in + * any casing, and a destination that merely happens to contain the word would otherwise certify a + * simulator-SDK build under a physical-device identity that agrees with it key for key. + * + * A destination with no `platform=` token — a bare `id=` works for `xcodebuild` — leaves the + * SDK choice to the scheme, so no identity can be recorded for it and the build is refused. + */ +function resolveRunnerScriptDestinationKind(destination: string): DeviceInfo['kind'] { + const platformToken = readDestinationPlatformToken(destination); + if (platformToken === undefined) { + throw new AppError( + 'INVALID_ARGS', + 'The Apple runner build destination must name its platform', + { + destination, + hint: 'Pass a destination with a platform= token, e.g. generic/platform=iOS Simulator for a simulator or generic/platform=iOS for a physical device.', + }, + ); + } + const named = platformToken.toLowerCase(); + return named.endsWith('simulator') ? 'simulator' : 'device'; +} + +function readDestinationPlatformToken(destination: string): string | undefined { + for (const clause of destination.split(',')) { + const separator = clause.indexOf('='); + if (separator < 0) continue; + const key = clause + .slice(0, separator) + .trim() + .toLowerCase() + .replace(/^generic\//, ''); + if (key === 'platform') { + return clause.slice(separator + 1).trim() || undefined; + } + } + return undefined; +} + export function resolveRunnerPlatformName(device: DeviceInfo): RunnerApplePlatformName { if (!isApplePlatform(device.platform)) { throw new AppError( diff --git a/packages/platform-apple/src/runner/runner-artifact-manifest.ts b/packages/platform-apple/src/runner/runner-artifact-manifest.ts new file mode 100644 index 0000000000..c201e4fd5f --- /dev/null +++ b/packages/platform-apple/src/runner/runner-artifact-manifest.ts @@ -0,0 +1,655 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { + RunnerCacheArtifactEntry, + RunnerCacheArtifactFileEntry, + RunnerCacheArtifactSymlinkEntry, + RunnerXctestrunCacheArtifacts, + RunnerXctestrunCacheMetadata, +} from './runner-cache-metadata.ts'; + +/** Ceiling on one digested artifact file. Runner products are tens of MB at most. */ +const RUNNER_CACHE_ARTIFACT_MAX_FILE_BYTES = 128 * 1024 * 1024; +const RUNNER_CACHE_ARTIFACT_MODE_BITS = 0o7777; + +/** + * Whether a cached runner product tree may be reused. One traversal answers this for both sides: + * the writer digests a tree to publish a content manifest, and the reader walks the same tree to + * certify it against the manifest it published. A tree either side cannot fully describe is not + * reusable, because nothing then proves the bytes on disk are the bytes a build produced. + */ + +/** Why a content manifest does not certify the products on disk. */ +export type RunnerCacheArtifactMismatch = + | { path: string; reason: 'missing' } + | { path: string; reason: 'kind_changed' } + | { path: string; reason: 'size_changed'; expected: number; actual: number } + | { path: string; reason: 'digest_mismatch' } + | { path: string; reason: 'mode_changed'; expected: number; actual: number } + | { path: string; reason: 'symlink_target_changed'; expected: string; actual: string } + /** A symlinked entry whose target resolves outside every product the manifest covers. */ + | { path: string; reason: 'symlink_escapes_cache'; target: string } + /** A product root that is, or sits under, a symlink leading outside the cache root. */ + | { path: string; reason: 'root_escapes_cache'; target: string } + /** A root or entry the manifest cannot describe: unreadable, or an exotic entry kind. */ + | { path: string; reason: 'root_unusable' } + | { path: string; reason: 'undeclared_entry' } + | { path: string; reason: 'file_too_large'; size: number }; + +/** + * Why a tree could not carry a content manifest, and so cannot be reused. A symlinked root or a + * symlinked entry names where its bytes actually resolve; `root_unusable` covers what no manifest + * can hold — an unreadable directory, an entry kind with no manifest form, an oversized file — and + * names the refusing entry when the walk reached one. + * Reported by {@link writeRunnerCacheMetadataForArtifacts}. + */ +export type RunnerCacheRefusal = + | { reason: 'root_escapes_cache'; path: string; target: string } + | { reason: 'symlink_escapes_cache'; path: string; target: string } + | { reason: 'root_unusable'; path?: string }; + +type RunnerCacheArtifactWrite = + | { ok: true; artifacts: RunnerXctestrunCacheArtifacts } + | { ok: false; refusal: RunnerCacheRefusal }; + +/** + * Digests a freshly built product tree into the manifest its cache entry will carry, or names why + * the tree cannot carry one. + */ +export function buildRunnerCacheArtifactManifest( + cacheRoot: string, + xctestrunPath: string, + productPaths: readonly string[], +): RunnerCacheArtifactWrite { + const canonicalCacheRoot = resolveRealPath(cacheRoot); + if (canonicalCacheRoot === null) { + return { ok: false, refusal: { reason: 'root_unusable', path: cacheRoot } }; + } + const xctestrun = digestXctestrun(cacheRoot, xctestrunPath, canonicalCacheRoot); + if ('reason' in xctestrun) { + return { ok: false, refusal: xctestrun }; + } + const walked = resolveWalkedRoots(productPaths, cacheRoot, canonicalCacheRoot); + if (!walked.ok) { + return { ok: false, refusal: walked.refusal }; + } + const entries: RunnerCacheArtifactEntry[] = []; + for (const walkedRoot of walked.roots) { + const collected = collectRunnerCacheArtifactEntries( + canonicalCacheRoot, + walkedRoot, + walked.roots, + ); + if (!collected.ok) { + return { ok: false, refusal: collected.refusal }; + } + entries.push(...collected.entries); + } + if (entries.length === 0) { + return { ok: false, refusal: { reason: 'root_unusable', path: productPaths.join(', ') } }; + } + entries.sort((left, right) => left.path.localeCompare(right.path)); + return { + ok: true, + artifacts: { + xctestrunPath, + xctestrunSize: xctestrun.size, + xctestrunDigest: xctestrun.digest, + productPaths: [...productPaths], + entries, + }, + }; +} + +/** + * The `.xctestrun` the manifest will certify: a regular file, not a symlink, whose bytes resolve + * to inside the cache. A symlink here would make the manifest name one file while its digest + * described another. + */ +function digestXctestrun( + cacheRoot: string, + xctestrunPath: string, + canonicalCacheRoot: string, +): { digest: string; size: number } | RunnerCacheRefusal { + const relativePath = toManifestPath(cacheRoot, xctestrunPath) ?? xctestrunPath; + const resolved = resolveRealPath(xctestrunPath); + if (resolved === null) { + return { reason: 'root_unusable', path: relativePath }; + } + if (!isPathInsideDirectory(resolved, canonicalCacheRoot)) { + return { reason: 'root_escapes_cache', path: relativePath, target: resolved }; + } + if (!isRegularFile(resolved)) { + return { reason: 'root_unusable', path: relativePath }; + } + const digested = digestFile(xctestrunPath); + return digested ?? { reason: 'root_unusable', path: relativePath }; +} + +function isRegularFile(filePath: string): boolean { + try { + return fs.lstatSync(filePath).isFile(); + } catch { + return false; + } +} + +/** The product roots a manifest covers, or why none of them can be walked. */ +type RunnerCacheWalkedRoots = + | { ok: true; roots: string[] } + | { ok: false; refusal: RunnerCacheRefusal }; + +/** + * The product roots this manifest will cover, each resolved through the filesystem and required + * to land inside the cache root as the filesystem resolves it. A root that is a symlink, or sits + * under one that leaves the cache, is refused here rather than walked: its manifest-relative + * names would describe a tree the cache does not own, and re-pointing the symlink would swap + * those bytes without any manifest entry noticing. + */ +function resolveWalkedRoots( + productPaths: readonly string[], + cacheRoot: string, + canonicalCacheRoot: string, +): RunnerCacheWalkedRoots { + const roots: string[] = []; + for (const productPath of dedupeNestedPaths(productPaths)) { + const resolvedRoot = resolveRealPath(productPath); + if (resolvedRoot === null) { + return { ok: false, refusal: unusableRootRefusal(cacheRoot, productPath) }; + } + if (!isPathInsideDirectory(resolvedRoot, canonicalCacheRoot)) { + return { + ok: false, + refusal: { + reason: 'root_escapes_cache', + path: toManifestPath(cacheRoot, productPath) ?? productPath, + target: resolvedRoot, + }, + }; + } + if (!roots.some((keptRoot) => isPathInsideDirectory(resolvedRoot, keptRoot))) { + roots.push(resolvedRoot); + } + } + if (roots.length > 0) { + return { ok: true, roots }; + } + return { ok: false, refusal: { reason: 'root_unusable', path: productPaths.join(', ') } }; +} + +/** Drop paths whose ancestor is already walked, so no subtree is collected twice. */ +function dedupeNestedPaths(productPaths: readonly string[]): string[] { + const sorted = [...new Set(productPaths.map((target) => path.resolve(target)))].sort(); + const kept: string[] = []; + for (const candidate of sorted) { + if (kept.some((keptPath) => isPathInsideDirectory(candidate, keptPath))) continue; + kept.push(candidate); + } + return kept; +} + +/** + * One leaf of a cached product: where it lives in the manifest, where it lives on disk, and + * what `lstat` says about it. The writer digests these; the reader compares them to the manifest. + */ +type RunnerCacheArtifactLeaf = { + relativePath: string; + fullPath: string; + stat: fs.Stats; +}; + +type RunnerCacheLeafWalk = + | { ok: true; leaves: RunnerCacheArtifactLeaf[] } + | { ok: false; refusal: RunnerCacheRefusal }; + +/** + * The single traversal both cache sides share: every file and symlink under one product root, in + * manifest-relative form, where `walkedRoots` are all the roots the manifest covers. + * + * A symlink is contained only when its target resolves, through the filesystem rather than + * lexically, inside one of those roots. That is what makes the target's bytes certified: the walk + * of that root digests them. A symlink pointing anywhere else — outside the cache, or into a + * directory of it that no root covers — leaves the product uncertifiable, as does a kind the + * manifest cannot represent (a socket, a device) or an unreadable directory. + */ +// fallow-ignore-next-line complexity +function walkRunnerCacheArtifactLeaves( + cacheRoot: string, + root: string, + walkedRoots: readonly string[], +): RunnerCacheLeafWalk { + const leaves: RunnerCacheArtifactLeaf[] = []; + const stack: string[] = [root]; + while (stack.length > 0) { + const directory = stack.pop()!; + let names: string[]; + try { + names = fs.readdirSync(directory); + } catch { + const relativePath = toManifestPath(cacheRoot, directory); + return { ok: false, refusal: { reason: 'root_unusable', path: relativePath ?? undefined } }; + } + for (const name of names) { + const fullPath = path.join(directory, name); + const relativePath = toManifestPath(cacheRoot, fullPath); + if (relativePath === null) { + // Only a root that escaped the cache can produce this, and the caller checked the roots. + return { ok: false, refusal: { reason: 'root_unusable', path: fullPath } }; + } + let stat: fs.Stats; + try { + stat = fs.lstatSync(fullPath); + } catch { + return { ok: false, refusal: { reason: 'root_unusable', path: relativePath } }; + } + if (!isManifestDescribingEntry(stat)) { + return { ok: false, refusal: { reason: 'root_unusable', path: relativePath } }; + } + if (stat.isSymbolicLink()) { + const target = readContainedSymlinkTarget(fullPath, walkedRoots); + if (target === null) { + return { + ok: false, + refusal: { + reason: 'symlink_escapes_cache', + path: relativePath, + target: readSymlinkTarget(fullPath) ?? '(unreadable)', + }, + }; + } + } + if (!stat.isDirectory()) { + leaves.push({ relativePath, fullPath, stat }); + } else { + stack.push(fullPath); + } + } + } + return { ok: true, leaves }; +} + +/** + * A symlink's raw target, but only when it resolves inside one of `walkedRoots`. A dangling target + * is judged where it would land, so a link carried outside by its own parent chain is refused. + */ +function readContainedSymlinkTarget( + linkPath: string, + walkedRoots: readonly string[], +): string | null { + const target = readSymlinkTarget(linkPath); + if (target === null) { + return null; + } + const resolvedTarget = resolveSymlinkTarget(linkPath, target); + if (resolvedTarget === null) { + return null; + } + return walkedRoots.some((walkedRoot) => isPathInsideDirectory(resolvedTarget, walkedRoot)) + ? target + : null; +} + +/** A symlink's raw target, or null when the link cannot be read. */ +function readSymlinkTarget(linkPath: string): string | null { + try { + return fs.readlinkSync(linkPath); + } catch { + return null; + } +} + +function resolveSymlinkTarget(linkPath: string, target: string): string | null { + const resolvedParent = resolveRealPath(path.dirname(linkPath)); + if (resolvedParent === null) { + return null; + } + const anchoredPath = path.isAbsolute(target) + ? path.resolve(target) + : path.resolve(resolvedParent, target); + return resolveRealPath(anchoredPath) ?? anchoredPath; +} + +type RunnerCacheEntryCollection = + | { ok: true; entries: RunnerCacheArtifactEntry[] } + | { ok: false; refusal: RunnerCacheRefusal }; + +/** + * Whether one directory entry is something a content manifest can describe at all: a directory to + * descend into, a regular file, or a symlink. A socket or a device makes a product uncertifiable. + */ +function isManifestDescribingEntry(stat: fs.Stats): boolean { + return stat.isDirectory() || stat.isFile() || stat.isSymbolicLink(); +} + +function collectRunnerCacheArtifactEntries( + cacheRoot: string, + root: string, + walkedRoots: readonly string[], +): RunnerCacheEntryCollection { + const walk = walkRunnerCacheArtifactLeaves(cacheRoot, root, walkedRoots); + if (!walk.ok) { + return { ok: false, refusal: walk.refusal }; + } + const entries: RunnerCacheArtifactEntry[] = []; + for (const leaf of walk.leaves) { + if (leaf.stat.isSymbolicLink()) { + const target = readSymlinkTarget(leaf.fullPath); + if (target === null) { + return { ok: false, refusal: { reason: 'root_unusable', path: leaf.relativePath } }; + } + entries.push({ path: leaf.relativePath, symlink: target }); + continue; + } + if (leaf.stat.size > RUNNER_CACHE_ARTIFACT_MAX_FILE_BYTES) { + return { ok: false, refusal: { reason: 'root_unusable', path: leaf.relativePath } }; + } + const digest = digestFile(leaf.fullPath); + if (!digest) { + return { ok: false, refusal: { reason: 'root_unusable', path: leaf.relativePath } }; + } + entries.push({ + path: leaf.relativePath, + size: digest.size, + mode: leaf.stat.mode & RUNNER_CACHE_ARTIFACT_MODE_BITS, + digest: digest.digest, + }); + } + return { ok: true, entries }; +} + +function toManifestPath(cacheRoot: string, fullPath: string): string | null { + const relativePath = path.relative(path.resolve(cacheRoot), path.resolve(fullPath)); + if (relativePath === '' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return null; + } + return relativePath; +} + +function resolveRealPath(targetPath: string): string | null { + try { + return fs.realpathSync(targetPath); + } catch { + return null; + } +} + +function digestFile(filePath: string): { digest: string; size: number } | null { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size > RUNNER_CACHE_ARTIFACT_MAX_FILE_BYTES) { + return null; + } + const hash = crypto.createHash('sha256'); + hash.update(fs.readFileSync(filePath)); + return { digest: hash.digest('hex'), size: stat.size }; + } catch { + return null; + } +} + +type RunnerCacheArtifactValidation = + | { ok: true; xctestrunPath: string; productPaths: string[] } + | { ok: false; mismatch: RunnerCacheArtifactMismatch | null }; + +/** + * Checks a cache root's manifest against the tree on disk: the `.xctestrun` and every product the + * manifest names, byte for byte, inside the roots the manifest itself claims. + */ +export function validateRunnerCacheArtifactManifest( + derived: string, + metadata: RunnerXctestrunCacheMetadata, +): RunnerCacheArtifactValidation { + const artifacts = metadata.artifacts; + const canonicalCacheRoot = artifacts ? resolveRealPath(derived) : null; + if (!artifacts || !isRunnerCacheArtifacts(artifacts) || canonicalCacheRoot === null) { + return { ok: false, mismatch: null }; + } + if (!isPathInsideDirectory(artifacts.xctestrunPath, derived)) { + return { ok: false, mismatch: null }; + } + const xctestrunMismatch = validateManifestedXctestrun(artifacts, canonicalCacheRoot); + if (xctestrunMismatch) { + return { ok: false, mismatch: xctestrunMismatch }; + } + const productMismatch = validateManifestedProducts( + artifacts, + canonicalCacheRoot, + resolveWalkedRoots(artifacts.productPaths, derived, canonicalCacheRoot), + ); + if (productMismatch) { + return { ok: false, mismatch: productMismatch }; + } + return { + ok: true, + xctestrunPath: artifacts.xctestrunPath, + productPaths: [...artifacts.productPaths], + }; +} + +/** + * Walks each product root and certifies its leaves against the manifest. An entry the manifest + * declares but the walks never returned never existed here, and one the walks returned that the + * manifest never names is a byte nobody accounted for. + */ +function validateManifestedProducts( + artifacts: RunnerXctestrunCacheArtifacts, + canonicalCacheRoot: string, + walked: RunnerCacheWalkedRoots, +): RunnerCacheArtifactMismatch | null { + if (!walked.ok) { + return toWalkRefusal(walked.refusal); + } + const declared = new Map(artifacts.entries.map((entry) => [entry.path, entry])); + for (const walkedRoot of walked.roots) { + const mismatch = validateProductAgainstManifest( + canonicalCacheRoot, + walkedRoot, + walked.roots, + declared, + ); + if (mismatch) { + return mismatch; + } + } + const unlisted = declared.keys().next(); + return unlisted.done ? null : { path: unlisted.value, reason: 'missing' }; +} + +/** + * The `.xctestrun` as it sits now: a regular file whose bytes resolve to inside the cache root, + * matching the digest the manifest holds. A manifest path swapped for a symlink elsewhere would + * otherwise be digested from outside the cache. + */ +function validateManifestedXctestrun( + artifacts: RunnerXctestrunCacheArtifacts, + canonicalCacheRoot: string, +): RunnerCacheArtifactMismatch | null { + const mismatch = validateManifestedFile( + artifacts.xctestrunPath, + artifacts.xctestrunSize, + artifacts.xctestrunDigest, + ); + if (mismatch) { + return mismatch; + } + const resolved = resolveRealPath(artifacts.xctestrunPath); + return resolved !== null && isPathInsideDirectory(resolved, canonicalCacheRoot) + ? null + : { path: artifacts.xctestrunPath, reason: 'root_escapes_cache', target: resolved ?? '(?)' }; +} + +/** Widens a walk refusal into the reason a reuse decision reports. */ +function toWalkRefusal(refusal: RunnerCacheRefusal): RunnerCacheArtifactMismatch { + if (refusal.reason === 'root_unusable') { + return { path: refusal.path ?? '(cache root)', reason: 'root_unusable' }; + } + return { ...refusal }; +} + +/** Names a root the walk never reached, so a refusal always reports something to inspect. */ +function unusableRootRefusal(cacheRoot: string, productPath: string): RunnerCacheRefusal { + return { + reason: 'root_unusable', + path: toManifestPath(cacheRoot, productPath) ?? undefined, + }; +} + +/** + * Walks one product with the same traversal the writer used and removes each leaf it can certify + * from `declared`. A leaf on disk the manifest never names is unaccounted for, and a tree the walk + * cannot describe cannot be certified at all. + */ +function validateProductAgainstManifest( + canonicalCacheRoot: string, + productPath: string, + walkedRoots: readonly string[], + declared: Map, +): RunnerCacheArtifactMismatch | null { + const walk = walkRunnerCacheArtifactLeaves(canonicalCacheRoot, productPath, walkedRoots); + if (!walk.ok) { + return walk.refusal + ? toWalkRefusal(walk.refusal) + : { path: productPath, reason: 'root_unusable' }; + } + for (const leaf of walk.leaves) { + const entry = declared.get(leaf.relativePath); + if (!entry) { + return { path: leaf.relativePath, reason: 'undeclared_entry' }; + } + const mismatch = validateManifestEntry(leaf.fullPath, leaf.relativePath, entry); + if (mismatch) { + return mismatch; + } + declared.delete(leaf.relativePath); + } + return null; +} + +function validateManifestEntry( + fullPath: string, + relativePath: string, + entry: RunnerCacheArtifactEntry, +): RunnerCacheArtifactMismatch | null { + if ('symlink' in entry) { + const actual = readSymlinkTarget(fullPath); + if (actual === null) { + return { path: relativePath, reason: 'missing' }; + } + return actual === entry.symlink + ? null + : { + path: relativePath, + reason: 'symlink_target_changed', + expected: entry.symlink, + actual, + }; + } + return validateManifestedFile(fullPath, entry.size, entry.digest, entry.mode, relativePath); +} + +function validateManifestedFile( + fullPath: string, + expectedSize: number, + expectedDigest: string, + expectedMode?: number, + relativePath?: string, +): RunnerCacheArtifactMismatch | null { + const reportedPath = relativePath ?? fullPath; + let stat: fs.Stats; + try { + stat = fs.lstatSync(fullPath); + } catch { + return { path: reportedPath, reason: 'missing' }; + } + if (!stat.isFile()) { + return { path: reportedPath, reason: 'kind_changed' }; + } + if (stat.size !== expectedSize) { + return { + path: reportedPath, + reason: 'size_changed', + expected: expectedSize, + actual: stat.size, + }; + } + if ( + expectedMode !== undefined && + (stat.mode & RUNNER_CACHE_ARTIFACT_MODE_BITS) !== expectedMode + ) { + return { + path: reportedPath, + reason: 'mode_changed', + expected: expectedMode, + actual: stat.mode & RUNNER_CACHE_ARTIFACT_MODE_BITS, + }; + } + const digested = digestFile(fullPath); + if (!digested) { + return { path: reportedPath, reason: 'file_too_large', size: stat.size }; + } + if (digested.digest !== expectedDigest) { + return { path: reportedPath, reason: 'digest_mismatch' }; + } + return null; +} + +function isRunnerCacheArtifacts(value: unknown): value is RunnerXctestrunCacheArtifacts { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const artifacts = value as Partial; + return ( + typeof artifacts.xctestrunPath === 'string' && + isNonNegativeInteger(artifacts.xctestrunSize) && + typeof artifacts.xctestrunDigest === 'string' && + isNonEmptyStringArray(artifacts.productPaths) && + isNonEmptyArray(artifacts.entries) && + artifacts.entries.every(isRunnerCacheArtifactEntry) + ); +} + +function isRunnerCacheArtifactEntry(value: unknown): value is RunnerCacheArtifactEntry { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const entry = value as Partial & + Partial; + if (typeof entry.path !== 'string' || !isManifestRelativePath(entry.path)) { + return false; + } + if (typeof entry.symlink === 'string') { + return entry.digest === undefined; + } + return ( + typeof entry.digest === 'string' && + isNonNegativeInteger(entry.size) && + typeof entry.mode === 'number' + ); +} + +/** A manifest path must stay inside the cache root it was written under. */ +function isManifestRelativePath(relativePath: string): boolean { + return ( + !relativePath.startsWith('/') && + !relativePath.startsWith('..') && + !path.isAbsolute(relativePath) + ); +} + +function isNonNegativeInteger(value: unknown): value is number { + return Number.isInteger(value) && (value as number) >= 0; +} + +function isNonEmptyStringArray(value: unknown): value is string[] { + return isNonEmptyArray(value) && value.every((item) => typeof item === 'string'); +} + +function isNonEmptyArray(value: unknown): value is Item[] { + return Array.isArray(value) && value.length > 0; +} + +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 edb546bb1c..6326acbec1 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -37,6 +37,7 @@ import { resolveRunnerPerformanceBuildSettings, resolveRunnerSandboxBuildArgs, resolveRunnerSigningBuildSettings, + requireCertifiedRunnerCacheArtifacts, writeRunnerCacheMetadataForArtifacts, type ExistingXctestrunState, type RunnerPhaseBudget, @@ -276,7 +277,10 @@ async function buildXctestrunArtifact(params: { // This covers direct local xcodebuilds triggered by ensureXctestrunArtifact on cache miss. // The manifest is written last so it certifies the bytes that actually run. await applyXctestRunnerAppIcon(builtProductPaths); - writeRunnerCacheMetadataForArtifacts(derived, expectedCacheMetadata, built, builtProductPaths); + requireCertifiedRunnerCacheArtifacts( + writeRunnerCacheMetadataForArtifacts(derived, expectedCacheMetadata, built, builtProductPaths), + derived, + ); emitRunnerXctestrunDecision('build', 'built_new', { derived, xctestrunPath: built, @@ -304,11 +308,14 @@ async function tryReuseExistingXctestrun( derived, xctestrunPath: existing.xctestrunPath, }); - writeRunnerCacheMetadataForArtifacts( + requireCertifiedRunnerCacheArtifacts( + writeRunnerCacheMetadataForArtifacts( + derived, + expectedCacheMetadata, + existing.xctestrunPath, + existing.productPaths, + ), derived, - expectedCacheMetadata, - existing.xctestrunPath, - existing.productPaths, ); return existing.xctestrunPath; } 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 c4ae6eca71..475f40718a 100644 --- a/packages/platform-apple/src/runner/runner-cache-metadata.ts +++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts @@ -639,6 +639,7 @@ function resolveRunnerSwiftFlags(env: NodeJS.ProcessEnv): string { const BUILD_SETTINGS_HEADER = /^\s*Build settings from command line:\s*$/; const BUILD_SETTING_LINE = /^\s+([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/; const RECORDED_BUILD_SETTING = /^([A-Z][A-Z0-9_]*)=(.*)$/; +const COMMAND_LINE_INVOCATION_HEADER = /^\s*Command line invocation:\s*$/; export type RunnerBuildSettingEvidence = { key: string; @@ -647,19 +648,31 @@ export type RunnerBuildSettingEvidence = { }; /** - * The build settings `xcodebuild` echoed back, or null when the log holds no such block. That - * echo is the build's own record of the recipe it was handed. + * What one build log says it was handed: the settings block `xcodebuild` echoed, and the + * invocation line that carries every argument, including the ones that are not build settings. + * Null when the log holds no settings block at all. */ -function readRunnerBuildSettingsFromBuildLog(logPath: string): Map | null { +type RunnerBuildLogRecipe = { + settings: Map; + invocationLine: string; +}; + +function readRunnerBuildLogRecipe(logPath: string): RunnerBuildLogRecipe | null { let contents: string; try { contents = fs.readFileSync(logPath, 'utf8'); } catch { return null; } + const lines = contents.split('\n'); const settings = new Map(); let inBlock = false; - for (const line of contents.split('\n')) { + let invocationLine = ''; + for (const [index, line] of lines.entries()) { + if (COMMAND_LINE_INVOCATION_HEADER.test(line)) { + invocationLine = lines[index + 1] ?? ''; + continue; + } if (BUILD_SETTINGS_HEADER.test(line)) { inBlock = true; continue; @@ -669,20 +682,25 @@ function readRunnerBuildSettingsFromBuildLog(logPath: string): Map { - const recorded: Record = {}; - for (const arg of [ +/** Every argument the cache identity records as a recipe, in the `xcodebuild` spelling. */ +function recordedRunnerBuildArguments(metadata: RunnerXctestrunCacheMetadata): string[] { + return [ ...metadata.runnerBundleBuildSettings, ...metadata.runnerSigningBuildSettings, ...metadata.runnerPerformanceBuildSettings, ...metadata.runnerArchBuildSettings, ...metadata.runnerSandboxBuildArgs, - ]) { + ]; +} + +function recordedRunnerBuildSettings( + metadata: RunnerXctestrunCacheMetadata, +): Record { + const recorded: Record = {}; + for (const arg of recordedRunnerBuildArguments(metadata)) { const setting = RECORDED_BUILD_SETTING.exec(arg); if (setting) { recorded[setting[1]!] = setting[2]!; @@ -691,6 +709,25 @@ function recordedRunnerBuildSettings( return recorded; } +/** + * Recorded arguments `xcodebuild` echoes on the invocation line rather than in its settings block, + * which is where its whole recipe shows: `-I` user-default flags such as the package-sandbox + * disables. Without this the settings diff would call a recipe complete while ignoring them. + */ +function diffRunnerInvocationFlagsAgainstBuildLog( + metadata: RunnerXctestrunCacheMetadata, + invocationLine: string, +): RunnerBuildSettingEvidence[] { + return recordedRunnerBuildArguments(metadata) + .filter((arg) => !RECORDED_BUILD_SETTING.test(arg)) + .filter((arg) => !invocationLine.includes(arg)) + .map((arg) => ({ + key: '(invocation flag)', + expected: arg, + actual: 'absent from the "Command line invocation:" line', + })); +} + /** * The recorded settings a build log shows `xcodebuild` did not receive exactly as recorded. An * empty recorded value matches an absent report, which is how `CODE_SIGN_IDENTITY=` arrives. @@ -699,7 +736,7 @@ function diffRunnerBuildSettingsAgainstBuildLog( metadata: RunnerXctestrunCacheMetadata, logPath: string, ): RunnerBuildSettingEvidence[] { - const reported = readRunnerBuildSettingsFromBuildLog(logPath); + const reported = readRunnerBuildLogRecipe(logPath); if (!reported) { return [ { @@ -709,20 +746,25 @@ function diffRunnerBuildSettingsAgainstBuildLog( }, ]; } - return Object.entries(recordedRunnerBuildSettings(metadata)) + const settingDifferences = Object.entries(recordedRunnerBuildSettings(metadata)) .filter(([key, expected]) => { - const actual = reported.get(key); + const actual = reported.settings.get(key); return actual === undefined ? expected !== '' : actual !== expected; }) .map(([key, expected]) => ({ key, expected, - actual: reported.get(key) ?? '(absent)', + actual: reported.settings.get(key) ?? '(absent)', })); + return [ + ...settingDifferences, + ...diffRunnerInvocationFlagsAgainstBuildLog(metadata, reported.invocationLine), + ]; } /** - * Fails when a build log shows a recipe other than the one `metadata` records, so a caller that + * Fails when a build log shows a recipe other than the one `metadata` records — a setting whose + * value differs or went missing, or a recorded flag absent from the invocation — so a caller that * drifted from this identity cannot have its products certified under it. */ export function requireRunnerBuildSettingsMatchBuildLog( diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts index eebf1aa321..a53813b54e 100644 --- a/packages/platform-apple/src/runner/runner-cache.ts +++ b/packages/platform-apple/src/runner/runner-cache.ts @@ -1,4 +1,3 @@ -import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; @@ -15,13 +14,19 @@ import { comparableRunnerCacheMetadata, diffComparableRunnerCacheMetadata, stableJsonStringify, - type RunnerCacheArtifactEntry, - type RunnerCacheArtifactFileEntry, - type RunnerCacheArtifactSymlinkEntry, type RunnerCacheMetadataDifference, - type RunnerXctestrunCacheArtifacts, type RunnerXctestrunCacheMetadata, } from './runner-cache-metadata.ts'; +import { + buildRunnerCacheArtifactManifest, + validateRunnerCacheArtifactManifest, + type RunnerCacheArtifactMismatch, + type RunnerCacheRefusal, +} from './runner-artifact-manifest.ts'; +export type { + RunnerCacheArtifactMismatch, + RunnerCacheRefusal, +} from './runner-artifact-manifest.ts'; export { requireRunnerPhaseRemainingMs, resolveExpectedRunnerCacheMetadata, @@ -40,10 +45,6 @@ const RUNNER_XCTESTRUN_CACHE_LOCK_TIMEOUT_MS = 10 * 60_000; const RUNNER_XCTESTRUN_CACHE_LOCK_POLL_MS = 100; const RUNNER_XCTESTRUN_CACHE_LOCK_OWNER_GRACE_MS = 5_000; -/** Ceiling on one digested artifact file. Runner products are tens of MB at most. */ -const RUNNER_CACHE_ARTIFACT_MAX_FILE_BYTES = 128 * 1024 * 1024; -const RUNNER_CACHE_ARTIFACT_MODE_BITS = 0o7777; - const badRunnerArtifactsForRun = new Set(); export type RunnerXctestrunCacheKind = 'exact' | 'miss' | 'external'; @@ -74,18 +75,6 @@ export type ExistingXctestrunState = metadataDifferences: RunnerCacheMetadataDifference[]; }; -/** Why a content manifest does not certify the products on disk. */ -export type RunnerCacheArtifactMismatch = - | { path: string; reason: 'missing' } - | { path: string; reason: 'kind_changed' } - | { path: string; reason: 'size_changed'; expected: number; actual: number } - | { path: string; reason: 'digest_mismatch' } - | { path: string; reason: 'mode_changed'; expected: number; actual: number } - | { path: string; reason: 'symlink_target_changed'; expected: string; actual: string } - | { path: string; reason: 'escaping_symlink'; target: string } - | { path: string; reason: 'undeclared_entry' } - | { path: string; reason: 'file_too_large'; size: number }; - type RunnerXctestrunArtifactIdentity = { cache: RunnerXctestrunCacheKind; derived: string; @@ -169,18 +158,60 @@ export function cleanRunnerDerivedBeforeEvaluation(derived: string, forceRebuild } /** - * Writes cache metadata whose `artifacts` manifest digests the exact bytes of the - * `.xctestrun` and every file and symlink under the referenced product paths, keyed - * relative to the cache root. Reuse is authorized from this manifest alone. + * Publishes cache metadata whose `artifacts` manifest digests the exact bytes of the `.xctestrun` + * and every file and symlink under the referenced product paths, keyed relative to the cache root. + * Reuse is authorized from this manifest alone. + * + * A tree the walk cannot describe is published without a manifest, which makes it a permanent + * miss, and the refusal is returned so the caller can name it. Silently uncertifiable products + * would otherwise cost a full rebuild on every launch with nothing to trace. */ export function writeRunnerCacheMetadataForArtifacts( derived: string, metadata: RunnerXctestrunCacheMetadata, xctestrunPath: string, productPaths: readonly string[], +): RunnerCacheRefusal | null { + const built = buildRunnerCacheArtifactManifest(derived, xctestrunPath, productPaths); + writeRunnerCacheMetadata( + derived, + built.ok ? { ...metadata, artifacts: built.artifacts } : metadata, + ); + if (built.ok) { + return null; + } + emitRunnerXctestrunDecision('preserve', 'uncertifiable_products', { + derived, + xctestrunPath, + ...(built.refusal ? { refusal: built.refusal } : {}), + }); + return built.refusal; +} + +/** + * A cache tree a content manifest cannot certify is not safe to launch: nothing can prove those + * bytes came from this build. Fails with the refusing entry rather than reporting a plain miss, + * which would rebuild the same uncertifiable tree on every launch. + */ +export function requireCertifiedRunnerCacheArtifacts( + refusal: RunnerCacheRefusal | null, + derived: string, ): void { - const artifacts = buildRunnerCacheArtifacts(derived, xctestrunPath, productPaths); - writeRunnerCacheMetadata(derived, artifacts ? { ...metadata, artifacts } : metadata); + if (!refusal) { + return; + } + throw new AppError( + 'COMMAND_FAILED', + 'The Apple runner products cannot be certified for cache reuse', + { + reason: 'runner_cache_uncertifiable', + refusalReason: refusal.reason, + refusingPath: refusal.path ?? derived, + ...(refusal.reason === 'root_unusable' ? {} : { resolvesTo: refusal.target }), + derived, + hint: `Inspect ${refusal.path ?? derived} under ${derived}. A symlinked or unreadable product tree must be replaced; run pnpm build:xcuitest with a clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH.`, + }, + ); } export function cleanRunnerDerivedArtifacts(derived: string): void { @@ -258,392 +289,6 @@ function evaluateRunnerCacheMetadata( return { ok: true, metadata: actual }; } -function buildRunnerCacheArtifacts( - cacheRoot: string, - xctestrunPath: string, - productPaths: readonly string[], -): RunnerXctestrunCacheArtifacts | null { - if (productPaths.length === 0) { - return null; - } - if ( - !isPathInsideDirectory(xctestrunPath, cacheRoot) || - !productPaths.every((productPath) => isPathInsideDirectory(productPath, cacheRoot)) - ) { - return null; - } - const xctestrunDigest = digestFile(xctestrunPath); - if (!xctestrunDigest) { - return null; - } - const entries: RunnerCacheArtifactEntry[] = []; - for (const productPath of dedupeNestedPaths(productPaths)) { - const collected = collectRunnerCacheArtifactEntries(cacheRoot, productPath); - if (!collected) { - return null; - } - entries.push(...collected); - } - if (entries.length === 0) { - return null; - } - entries.sort((left, right) => left.path.localeCompare(right.path)); - return { - xctestrunPath, - xctestrunSize: fs.statSync(xctestrunPath).size, - xctestrunDigest: xctestrunDigest.digest, - productPaths: [...productPaths], - entries, - }; -} - -/** Drop paths whose ancestor is already walked, so no subtree is collected twice. */ -function dedupeNestedPaths(productPaths: readonly string[]): string[] { - const sorted = [...new Set(productPaths.map((target) => path.resolve(target)))].sort(); - const kept: string[] = []; - for (const candidate of sorted) { - if (kept.some((keptPath) => isPathInsideDirectory(candidate, keptPath))) continue; - kept.push(candidate); - } - return kept; -} - -/** - * One leaf of a cached product: where it lives in the manifest, where it lives on disk, and - * what `lstat` says about it. The writer digests these; the reader compares them to the manifest. - */ -type RunnerCacheArtifactLeaf = { - relativePath: string; - fullPath: string; - stat: fs.Stats; -}; - -/** - * The single traversal both cache sides share: every file and symlink under one product root, - * in manifest-relative form. A kind the manifest cannot represent — a socket, a device, an - * escaping symlink — or an unreadable directory makes the whole product uncertifiable. - */ -// fallow-ignore-next-line complexity -function walkRunnerCacheArtifactLeaves( - cacheRoot: string, - root: string, -): RunnerCacheArtifactLeaf[] | null { - const leaves: RunnerCacheArtifactLeaf[] = []; - const stack: string[] = [root]; - while (stack.length > 0) { - const directory = stack.pop()!; - let names: string[]; - try { - names = fs.readdirSync(directory); - } catch { - return null; - } - for (const name of names) { - const fullPath = path.join(directory, name); - const relativePath = toManifestPath(cacheRoot, fullPath); - if (relativePath === null) { - return null; - } - let stat: fs.Stats; - try { - stat = fs.lstatSync(fullPath); - } catch { - return null; - } - if (!isManifestDescribingEntry(stat)) { - return null; - } - if (!stat.isDirectory()) { - leaves.push({ relativePath, fullPath, stat }); - } else { - stack.push(fullPath); - } - } - } - return leaves; -} - -/** - * Whether one directory entry is something a content manifest can describe at all: a directory - * to descend into, a regular file, or a symlink. A socket or a device makes the product - * uncertifiable. Whether a symlink stays inside the tree is decided per side: the writer - * refuses to certify one that escapes, the reader reports which entry started escaping. - */ -function isManifestDescribingEntry(stat: fs.Stats): boolean { - return stat.isDirectory() || stat.isFile() || stat.isSymbolicLink(); -} - -function collectRunnerCacheArtifactEntries( - cacheRoot: string, - root: string, -): RunnerCacheArtifactEntry[] | null { - const leaves = walkRunnerCacheArtifactLeaves(cacheRoot, root); - if (!leaves) { - return null; - } - const entries: RunnerCacheArtifactEntry[] = []; - for (const leaf of leaves) { - if (leaf.stat.isSymbolicLink()) { - const target = fs.readlinkSync(leaf.fullPath); - if (!isSymlinkContained(cacheRoot, leaf.fullPath, target)) { - return null; - } - entries.push({ path: leaf.relativePath, symlink: target }); - continue; - } - if (leaf.stat.size > RUNNER_CACHE_ARTIFACT_MAX_FILE_BYTES) { - return null; - } - const digest = digestFile(leaf.fullPath); - if (!digest) { - return null; - } - entries.push({ - path: leaf.relativePath, - size: digest.size, - mode: leaf.stat.mode & RUNNER_CACHE_ARTIFACT_MODE_BITS, - digest: digest.digest, - }); - } - return entries; -} - -function toManifestPath(cacheRoot: string, fullPath: string): string | null { - const relativePath = path.relative(cacheRoot, fullPath); - if (relativePath === '' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { - return null; - } - return relativePath; -} - -function isSymlinkContained(cacheRoot: string, linkPath: string, target: string): boolean { - if (path.isAbsolute(target)) { - return isPathInsideDirectory(target, cacheRoot); - } - return isPathInsideDirectory(path.resolve(path.dirname(linkPath), target), cacheRoot); -} - -function digestFile(filePath: string): { digest: string; size: number } | null { - try { - const stat = fs.statSync(filePath); - if (!stat.isFile() || stat.size > RUNNER_CACHE_ARTIFACT_MAX_FILE_BYTES) { - return null; - } - const hash = crypto.createHash('sha256'); - hash.update(fs.readFileSync(filePath)); - return { digest: hash.digest('hex'), size: stat.size }; - } catch { - return null; - } -} - -type RunnerCacheArtifactValidation = - | { ok: true; xctestrunPath: string; productPaths: string[] } - | { ok: false; mismatch: RunnerCacheArtifactMismatch | null }; - -function readValidatedRunnerCacheArtifacts( - derived: string, - metadata: RunnerXctestrunCacheMetadata, -): RunnerCacheArtifactValidation { - const artifacts = metadata.artifacts; - if (!isRunnerCacheArtifacts(artifacts)) { - return { ok: false, mismatch: null }; - } - if ( - !isPathInsideDirectory(artifacts.xctestrunPath, derived) || - !artifacts.productPaths.every((productPath) => isPathInsideDirectory(productPath, derived)) - ) { - return { ok: false, mismatch: null }; - } - const declared = new Map(artifacts.entries.map((entry) => [entry.path, entry])); - const xctestrunMismatch = validateManifestedFile( - artifacts.xctestrunPath, - artifacts.xctestrunSize, - artifacts.xctestrunDigest, - ); - if (xctestrunMismatch) { - return { ok: false, mismatch: xctestrunMismatch }; - } - for (const productPath of dedupeNestedPaths(artifacts.productPaths)) { - const mismatch = validateProductAgainstManifest(derived, productPath, declared); - if (mismatch) { - return { ok: false, mismatch }; - } - } - // Anything still declared never came back from the walk. - const unlisted = declared.keys().next(); - if (!unlisted.done) { - return { ok: false, mismatch: { path: unlisted.value, reason: 'missing' } }; - } - return { - ok: true, - xctestrunPath: artifacts.xctestrunPath, - productPaths: [...artifacts.productPaths], - }; -} - -/** - * Walks one product with the same traversal the writer used and removes each leaf it can - * certify from `declared`. A leaf on disk that the manifest never names is unaccounted for. - */ -function validateProductAgainstManifest( - derived: string, - productPath: string, - declared: Map, -): RunnerCacheArtifactMismatch | null { - const leaves = walkRunnerCacheArtifactLeaves(derived, productPath); - if (!leaves) { - return { path: productPath, reason: 'missing' }; - } - for (const leaf of leaves) { - const entry = declared.get(leaf.relativePath); - if (!entry) { - return { path: leaf.relativePath, reason: 'undeclared_entry' }; - } - const mismatch = validateManifestEntry(derived, leaf.fullPath, leaf.relativePath, entry); - if (mismatch) { - return mismatch; - } - declared.delete(leaf.relativePath); - } - return null; -} - -function validateManifestEntry( - cacheRoot: string, - fullPath: string, - relativePath: string, - entry: RunnerCacheArtifactEntry, -): RunnerCacheArtifactMismatch | null { - if ('symlink' in entry) { - let actual: string; - try { - actual = fs.readlinkSync(fullPath); - } catch { - return { path: relativePath, reason: 'missing' }; - } - if (actual !== entry.symlink) { - return { - path: relativePath, - reason: 'symlink_target_changed', - expected: entry.symlink, - actual, - }; - } - if (!isSymlinkContained(cacheRoot, fullPath, actual)) { - return { path: relativePath, reason: 'escaping_symlink', target: actual }; - } - return null; - } - return validateManifestedFile(fullPath, entry.size, entry.digest, entry.mode, relativePath); -} - -function validateManifestedFile( - fullPath: string, - expectedSize: number, - expectedDigest: string, - expectedMode?: number, - relativePath?: string, -): RunnerCacheArtifactMismatch | null { - const reportedPath = relativePath ?? fullPath; - let stat: fs.Stats; - try { - stat = fs.lstatSync(fullPath); - } catch { - return { path: reportedPath, reason: 'missing' }; - } - if (!stat.isFile()) { - return { path: reportedPath, reason: 'kind_changed' }; - } - if (stat.size !== expectedSize) { - return { - path: reportedPath, - reason: 'size_changed', - expected: expectedSize, - actual: stat.size, - }; - } - if ( - expectedMode !== undefined && - (stat.mode & RUNNER_CACHE_ARTIFACT_MODE_BITS) !== expectedMode - ) { - return { - path: reportedPath, - reason: 'mode_changed', - expected: expectedMode, - actual: stat.mode & RUNNER_CACHE_ARTIFACT_MODE_BITS, - }; - } - const digested = digestFile(fullPath); - if (!digested) { - return { path: reportedPath, reason: 'file_too_large', size: stat.size }; - } - if (digested.digest !== expectedDigest) { - return { path: reportedPath, reason: 'digest_mismatch' }; - } - return null; -} - -function isRunnerCacheArtifacts(value: unknown): value is RunnerXctestrunCacheArtifacts { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false; - } - const artifacts = value as Partial; - return ( - typeof artifacts.xctestrunPath === 'string' && - isNonNegativeInteger(artifacts.xctestrunSize) && - typeof artifacts.xctestrunDigest === 'string' && - isNonEmptyStringArray(artifacts.productPaths) && - isNonEmptyArray(artifacts.entries) && - artifacts.entries.every(isRunnerCacheArtifactEntry) - ); -} - -function isRunnerCacheArtifactEntry(value: unknown): value is RunnerCacheArtifactEntry { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false; - } - const entry = value as Partial & - Partial; - if (typeof entry.path !== 'string' || !isManifestRelativePath(entry.path)) { - return false; - } - if (typeof entry.symlink === 'string') { - return entry.digest === undefined; - } - return ( - typeof entry.digest === 'string' && - isNonNegativeInteger(entry.size) && - typeof entry.mode === 'number' - ); -} - -/** A manifest path must stay inside the cache root it was written under. */ -function isManifestRelativePath(relativePath: string): boolean { - return ( - !relativePath.startsWith('/') && - !relativePath.startsWith('..') && - !path.isAbsolute(relativePath) - ); -} - -function isNonNegativeInteger(value: unknown): value is number { - return Number.isInteger(value) && (value as number) >= 0; -} - -function isNonEmptyStringArray(value: unknown): value is string[] { - return isNonEmptyArray(value) && value.every((item) => typeof item === 'string'); -} - -function isNonEmptyArray(value: unknown): value is Item[] { - return Array.isArray(value) && value.length > 0; -} - -function isPathInsideDirectory(targetPath: string, directoryPath: string): boolean { - const relativePath = path.relative(path.resolve(directoryPath), path.resolve(targetPath)); - return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath); -} - function shouldCleanDerived(): boolean { return isEnvTruthy(process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED); } @@ -690,7 +335,7 @@ export async function evaluateExistingXctestrun(options: { } : { reason: cacheMetadata.reason, xctestrunPath: null, productPaths: [] }; } - const artifacts = readValidatedRunnerCacheArtifacts(options.derived, cacheMetadata.metadata); + const artifacts = validateRunnerCacheArtifactManifest(options.derived, cacheMetadata.metadata); if (!artifacts.ok) { return artifacts.mismatch ? { @@ -739,11 +384,12 @@ export function emitRunnerXctestrunDecision( | 'bad_artifact' | 'built_new' | 'external_xctestrun' - | 'external_bad_artifact', + | 'external_bad_artifact' + | 'uncertifiable_products', data: Record, ): void { emitDiagnostic({ - level: action === 'rebuild' ? 'warn' : 'info', + level: action === 'rebuild' || action === 'preserve' ? 'warn' : 'info', phase: 'runner_xctestrun_cache', data: { action, diff --git a/packages/platform-apple/src/runner/runner-source.ts b/packages/platform-apple/src/runner/runner-source.ts index 5483a6a394..a6cc0c3a0e 100644 --- a/packages/platform-apple/src/runner/runner-source.ts +++ b/packages/platform-apple/src/runner/runner-source.ts @@ -32,6 +32,13 @@ export function resolveAppleSnapshotPresentationSourceRoot(projectRoot: string): } const RUNNER_SOURCE_IGNORED_DIR_NAMES = new Set(['.build', '.swiftpm', 'xcuserdata']); +/** + * Finder and editor droppings that land inside an Xcode package without being build input. They + * would otherwise flip the fingerprint and cost a full runner rebuild whenever a checkout is + * browsed. Xcode keeps user state under `xcuserdata`, already ignored as a directory. + */ +const XCODE_PACKAGE_NON_BUILD_FILE_NAMES = new Set(['.DS_Store']); +const XCODE_PACKAGE_NON_BUILD_FILE_EXTENSIONS = new Set(['.xcuserstate']); const SNAPSHOT_PRESENTATION_SOURCE_IGNORED_DIR_NAMES = new Set([ '.build', '.swiftpm', @@ -138,7 +145,12 @@ function collectRunnerSourceFilesInDirectory( nestedIncludeEveryFile, ), ); - } else if (entry.isFile() && (includeEveryFile || isRunnerSourceFile(entry.name))) { + } else if (entry.isFile()) { + if ( + includeEveryFile ? isXcodePackageNonBuildFile(entry.name) : !isRunnerSourceFile(entry.name) + ) { + continue; + } files.push(fullPath); } } @@ -150,6 +162,14 @@ function collectRunnerSourceFilesInDirectory( * schemes, and workspace data all change what a build produces — so every file inside one * is build input, not just the ones with a recognizable extension. */ +/** Whether a file inside an Xcode package is Finder or editor droppings rather than build input. */ +function isXcodePackageNonBuildFile(fileName: string): boolean { + return ( + XCODE_PACKAGE_NON_BUILD_FILE_NAMES.has(fileName) || + XCODE_PACKAGE_NON_BUILD_FILE_EXTENSIONS.has(path.extname(fileName)) + ); +} + function isXcodePackageDirectory(directoryName: string): boolean { return directoryName.endsWith('.xcodeproj') || directoryName.endsWith('.xcworkspace'); } diff --git a/scripts/__tests__/apple-ci-impact.test.ts b/scripts/__tests__/apple-ci-impact.test.ts index 0151acde50..3cc8e306fc 100644 --- a/scripts/__tests__/apple-ci-impact.test.ts +++ b/scripts/__tests__/apple-ci-impact.test.ts @@ -72,17 +72,38 @@ type AppleRunnerBuildStep = { with?: Record; }; -function appleRunnerBuildAction(): { text: string; steps: AppleRunnerBuildStep[] } { +function appleRunnerBuildAction(): { + text: string; + steps: AppleRunnerBuildStep[]; + inputs: Record; +} { const action = fs.readFileSync( path.join(repoRoot, '.github/actions/setup-apple-runner-build/action.yml'), 'utf8', ); const doc = parse(action) as { + inputs?: Record; runs: { steps: AppleRunnerBuildStep[] }; }; - return { text: action, steps: doc.runs.steps }; + return { text: action, steps: doc.runs.steps, inputs: doc.inputs ?? {} }; } +/** + * The metadata writer resolves the build identity from these two values, so a job that omitted + * them must be refused when it starts rather than at re-publish time, after a full build. + */ +test('the Apple runner build action requires the identity its metadata writer reads', () => { + const { inputs } = appleRunnerBuildAction(); + expect(inputs['xcuitest-platform']).toEqual({ + required: true, + description: expect.stringContaining('AGENT_DEVICE_XCUITEST_PLATFORM'), + }); + expect(inputs['xcuitest-destination']).toEqual({ + required: true, + description: expect.stringContaining('AGENT_DEVICE_XCUITEST_DESTINATION'), + }); +}); + test('Apple runner build cache uses only declared source and schema hashes', () => { const { text, steps } = appleRunnerBuildAction(); const restoreIndex = steps.findIndex((step) => step.name === 'Restore Apple runner build cache'); diff --git a/scripts/write-xcuitest-cache-metadata.ts b/scripts/write-xcuitest-cache-metadata.ts index 118a205fd4..88aabeac69 100644 --- a/scripts/write-xcuitest-cache-metadata.ts +++ b/scripts/write-xcuitest-cache-metadata.ts @@ -4,6 +4,8 @@ import { pathToFileURL } from 'node:url'; import type { RunnerXctestrunCacheMetadata } from '@agent-device/platform-apple/runner/operations'; import { findRunnerXctestrun, + isRunnerXcuitestScriptPlatform, + requireCertifiedRunnerCacheArtifacts, requireRunnerBuildSettingsMatchBuildLog, resolveExistingRunnerProductPaths, resolveExpectedRunnerCacheMetadata, @@ -11,15 +13,9 @@ import { writeRunnerCacheMetadataForArtifacts, } from '@agent-device/platform-apple/runner/operations'; -type XcuitestCachePlatform = 'ios' | 'macos' | 'tvos' | 'visionos'; - const USAGE = 'Usage: write-xcuitest-cache-metadata.ts '; -function isScriptPlatform(value: string): value is XcuitestCachePlatform { - return value === 'ios' || value === 'macos' || value === 'tvos' || value === 'visionos'; -} - /** * Publishes the cache metadata for a `scripts/build-xcuitest-apple.sh` build. The identity half * resolves through the same owner the daemon uses; the recorded recipe is then checked against @@ -39,7 +35,7 @@ function parseWriterInvocation(args: readonly string[]): WriterInvocation { if (!platform || !derivedPath || !destination || !buildLogPath) { throw new Error(USAGE); } - if (!isScriptPlatform(platform)) { + if (!isRunnerXcuitestScriptPlatform(platform)) { throw new Error(`Unsupported platform: ${platform}`); } return { @@ -65,7 +61,10 @@ async function writeXcuitestCacheMetadata( if (!productPaths || productPaths.length === 0) { throw new Error(`Runner products referenced by ${xctestrunPath} are missing`); } - writeRunnerCacheMetadataForArtifacts(derivedPath, metadata, xctestrunPath, productPaths); + requireCertifiedRunnerCacheArtifacts( + writeRunnerCacheMetadataForArtifacts(derivedPath, metadata, xctestrunPath, productPaths), + derivedPath, + ); return metadata; } diff --git a/scripts/xcuitest-build-settings.ts b/scripts/xcuitest-build-settings.ts index 1503bf9db6..9ab9f596db 100644 --- a/scripts/xcuitest-build-settings.ts +++ b/scripts/xcuitest-build-settings.ts @@ -4,6 +4,7 @@ // compiler invocation and the cache identity cannot drift apart. import { pathToFileURL } from 'node:url'; import { + isRunnerXcuitestScriptPlatform, resolveRunnerArchBuildSettings, resolveRunnerBundleBuildSettings, resolveRunnerPerformanceBuildSettings, @@ -20,6 +21,9 @@ function resolveXcuitestBuildSettings( destination: string, env: NodeJS.ProcessEnv = process.env, ): string[] { + if (!isRunnerXcuitestScriptPlatform(platform)) { + throw new Error(`Unsupported platform: ${platform}`); + } const device = resolveRunnerScriptDevice(platform, destination); return [ ...resolveRunnerPerformanceBuildSettings(), From c99899e5d98f9d510725fe849abd69eb648a5787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 26 Sep 2026 12:26:53 +0200 Subject: [PATCH 4/7] refactor(ios-runner): keep manifest types at the module that raises them --- .../src/runner/__tests__/runner-xctestrun.fixtures.ts | 3 ++- packages/platform-apple/src/runner/runner-cache.ts | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts index d6293d392e..35bd9e2595 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts @@ -4,7 +4,8 @@ import { onTestFinished } from 'vitest'; import { fileURLToPath } from 'node:url'; import { mkdtempForTest } from './tmp-dir.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { writeRunnerCacheMetadataForArtifacts, type RunnerCacheRefusal } from '../runner-cache.ts'; +import { writeRunnerCacheMetadataForArtifacts } from '../runner-cache.ts'; +import type { RunnerCacheRefusal } from '../runner-artifact-manifest.ts'; import { resolveExpectedRunnerCacheMetadata } from '../runner-xctestrun.ts'; // Scratch trees and certified runner products shared by the tests that exercise diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts index a53813b54e..5d7ec78310 100644 --- a/packages/platform-apple/src/runner/runner-cache.ts +++ b/packages/platform-apple/src/runner/runner-cache.ts @@ -23,10 +23,6 @@ import { type RunnerCacheArtifactMismatch, type RunnerCacheRefusal, } from './runner-artifact-manifest.ts'; -export type { - RunnerCacheArtifactMismatch, - RunnerCacheRefusal, -} from './runner-artifact-manifest.ts'; export { requireRunnerPhaseRemainingMs, resolveExpectedRunnerCacheMetadata, From 2552602fe641d162d8b1f4906c743fdfe10a7d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 26 Sep 2026 14:24:07 +0200 Subject: [PATCH 5/7] =?UTF-8?q?perf(ios-runner):=20keep=20the=20certificat?= =?UTF-8?q?ion=20engine=20out=20of=20the=20fa=C3=A7ade=20closure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting the manifest out of `runner-cache.ts` made three platform façades (`app-lifecycle`, `doctor`, `runner-operations`) evaluate one module more than the merge-base did, which ADR-0019's implementation-laziness rule refuses. `runner-artifact-manifest.ts` digests whole product trees, so nothing on the metadata path should pay to load it. Both entry points into it are already `async`, so the edge moves behind a function-scoped `await import` as the gate prescribes; the mismatch and refusal types stay type-only and cost nothing. --- .../runner-artifact-manifest.test.ts | 52 +++++++++---------- .../__tests__/runner-artifact-reuse.test.ts | 18 +++---- .../runner/__tests__/runner-cache.fixtures.ts | 4 +- .../src/runner/__tests__/runner-cache.test.ts | 2 +- .../__tests__/runner-xctestrun.fixtures.ts | 6 +-- .../src/runner/runner-artifact.ts | 9 +++- .../platform-apple/src/runner/runner-cache.ts | 14 ++--- scripts/write-xcuitest-cache-metadata.ts | 2 +- 8 files changed, 56 insertions(+), 51 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts b/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts index cc6574774e..7922e2909c 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts @@ -23,7 +23,7 @@ import { stubAppleToolchainProbes(); test('a manifest-certified build is reused', async () => { - const { derived, xctestrunPath, executablePath, expected } = makeCachedRunnerBuild(); + const { derived, xctestrunPath, executablePath, expected } = await makeCachedRunnerBuild(); const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); @@ -36,7 +36,7 @@ test('a manifest-certified build is reused', async () => { }); test('executable bytes rewritten with equal size and preserved stats break reuse', async () => { - const { derived, executablePath, expected } = makeCachedRunnerBuild(); + const { derived, executablePath, expected } = await makeCachedRunnerBuild(); const stat = fs.statSync(executablePath); fs.writeFileSync(executablePath, Buffer.alloc(EXECUTABLE_BYTES.length, 9), { mode: 0o755 }); @@ -53,7 +53,7 @@ test('executable bytes rewritten with equal size and preserved stats break reuse }); test('a size change breaks reuse', async () => { - const { derived, executablePath, expected } = makeCachedRunnerBuild(); + const { derived, executablePath, expected } = await makeCachedRunnerBuild(); fs.appendFileSync(executablePath, 'x'); @@ -65,7 +65,7 @@ test('a size change breaks reuse', async () => { }); test('a lost permission bit breaks reuse', async () => { - const { derived, executablePath, expected } = makeCachedRunnerBuild(); + const { derived, executablePath, expected } = await makeCachedRunnerBuild(); fs.chmodSync(executablePath, 0o644); @@ -77,7 +77,7 @@ test('a lost permission bit breaks reuse', async () => { }); test('a missing executable breaks reuse', async () => { - const { derived, executablePath, expected } = makeCachedRunnerBuild(); + const { derived, executablePath, expected } = await makeCachedRunnerBuild(); fs.rmSync(executablePath); @@ -89,7 +89,7 @@ test('a missing executable breaks reuse', async () => { }); test('a certified executable replaced by a directory breaks reuse', async () => { - const { derived, executablePath, expected } = makeCachedRunnerBuild(); + const { derived, executablePath, expected } = await makeCachedRunnerBuild(); fs.rmSync(executablePath); fs.mkdirSync(executablePath); @@ -103,7 +103,7 @@ test('a certified executable replaced by a directory breaks reuse', async () => }); test('a file added under a certified product breaks reuse', async () => { - const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); + const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild(); fs.writeFileSync(path.join(runnerAppPath, 'injected.dylib'), 'injected'); @@ -117,11 +117,11 @@ test('a file added under a certified product breaks reuse', async () => { test('a certified symlink that starts escaping the cache root breaks reuse', async () => { const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); - const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); + const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild(); const linkPath = path.join(runnerAppPath, 'Frameworks'); fs.mkdirSync(path.join(runnerAppPath, 'FrameworksInside'), { recursive: true }); fs.symlinkSync('FrameworksInside', linkPath); - writeRunnerCacheMetadataForArtifacts(derived, expected, publishedXctestrun(derived), [ + await writeRunnerCacheMetadataForArtifacts(derived, expected, publishedXctestrun(derived), [ runnerAppPath, ]); @@ -137,12 +137,12 @@ test('a certified symlink that starts escaping the cache root breaks reuse', asy }); test('a symlink that changes target but stays inside the cache is a target change', async () => { - const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); + const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild(); const linkPath = path.join(runnerAppPath, 'Frameworks'); fs.mkdirSync(path.join(runnerAppPath, 'FrameworksInside'), { recursive: true }); fs.mkdirSync(path.join(runnerAppPath, 'FrameworksMoved'), { recursive: true }); fs.symlinkSync('FrameworksInside', linkPath); - writeRunnerCacheMetadataForArtifacts(derived, expected, publishedXctestrun(derived), [ + await writeRunnerCacheMetadataForArtifacts(derived, expected, publishedXctestrun(derived), [ runnerAppPath, ]); @@ -174,7 +174,7 @@ test('a product root that is a symlink out of the cache is never certified', asy fs.writeFileSync(xctestrunPath, 'xctestrun'); const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [linkedProduct]); + await writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [linkedProduct]); const published = JSON.parse( fs.readFileSync(resolveRunnerCacheMetadataPath(derived), 'utf8'), @@ -196,7 +196,7 @@ test('a symlinked product root that stays inside the cache is certified', async fs.writeFileSync(xctestrunPath, 'xctestrun'); const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [linkedProduct]); + await writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [linkedProduct]); // Names describe where the bytes actually live, not the link that reached them, so re-pointing // the in-cache symlink at another bundle cannot make reuse read an uncertified tree. @@ -225,7 +225,7 @@ test('a symlinked product root that stays inside the cache is certified', async test('a manifest that certifies an escaping symlink refuses reuse', async () => { const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); - const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); + const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild(); const escapingTarget = path.join(outside, 'Frameworks'); fs.symlinkSync(escapingTarget, path.join(runnerAppPath, 'Frameworks')); writeRunnerCacheMetadata(derived, { @@ -262,10 +262,10 @@ test('a manifest that certifies an escaping symlink refuses reuse', async () => }); test('a build holding an escaping symlink publishes no manifest at all', async () => { - const { derived, runnerAppPath, xctestrunPath, expected } = makeCachedRunnerBuild(); + const { derived, runnerAppPath, xctestrunPath, expected } = await makeCachedRunnerBuild(); fs.symlinkSync('../../../../../outside', path.join(runnerAppPath, 'Frameworks')); - writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]); + await writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]); const published = JSON.parse( fs.readFileSync(resolveRunnerCacheMetadataPath(derived), 'utf8'), @@ -274,7 +274,7 @@ test('a build holding an escaping symlink publishes no manifest at all', async ( }); test('an unreadable product subtree is refused and reported, not silently uncertified', async () => { - const { derived, runnerAppPath, expected, xctestrunPath } = makeCachedRunnerBuild(); + const { derived, runnerAppPath, expected, xctestrunPath } = await makeCachedRunnerBuild(); const sealed = path.join(runnerAppPath, 'Sealed.framework'); fs.mkdirSync(sealed, { recursive: true }); fs.writeFileSync(path.join(sealed, 'Binary'), 'binary'); @@ -283,7 +283,7 @@ test('an unreadable product subtree is refused and reported, not silently uncert fs.chmodSync(sealed, 0o755); }); - const refusal = writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [ + const refusal = await writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [ runnerAppPath, ]); @@ -300,7 +300,7 @@ test('an unreadable product subtree is refused and reported, not silently uncert test('a manifest whose .xctestrun was replaced by a symlink refuses reuse', async () => { const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); - const { derived, expected, runnerAppPath } = makeCachedRunnerBuild(); + const { derived, expected, runnerAppPath } = await makeCachedRunnerBuild(); const realXctestrun = path.join(outside, 'real.xctestrun'); fs.writeFileSync(realXctestrun, fs.readFileSync(publishedXctestrun(derived))); const linkedXctestrun = path.join(derived, 'Build', 'Products', 'linked.xctestrun'); @@ -340,14 +340,14 @@ test('a manifest whose .xctestrun was replaced by a symlink refuses reuse', asyn test('a build whose .xctestrun is a symlink out of the cache publishes no manifest', async () => { const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); - const { derived, runnerAppPath, expected } = makeCachedRunnerBuild(); + const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild(); const staged = path.join(outside, 'real.xctestrun'); fs.writeFileSync(staged, 'xctestrun'); const linkedXctestrun = path.join(derived, 'Build', 'Products', 'linked.xctestrun'); fs.rmSync(publishedXctestrun(derived)); fs.symlinkSync(staged, linkedXctestrun); - const refusal = writeRunnerCacheMetadataForArtifacts(derived, expected, linkedXctestrun, [ + const refusal = await writeRunnerCacheMetadataForArtifacts(derived, expected, linkedXctestrun, [ runnerAppPath, ]); @@ -359,7 +359,7 @@ test('a build whose .xctestrun is a symlink out of the cache publishes no manife }); test('products without a content manifest are a miss, never a reuse', async () => { - const { derived, expected } = makeCachedRunnerBuild(); + const { derived, expected } = await makeCachedRunnerBuild(); writeRunnerCacheMetadata(derived, expected); @@ -371,7 +371,7 @@ test('products without a content manifest are a miss, never a reuse', async () = test('a manifest naming paths outside the cache root is a miss', async () => { const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); - const { derived, expected } = makeCachedRunnerBuild(); + const { derived, expected } = await makeCachedRunnerBuild(); const foreignXctestrun = path.join(outside, 'foreign.xctestrun'); fs.writeFileSync(foreignXctestrun, 'xctestrun'); writeRunnerCacheMetadata(derived, { @@ -391,8 +391,8 @@ test('a manifest naming paths outside the cache root is a miss', async () => { }); test('a manifest written for a foreign cache root certifies nothing', async () => { - const { derived, expected } = makeCachedRunnerBuild(); - writeRunnerCacheMetadataForArtifacts( + const { derived, expected } = await makeCachedRunnerBuild(); + await writeRunnerCacheMetadataForArtifacts( derived, expected, path.join(derived, 'Build', 'Products', 'other.xctestrun'), @@ -423,7 +423,7 @@ test('a cache root reached through a symlinked ancestor is certified and reused' const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); assert.equal( - writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]), + await writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]), null, ); diff --git a/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts b/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts index 7e192debc7..57b84724b8 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts @@ -33,13 +33,13 @@ import { } from './runner-xctestrun.fixtures.ts'; /** Certifies a fixture tree the way the production writer would, and proves it worked. */ -function writeCertifiedRunnerMetadata(params: { +async function writeCertifiedRunnerMetadata(params: { derivedPath: string; device: DeviceInfo; xctestrunPath: string; productPaths: string[]; -}): void { - assert.equal(writeRunnerCacheMetadataWithArtifacts(params), null); +}): Promise { + assert.equal(await writeRunnerCacheMetadataWithArtifacts(params), null); } const mockRunCmdStreaming = vi.fn(); @@ -104,7 +104,7 @@ test('ensureXctestrunArtifact reuses matching manifest artifacts from another pr projectRoot: '/tmp/other-agent-device-worktree', productRelativePaths: ['Runner.app'], }); - writeCertifiedRunnerMetadata({ + await writeCertifiedRunnerMetadata({ derivedPath, device: macOsDevice, xctestrunPath, @@ -131,7 +131,7 @@ test('ensureXctestrunArtifact rebuilds foreign artifacts when metadata does not projectRoot: '/tmp/other-agent-device-worktree', productRelativePaths: ['Runner.app'], }); - writeCertifiedRunnerMetadata({ + await writeCertifiedRunnerMetadata({ derivedPath, device: macOsDevice, xctestrunPath: foreignXctestrunPath, @@ -368,7 +368,7 @@ test('ensureXctestrunArtifact prefers validated cache manifest over recursive sc new Date(now.getTime() + 5_000), new Date(now.getTime() + 5_000), ); - writeCertifiedRunnerMetadata({ + await writeCertifiedRunnerMetadata({ derivedPath, device: macOsDevice, xctestrunPath: manifestXctestrunPath, @@ -409,7 +409,7 @@ test('ensureXctestrunArtifact ignores a newer foreign xctestrun beside a certifi new Date(now.getTime() + 5_000), new Date(now.getTime() + 5_000), ); - writeCertifiedRunnerMetadata({ + await writeCertifiedRunnerMetadata({ derivedPath, device: macOsDevice, xctestrunPath: manifestXctestrunPath, @@ -435,7 +435,7 @@ test('ensureXctestrunArtifact discards and rebuilds a manifest whose bytes no lo projectRoot: repoRoot, productRelativePaths: ['Runner.app'], }); - writeCertifiedRunnerMetadata({ + await writeCertifiedRunnerMetadata({ derivedPath, device: macOsDevice, xctestrunPath: cachedXctestrunPath, @@ -586,7 +586,7 @@ test('ensureXctestrunArtifact stress-recovers after a bad restored artifact', as projectRoot, productRelativePaths: ['Runner.app'], }); - writeCertifiedRunnerMetadata({ + await writeCertifiedRunnerMetadata({ derivedPath, device: macOsDevice, xctestrunPath: cachedXctestrunPath, diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts index 717019515a..9e686764cd 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts @@ -28,7 +28,7 @@ export type CachedRunnerBuild = { * A cache root laid out the way a build leaves it: the `.xctestrun` under Build/Products, a * product bundle with an executable, and a manifest certifying both. */ -export function makeCachedRunnerBuild(): CachedRunnerBuild { +export async function makeCachedRunnerBuild(): Promise { const derived = mkdtempForTestSync('agent-device-runner-cache-eval-'); onTestFinished(() => fs.rmSync(derived, { recursive: true, force: true })); const productsPath = path.join(derived, 'Build', 'Products'); @@ -39,7 +39,7 @@ export function makeCachedRunnerBuild(): CachedRunnerBuild { const executablePath = path.join(runnerAppPath, 'Runner'); fs.writeFileSync(executablePath, EXECUTABLE_BYTES, { mode: 0o755 }); const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]); + await writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]); return { derived, xctestrunPath, runnerAppPath, executablePath, expected }; } diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts index 24b0dc8ad3..09af3e2307 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts @@ -11,7 +11,7 @@ import { makeCachedRunnerBuild } from './runner-cache.fixtures.ts'; stubAppleToolchainProbes(); test('reuse ignores the non-comparable package version', async () => { - const { derived, expected } = makeCachedRunnerBuild(); + const { derived, expected } = await makeCachedRunnerBuild(); const state = await evaluateExistingXctestrun({ derived, diff --git a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts index 35bd9e2595..c75afaac1a 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts @@ -131,12 +131,12 @@ export async function seedRunnerProductBundle(bundlePath: string): Promise * Publishes the metadata the production writer would, so a fixture tree is really certified. * Returns the refusal when the tree cannot be certified, which callers assert rather than ignore. */ -export function writeRunnerCacheMetadataWithArtifacts(params: { +export async function writeRunnerCacheMetadataWithArtifacts(params: { derivedPath: string; device: DeviceInfo; xctestrunPath: string; productPaths: string[]; -}): RunnerCacheRefusal | null { +}): Promise { return writeRunnerCacheMetadataForArtifacts( params.derivedPath, resolveExpectedRunnerCacheMetadata(params.device, REPO_ROOT_FOR_TEST), @@ -163,7 +163,7 @@ export async function makeCachedRunnerXctestrun(device: DeviceInfo): Promise<{ projectRoot: REPO_ROOT_FOR_TEST, productRelativePaths: ['Runner.app'], }); - writeRunnerCacheMetadataWithArtifacts({ + await writeRunnerCacheMetadataWithArtifacts({ derivedPath, device, xctestrunPath: existingXctestrunPath, diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index 6326acbec1..1eda255742 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -278,7 +278,12 @@ async function buildXctestrunArtifact(params: { // The manifest is written last so it certifies the bytes that actually run. await applyXctestRunnerAppIcon(builtProductPaths); requireCertifiedRunnerCacheArtifacts( - writeRunnerCacheMetadataForArtifacts(derived, expectedCacheMetadata, built, builtProductPaths), + await writeRunnerCacheMetadataForArtifacts( + derived, + expectedCacheMetadata, + built, + builtProductPaths, + ), derived, ); emitRunnerXctestrunDecision('build', 'built_new', { @@ -309,7 +314,7 @@ async function tryReuseExistingXctestrun( xctestrunPath: existing.xctestrunPath, }); requireCertifiedRunnerCacheArtifacts( - writeRunnerCacheMetadataForArtifacts( + await writeRunnerCacheMetadataForArtifacts( derived, expectedCacheMetadata, existing.xctestrunPath, diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts index 5d7ec78310..abfd2f9305 100644 --- a/packages/platform-apple/src/runner/runner-cache.ts +++ b/packages/platform-apple/src/runner/runner-cache.ts @@ -17,11 +17,9 @@ import { type RunnerCacheMetadataDifference, type RunnerXctestrunCacheMetadata, } from './runner-cache-metadata.ts'; -import { - buildRunnerCacheArtifactManifest, - validateRunnerCacheArtifactManifest, - type RunnerCacheArtifactMismatch, - type RunnerCacheRefusal, +import type { + RunnerCacheArtifactMismatch, + RunnerCacheRefusal, } from './runner-artifact-manifest.ts'; export { requireRunnerPhaseRemainingMs, @@ -162,12 +160,13 @@ export function cleanRunnerDerivedBeforeEvaluation(derived: string, forceRebuild * miss, and the refusal is returned so the caller can name it. Silently uncertifiable products * would otherwise cost a full rebuild on every launch with nothing to trace. */ -export function writeRunnerCacheMetadataForArtifacts( +export async function writeRunnerCacheMetadataForArtifacts( derived: string, metadata: RunnerXctestrunCacheMetadata, xctestrunPath: string, productPaths: readonly string[], -): RunnerCacheRefusal | null { +): Promise { + const { buildRunnerCacheArtifactManifest } = await import('./runner-artifact-manifest.ts'); const built = buildRunnerCacheArtifactManifest(derived, xctestrunPath, productPaths); writeRunnerCacheMetadata( derived, @@ -331,6 +330,7 @@ export async function evaluateExistingXctestrun(options: { } : { reason: cacheMetadata.reason, xctestrunPath: null, productPaths: [] }; } + const { validateRunnerCacheArtifactManifest } = await import('./runner-artifact-manifest.ts'); const artifacts = validateRunnerCacheArtifactManifest(options.derived, cacheMetadata.metadata); if (!artifacts.ok) { return artifacts.mismatch diff --git a/scripts/write-xcuitest-cache-metadata.ts b/scripts/write-xcuitest-cache-metadata.ts index 88aabeac69..f4b8e8c4dd 100644 --- a/scripts/write-xcuitest-cache-metadata.ts +++ b/scripts/write-xcuitest-cache-metadata.ts @@ -62,7 +62,7 @@ async function writeXcuitestCacheMetadata( throw new Error(`Runner products referenced by ${xctestrunPath} are missing`); } requireCertifiedRunnerCacheArtifacts( - writeRunnerCacheMetadataForArtifacts(derivedPath, metadata, xctestrunPath, productPaths), + await writeRunnerCacheMetadataForArtifacts(derivedPath, metadata, xctestrunPath, productPaths), derivedPath, ); return metadata; From 712552921f81fdb12ec72a1a312243021b09f3b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 26 Sep 2026 15:23:13 +0200 Subject: [PATCH 6/7] fix(ios-runner): certify one root once and never name a symlinked xctestrun The second review round found two ways a manifest and its reader disagreed: - two `productPaths` that resolve to the same directory -- a direct bundle beside an in-cache alias -- each became a walked root, so the leaves were collected twice and the reader's walk called the second root's copies undeclared. `resolveWalkedRoots` now collapses equal canonical roots, not just nested ones; shared by both sides, so a manifest already written certifies the same way it is read. - the writer digested an in-cache symlink at the `.xctestrun` path while the reader lstats the named path and calls a symlink a kind change, so every such entry missed on sight. The writer now checks the named path is a regular file before digesting. Certification also moved ahead of the `reuse_ready` decision, so diagnostics cannot record a reuse that the gate then refused. The remaining findings were test honesty: fixtures assert the refusal their own contract promises, the foreign-artifacts reuse case carries real digests so only containment can fail it, the toolchain stub is registered by the fixture that needs it, and the Xcode-package comment sits above the predicate it documents. --- .../src/runner/__tests__/digest-file.ts | 7 +++ .../runner-artifact-manifest.test.ts | 55 ++++++++++++++++-- .../__tests__/runner-artifact-reuse.test.ts | 57 +++++++++++++++++-- .../runner/__tests__/runner-cache.fixtures.ts | 17 ++++-- .../src/runner/__tests__/runner-cache.test.ts | 2 - .../__tests__/runner-xctestrun.fixtures.ts | 18 ++++-- .../src/runner/runner-artifact-manifest.ts | 15 ++++- .../src/runner/runner-artifact.ts | 8 +-- .../src/runner/runner-source.ts | 10 ++-- 9 files changed, 156 insertions(+), 33 deletions(-) create mode 100644 packages/platform-apple/src/runner/__tests__/digest-file.ts diff --git a/packages/platform-apple/src/runner/__tests__/digest-file.ts b/packages/platform-apple/src/runner/__tests__/digest-file.ts new file mode 100644 index 0000000000..853a603404 --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/digest-file.ts @@ -0,0 +1,7 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; + +/** sha256 of a file's bytes, the same digest a cache manifest records. */ +export function digestFile(filePath: string): string { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} diff --git a/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts b/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts index 7922e2909c..90af9e40fd 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts @@ -10,7 +10,6 @@ import { } from '../runner-cache.ts'; import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts'; import { IOS_SIMULATOR } from './device-fixtures.ts'; -import { stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; import { EXECUTABLE_BYTES, @@ -20,8 +19,6 @@ import { publishedXctestrun, } from './runner-cache.fixtures.ts'; -stubAppleToolchainProbes(); - test('a manifest-certified build is reused', async () => { const { derived, xctestrunPath, executablePath, expected } = await makeCachedRunnerBuild(); @@ -222,6 +219,27 @@ test('a symlinked product root that stays inside the cache is certified', async assert.equal(state.mismatch.reason, 'undeclared_entry'); }); +test('a direct product path beside a symlink alias of it certifies one walk', async () => { + // Xcode hands the .xctestrun a product per relative path; two spellings of one bundle (a + // direct path plus an in-cache alias) must collapse to one walked root, or the leaves get + // collected twice and the reader's walk calls the second root's copies undeclared. + const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild(); + const aliasPath = path.join(path.dirname(runnerAppPath), 'Runner.alias.app'); + fs.symlinkSync(path.basename(runnerAppPath), aliasPath); + + assert.equal( + await writeRunnerCacheMetadataForArtifacts(derived, expected, publishedXctestrun(derived), [ + runnerAppPath, + aliasPath, + ]), + null, + ); + + const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); + + assert.equal(state.reason, 'reuse_ready'); +}); + test('a manifest that certifies an escaping symlink refuses reuse', async () => { const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); @@ -358,6 +376,28 @@ test('a build whose .xctestrun is a symlink out of the cache publishes no manife assert.equal(published.artifacts, undefined); }); +test('a build whose .xctestrun is an in-cache symlink publishes no manifest', async () => { + // The reader lstats the named path and calls a symlink a kind change, so a manifest written + // for a link would be refused on sight: a rebuild on every launch. The writer must decline to + // publish that pairing rather than hand every cache entry to the miss path. + const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild(); + const productsPath = path.join(derived, 'Build', 'Products'); + const realXctestrun = path.join(productsPath, 'staged.xctestrun'); + fs.writeFileSync(realXctestrun, 'xctestrun'); + const linkedXctestrun = path.join(productsPath, 'linked.xctestrun'); + fs.symlinkSync('staged.xctestrun', linkedXctestrun); + + const refusal = await writeRunnerCacheMetadataForArtifacts(derived, expected, linkedXctestrun, [ + runnerAppPath, + ]); + + assert.equal(refusal?.reason, 'root_unusable'); + + const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); + + assert.equal(state.reason, 'artifact_manifest_missing'); +}); + test('products without a content manifest are a miss, never a reuse', async () => { const { derived, expected } = await makeCachedRunnerBuild(); @@ -390,15 +430,20 @@ test('a manifest naming paths outside the cache root is a miss', async () => { assert.equal(state.reason, 'artifact_manifest_missing'); }); -test('a manifest written for a foreign cache root certifies nothing', async () => { +test('a writer handed an .xctestrun path that does not exist certifies nothing', async () => { + // The reader-side crossing-root defense is 'a manifest naming paths outside the cache root is + // a miss'; this is the writer half: a named path with no bytes behind it cannot be digested, + // so the identity half is published and the tree stays a miss. const { derived, expected } = await makeCachedRunnerBuild(); - await writeRunnerCacheMetadataForArtifacts( + const refusal = await writeRunnerCacheMetadataForArtifacts( derived, expected, path.join(derived, 'Build', 'Products', 'other.xctestrun'), [path.join(derived, 'Build', 'Products', 'Debug-iphonesimulator', 'Runner-Runner.app')], ); + assert.equal(refusal?.reason, 'root_unusable'); + const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); assert.equal(state.reason, 'artifact_manifest_missing'); diff --git a/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts b/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts index 57b84724b8..fb737f09ad 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-artifact-reuse.test.ts @@ -18,6 +18,7 @@ import { resolveRunnerDerivedPath, } from '../runner-xctestrun.ts'; import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts'; +import { digestFile } from './digest-file.ts'; import { REPO_ROOT_FOR_TEST as repoRoot, makeCachedRunnerXctestrun, @@ -178,6 +179,10 @@ test('ensureXctestrunArtifact ignores manifest artifacts outside the cache root' await fs.promises.mkdir(derivedPath, { recursive: true }); // A manifest naming paths outside its own cache root cannot have been written for this tree, // so the reader declines it. The production writer refuses to publish one, hence the hand-off. + // Every byte the manifest describes is hashed from where it really sits, so nothing but the + // crossing paths can make this manifest fail: a regression that dropped the containment check + // would certify or content-mismatch, not the pinned manifest miss below. + const externalExecutablePath = path.join(externalProductPath, 'Runner'); fs.writeFileSync( resolveRunnerCacheMetadataPath(derivedPath), JSON.stringify({ @@ -185,9 +190,16 @@ test('ensureXctestrunArtifact ignores manifest artifacts outside the cache root' artifacts: { xctestrunPath: externalXctestrunPath, xctestrunSize: fs.statSync(externalXctestrunPath).size, - xctestrunDigest: '0'.repeat(64), + xctestrunDigest: digestFile(externalXctestrunPath), productPaths: [externalProductPath], - entries: [{ path: 'Runner', size: 1, mode: 0o755, digest: '1'.repeat(64) }], + entries: [ + { + path: 'Runner', + size: fs.statSync(externalExecutablePath).size, + mode: 0o755, + digest: digestFile(externalExecutablePath), + }, + ], }, }), ); @@ -201,9 +213,10 @@ test('ensureXctestrunArtifact ignores manifest artifacts outside the cache root' }); }); - const result = (await ensureXctestrunArtifact(macOsDevice, {})).xctestrunPath; + const result = await ensureXctestrunArtifact(macOsDevice, {}); - assert.equal(result, rebuiltXctestrunPath); + assert.equal(result.xctestrunPath, rebuiltXctestrunPath); + assert.equal(result.reason, 'artifact_manifest_missing'); assert.equal(mockRunCmdStreaming.mock.calls.length, 1); }); @@ -295,6 +308,42 @@ test('ensureXctestrunArtifact aborts only the disconnected request build and pre assert.ok(survivorCall, 'survivor build received its request signal'); }); +test('ensureXctestrunArtifact never reports reuse of a tree that fails certification', async () => { + // A repair step that leaves an escaping symlink behind poisons the tree AFTER evaluation + // called it reusable. Certification catches it and throws, but only the ORDER of the + // `reuse_ready` decision proves the gate is real: emitted before it, diagnostics would record + // a reuse that never happened. + const { derivedPath } = await makeCachedRunnerXctestrun(macOsDevice); + const outside = await makeProjectScratchDir(); + withRunnerDerivedPathEnv(derivedPath); + const decisions: Array> = []; + appleRunnerTestHost.update({ + emitDiagnostic: (event) => { + if (event.phase === 'runner_xctestrun_cache') { + decisions.push(event.data ?? {}); + } + }, + }); + mockRepairMacOsRunnerProductsIfNeeded.mockImplementation(async () => { + fs.symlinkSync(path.join(outside, 'evil'), path.join(derivedPath, 'Runner.app', 'Leaked')); + }); + + await assert.rejects(() => ensureXctestrunArtifact(macOsDevice, {}), { + code: 'COMMAND_FAILED', + message: 'The Apple runner products cannot be certified for cache reuse', + }); + + assert.equal( + decisions.some((data) => data.reason === 'reuse_ready'), + false, + ); + assert.ok( + decisions.some((data) => data.reason === 'uncertifiable_products'), + 'the refusal is what the diagnostics record instead', + ); + assert.equal(mockRunCmdStreaming.mock.calls.length, 0); +}); + test('ensureXctestrunArtifact rebuilds after cached macOS runner repair failure', async () => { // Cached runner artifacts can look reusable until ad-hoc repair fails; ensure we clean once, // rebuild, and return the repaired rebuilt xctestrun instead of looping on stale cache state. diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts index 9e686764cd..7054fd3746 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts @@ -1,14 +1,18 @@ import assert from 'node:assert/strict'; -import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { onTestFinished } from 'vitest'; import { writeRunnerCacheMetadataForArtifacts } from '../runner-cache.ts'; import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts'; import type { ExistingXctestrunState, RunnerXctestrunCacheMetadata } from '../runner-cache.ts'; +import { stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; +// The identity half reads the host toolchain, so the fixture that builds a manifest answers those +// probes itself: a consumer must not have to know it is importing an Xcode-touching helper. +stubAppleToolchainProbes(); + /** * A cache root and a content manifest for the tests that decide what a manifest certifies: the * bytes must exist before a digest can name them, and the layout must be the one a build leaves. @@ -39,7 +43,12 @@ export async function makeCachedRunnerBuild(): Promise { const executablePath = path.join(runnerAppPath, 'Runner'); fs.writeFileSync(executablePath, EXECUTABLE_BYTES, { mode: 0o755 }); const expected = resolveExpectedRunnerCacheMetadata(IOS_SIMULATOR); - await writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]); + // A fixture tree that cannot be certified would leave every consumer facing an opaque + // `artifact_manifest_missing` instead of the named refusal the writer reports. + assert.equal( + await writeRunnerCacheMetadataForArtifacts(derived, expected, xctestrunPath, [runnerAppPath]), + null, + ); return { derived, xctestrunPath, runnerAppPath, executablePath, expected }; } @@ -47,9 +56,7 @@ export function publishedXctestrun(derived: string): string { return path.join(derived, 'Build', 'Products', 'Runner_iphonesimulator26.2-arm64.xctestrun'); } -export function digest(filePath: string): string { - return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); -} +export { digestFile as digest } from './digest-file.ts'; export function mismatchOf( state: ExistingXctestrunState, diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts index 09af3e2307..315b66cb33 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache.test.ts @@ -4,12 +4,10 @@ import { onTestFinished, test } from 'vitest'; import { evaluateExistingXctestrun, writeRunnerCacheMetadata } from '../runner-cache.ts'; import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts'; import { IOS_SIMULATOR } from './device-fixtures.ts'; -import { stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; import { restoreEnvVar } from './runner-xctestrun.fixtures.ts'; import { makeCachedRunnerBuild } from './runner-cache.fixtures.ts'; -stubAppleToolchainProbes(); test('reuse ignores the non-comparable package version', async () => { const { derived, expected } = await makeCachedRunnerBuild(); diff --git a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts index c75afaac1a..4386a3844b 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-xctestrun.fixtures.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { onTestFinished } from 'vitest'; @@ -163,11 +164,16 @@ export async function makeCachedRunnerXctestrun(device: DeviceInfo): Promise<{ projectRoot: REPO_ROOT_FOR_TEST, productRelativePaths: ['Runner.app'], }); - await writeRunnerCacheMetadataWithArtifacts({ - derivedPath, - device, - xctestrunPath: existingXctestrunPath, - productPaths: [path.join(derivedPath, 'Runner.app')], - }); + // The reuse tests that take this tree are asserting behaviour on a CERTIFIED cache; an + // uncertifiable one would silently exercise a different scenario than they set up. + assert.equal( + await writeRunnerCacheMetadataWithArtifacts({ + derivedPath, + device, + xctestrunPath: existingXctestrunPath, + productPaths: [path.join(derivedPath, 'Runner.app')], + }), + null, + ); return { derivedPath, existingXctestrunPath }; } diff --git a/packages/platform-apple/src/runner/runner-artifact-manifest.ts b/packages/platform-apple/src/runner/runner-artifact-manifest.ts index c201e4fd5f..05283c4d9a 100644 --- a/packages/platform-apple/src/runner/runner-artifact-manifest.ts +++ b/packages/platform-apple/src/runner/runner-artifact-manifest.ts @@ -120,7 +120,10 @@ function digestXctestrun( if (!isPathInsideDirectory(resolved, canonicalCacheRoot)) { return { reason: 'root_escapes_cache', path: relativePath, target: resolved }; } - if (!isRegularFile(resolved)) { + // The manifest names a regular file at this exact path; the reader lstats it and calls a + // symlink a kind change. Certifying an in-cache link here would publish a manifest the reader + // refuses at first sight, i.e. a rebuild on every launch. + if (!isRegularFile(xctestrunPath)) { return { reason: 'root_unusable', path: relativePath }; } const digested = digestFile(xctestrunPath); @@ -168,7 +171,15 @@ function resolveWalkedRoots( }, }; } - if (!roots.some((keptRoot) => isPathInsideDirectory(resolvedRoot, keptRoot))) { + // `isPathInsideDirectory` is strict, so an alias resolving onto a root already walked -- a + // direct path beside an in-cache symlink to the same bundle -- needs its own equality test. + // Without it the leaves would be collected twice and the reader's walk would delete them + // under the first root and call the second root's copies undeclared. + if ( + !roots.some( + (keptRoot) => resolvedRoot === keptRoot || isPathInsideDirectory(resolvedRoot, keptRoot), + ) + ) { roots.push(resolvedRoot); } } diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index 1eda255742..52b06c1d39 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -309,10 +309,6 @@ async function tryReuseExistingXctestrun( ): Promise { try { await repairMacOsRunnerProductsIfNeeded(device, existing.productPaths, existing.xctestrunPath); - emitRunnerXctestrunDecision('reuse', 'reuse_ready', { - derived, - xctestrunPath: existing.xctestrunPath, - }); requireCertifiedRunnerCacheArtifacts( await writeRunnerCacheMetadataForArtifacts( derived, @@ -322,6 +318,10 @@ async function tryReuseExistingXctestrun( ), derived, ); + emitRunnerXctestrunDecision('reuse', 'reuse_ready', { + derived, + xctestrunPath: existing.xctestrunPath, + }); return existing.xctestrunPath; } catch (error) { if (!isExpectedRunnerRepairFailure(error)) { diff --git a/packages/platform-apple/src/runner/runner-source.ts b/packages/platform-apple/src/runner/runner-source.ts index a6cc0c3a0e..58a2678cca 100644 --- a/packages/platform-apple/src/runner/runner-source.ts +++ b/packages/platform-apple/src/runner/runner-source.ts @@ -157,11 +157,6 @@ function collectRunnerSourceFilesInDirectory( return files; } -/** - * Xcode owns the contents of a project or workspace package — `project.pbxproj`, shared - * schemes, and workspace data all change what a build produces — so every file inside one - * is build input, not just the ones with a recognizable extension. - */ /** Whether a file inside an Xcode package is Finder or editor droppings rather than build input. */ function isXcodePackageNonBuildFile(fileName: string): boolean { return ( @@ -170,6 +165,11 @@ function isXcodePackageNonBuildFile(fileName: string): boolean { ); } +/** + * Xcode owns the contents of a project or workspace package — `project.pbxproj`, shared + * schemes, and workspace data all change what a build produces — so every file inside one + * is build input, not just the ones with a recognizable extension. + */ function isXcodePackageDirectory(directoryName: string): boolean { return directoryName.endsWith('.xcodeproj') || directoryName.endsWith('.xcworkspace'); } From 5086a95f2eab626bc04be0227e6583ce61be75c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 26 Sep 2026 16:30:41 +0200 Subject: [PATCH 7/7] fix(ios-runner): make root dedupe order-independent and document the fail-closed launch Reading the delta back, `resolveWalkedRoots` collapsed duplicates only against roots kept earlier in lexical order, so a descendant spelled first still walked its subtree twice. Dedupe now runs once over the fully resolved set: sort, then drop equal or nested candidates, which is the same kept set whatever order the caller used. The lexical pre-pass is gone with it, since it could drop a path whose lexical ancestor resolves somewhere else. The review's open question -- may a fresh build fail to launch when the writer refuses the tree -- is answered yes, and `prepare ios-runner` now says so: the refusal is the point, and the hint names the tree to replace. --- .../runner-artifact-manifest.test.ts | 32 ++++++++++++++--- .../runner/__tests__/runner-cache.fixtures.ts | 2 -- .../src/runner/runner-artifact-manifest.ts | 35 +++++++++---------- website/docs/docs/commands.md | 1 + 4 files changed, 44 insertions(+), 26 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts b/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts index 90af9e40fd..421538be97 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-artifact-manifest.test.ts @@ -13,11 +13,11 @@ import { IOS_SIMULATOR } from './device-fixtures.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; import { EXECUTABLE_BYTES, - digest, makeCachedRunnerBuild, mismatchOf, publishedXctestrun, } from './runner-cache.fixtures.ts'; +import { digestFile } from './digest-file.ts'; test('a manifest-certified build is reused', async () => { const { derived, xctestrunPath, executablePath, expected } = await makeCachedRunnerBuild(); @@ -240,6 +240,28 @@ test('a direct product path beside a symlink alias of it certifies one walk', as assert.equal(state.reason, 'reuse_ready'); }); +test('roots keep one walk whatever order their paths are spelled in', async () => { + // Descendant-first spelling must not survive as two roots: sorted-then-filtered is what makes + // the kept set identical to the alias-first spelling above, not the caller's ordering. + const { derived, runnerAppPath, expected } = await makeCachedRunnerBuild(); + const executablePath = path.join(runnerAppPath, 'Runner'); + const productsPath = path.join(derived, 'Build', 'Products'); + + assert.equal( + await writeRunnerCacheMetadataForArtifacts(derived, expected, publishedXctestrun(derived), [ + executablePath, + runnerAppPath, + path.join(productsPath, 'Debug-iphonesimulator'), + runnerAppPath, + ]), + null, + ); + + const state = await evaluateExistingXctestrun({ derived, expectedCacheMetadata: expected }); + + assert.equal(state.reason, 'reuse_ready'); +}); + test('a manifest that certifies an escaping symlink refuses reuse', async () => { const outside = mkdtempForTestSync('agent-device-runner-cache-outside-'); onTestFinished(() => fs.rmSync(outside, { recursive: true, force: true })); @@ -251,7 +273,7 @@ test('a manifest that certifies an escaping symlink refuses reuse', async () => artifacts: { xctestrunPath: publishedXctestrun(derived), xctestrunSize: fs.statSync(publishedXctestrun(derived)).size, - xctestrunDigest: digest(publishedXctestrun(derived)), + xctestrunDigest: digestFile(publishedXctestrun(derived)), productPaths: [runnerAppPath], entries: [ { @@ -266,7 +288,7 @@ test('a manifest that certifies an escaping symlink refuses reuse', async () => .replaceAll(path.sep, '/'), size: EXECUTABLE_BYTES.length, mode: 0o755, - digest: digest(path.join(runnerAppPath, 'Runner')), + digest: digestFile(path.join(runnerAppPath, 'Runner')), }, ], }, @@ -330,7 +352,7 @@ test('a manifest whose .xctestrun was replaced by a symlink refuses reuse', asyn artifacts: { xctestrunPath: linkedXctestrun, xctestrunSize: fs.statSync(realXctestrun).size, - xctestrunDigest: digest(realXctestrun), + xctestrunDigest: digestFile(realXctestrun), productPaths: [runnerAppPath], entries: [ { @@ -339,7 +361,7 @@ test('a manifest whose .xctestrun was replaced by a symlink refuses reuse', asyn .replaceAll(path.sep, '/'), size: EXECUTABLE_BYTES.length, mode: 0o755, - digest: digest(path.join(runnerAppPath, 'Runner')), + digest: digestFile(path.join(runnerAppPath, 'Runner')), }, ], }, diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts index 7054fd3746..3ed5c010e9 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-cache.fixtures.ts @@ -56,8 +56,6 @@ export function publishedXctestrun(derived: string): string { return path.join(derived, 'Build', 'Products', 'Runner_iphonesimulator26.2-arm64.xctestrun'); } -export { digestFile as digest } from './digest-file.ts'; - export function mismatchOf( state: ExistingXctestrunState, ): Extract { diff --git a/packages/platform-apple/src/runner/runner-artifact-manifest.ts b/packages/platform-apple/src/runner/runner-artifact-manifest.ts index 05283c4d9a..b9a8486291 100644 --- a/packages/platform-apple/src/runner/runner-artifact-manifest.ts +++ b/packages/platform-apple/src/runner/runner-artifact-manifest.ts @@ -155,8 +155,8 @@ function resolveWalkedRoots( cacheRoot: string, canonicalCacheRoot: string, ): RunnerCacheWalkedRoots { - const roots: string[] = []; - for (const productPath of dedupeNestedPaths(productPaths)) { + const resolved: string[] = []; + for (const productPath of productPaths) { const resolvedRoot = resolveRealPath(productPath); if (resolvedRoot === null) { return { ok: false, refusal: unusableRootRefusal(cacheRoot, productPath) }; @@ -171,31 +171,28 @@ function resolveWalkedRoots( }, }; } - // `isPathInsideDirectory` is strict, so an alias resolving onto a root already walked -- a - // direct path beside an in-cache symlink to the same bundle -- needs its own equality test. - // Without it the leaves would be collected twice and the reader's walk would delete them - // under the first root and call the second root's copies undeclared. - if ( - !roots.some( - (keptRoot) => resolvedRoot === keptRoot || isPathInsideDirectory(resolvedRoot, keptRoot), - ) - ) { - roots.push(resolvedRoot); - } + resolved.push(resolvedRoot); } + const roots = dropNestedRoots(resolved); if (roots.length > 0) { return { ok: true, roots }; } return { ok: false, refusal: { reason: 'root_unusable', path: productPaths.join(', ') } }; } -/** Drop paths whose ancestor is already walked, so no subtree is collected twice. */ -function dedupeNestedPaths(productPaths: readonly string[]): string[] { - const sorted = [...new Set(productPaths.map((target) => path.resolve(target)))].sort(); +/** + * The kept set of resolved roots, identical whatever order the caller spelled them in: sorted + * so every ancestor precedes its descendants, then an equal or nested candidate is dropped. + * Deduplicating in input order instead would keep a descendant whose ancestor arrives later, + * walking that subtree twice, and an alias resolving onto a kept root would ride along as a + * second root whose leaves the reader calls undeclared. + */ +function dropNestedRoots(resolvedRoots: readonly string[]): string[] { const kept: string[] = []; - for (const candidate of sorted) { - if (kept.some((keptPath) => isPathInsideDirectory(candidate, keptPath))) continue; - kept.push(candidate); + for (const candidate of new Set([...resolvedRoots].sort())) { + if (!kept.some((keptRoot) => isPathInsideDirectory(candidate, keptRoot))) { + kept.push(candidate); + } } return kept; } diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 73254c53ec..2b4ad186af 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -271,6 +271,7 @@ agent-device prepare ios-runner --platform ios --timeout 240000 - 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. - 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. ## TV targets