From acd5fdefc3f3725741d7b003372cbbba4579de90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 06:55:03 +0200 Subject: [PATCH 01/27] refactor(capture-kit): read swift cache, app-log and PNG worker settings from the command host The PNG worker starts with an empty environment and reports unavailability through diagnostics instead of a process warning. Co-Authored-By: Claude Sonnet 5.5 --- .../src/capture-admission/app-log-files.ts | 6 ++++- .../src/host-environment.fixtures.ts | 16 ++++++++++++ packages/capture-kit/src/png-worker-client.ts | 7 ++--- .../src/recording/__tests__/overlay.test.ts | 3 +++ .../recording/contact-sheet-frames.test.ts | 3 +++ .../src/recording/contact-sheet.test.ts | 3 +++ .../src/recording/swift-cache.test.ts | 26 ++++++++++++++++++- .../capture-kit/src/recording/swift-cache.ts | 14 +++++----- .../src/resources/__tests__/app-log.test.ts | 8 +++++- src/platform-runtime-app-log-output.test.ts | 4 ++- 10 files changed, 75 insertions(+), 15 deletions(-) create mode 100644 packages/capture-kit/src/host-environment.fixtures.ts diff --git a/packages/capture-kit/src/capture-admission/app-log-files.ts b/packages/capture-kit/src/capture-admission/app-log-files.ts index 1406b4ccb4..6de833b822 100644 --- a/packages/capture-kit/src/capture-admission/app-log-files.ts +++ b/packages/capture-kit/src/capture-admission/app-log-files.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { AppError } from '@agent-device/kernel/errors'; import { lstatIfPresent, NOT_REGULAR_FILE_HINT } from '@agent-device/host-kit/file'; @@ -12,7 +13,10 @@ export type AppLogRotationConfig = Readonly<{ }>; /** Shared lower-level file policy for daemon markers and runtime output sinks. */ -export function ensureAppLogPath(outPath: string, env: NodeJS.ProcessEnv = process.env): void { +export function ensureAppLogPath( + outPath: string, + env: NodeJS.ProcessEnv = commandHostEnvironment().env, +): void { const directory = path.dirname(outPath); if (!fs.existsSync(directory)) fs.mkdirSync(directory, { recursive: true }); assertAppLogFileIsNotSymbolicLink(outPath); diff --git a/packages/capture-kit/src/host-environment.fixtures.ts b/packages/capture-kit/src/host-environment.fixtures.ts new file mode 100644 index 0000000000..9bea7aa097 --- /dev/null +++ b/packages/capture-kit/src/host-environment.fixtures.ts @@ -0,0 +1,16 @@ +import { aroundEach } from 'vitest'; +import { withCommandHost } from '@agent-device/host-kit/command'; + +/** + * Runs every test of the calling suite as a command host whose environment is the live process + * environment. Test-kit owns the shared helper; capture-kit is a dependency of test-kit, so it + * keeps this copy local. + */ +export function bindTestCommandHost(): void { + aroundEach(async (runTest) => { + await withCommandHost( + { host: { env: process.env, cwd: process.cwd() }, requestEnv: undefined }, + async () => await runTest(), + ); + }); +} diff --git a/packages/capture-kit/src/png-worker-client.ts b/packages/capture-kit/src/png-worker-client.ts index 994e1cedf0..c18f02abac 100644 --- a/packages/capture-kit/src/png-worker-client.ts +++ b/packages/capture-kit/src/png-worker-client.ts @@ -48,12 +48,7 @@ function markWorkerUnavailable(reason: string): void { workerUnavailable = true; if (warnedWorkerUnavailable) return; warnedWorkerUnavailable = true; - // Worker failures can surface outside a diagnostics scope (e.g. daemon - // startup pre-warm), so pair the scoped diagnostic with a process warning. emitDiagnostic({ level: 'warn', phase: 'png_worker_unavailable', data: { reason } }); - process.emitWarning( - `PNG worker unavailable, falling back to in-process PNG processing: ${reason}`, - ); } function handleWorkerMessage(message: PngWorkerResponse): void { @@ -110,7 +105,9 @@ function obtainWorker(): Worker | null { return null; } try { + // The worker decodes and encodes bytes it is sent; it needs none of the host's environment. const created = new Worker(modulePath, { + env: {}, execArgv: modulePath.endsWith('.ts') ? ['--experimental-strip-types'] : [], }); created.on('message', handleWorkerMessage); diff --git a/packages/capture-kit/src/recording/__tests__/overlay.test.ts b/packages/capture-kit/src/recording/__tests__/overlay.test.ts index 9a54eec10d..892c629b57 100644 --- a/packages/capture-kit/src/recording/__tests__/overlay.test.ts +++ b/packages/capture-kit/src/recording/__tests__/overlay.test.ts @@ -2,6 +2,9 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts'; +import { bindTestCommandHost } from '../../host-environment.fixtures.ts'; + +bindTestCommandHost(); vi.mock('@agent-device/host-kit/command', async (importOriginal) => { const fs = await import('node:fs'); diff --git a/packages/capture-kit/src/recording/contact-sheet-frames.test.ts b/packages/capture-kit/src/recording/contact-sheet-frames.test.ts index b8266c16ab..9cb949a8dd 100644 --- a/packages/capture-kit/src/recording/contact-sheet-frames.test.ts +++ b/packages/capture-kit/src/recording/contact-sheet-frames.test.ts @@ -7,10 +7,13 @@ import { } from './contact-sheet-report.ts'; import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors'; import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; +import { bindTestCommandHost } from '../host-environment.fixtures.ts'; import { solidPng } from '../png-pixels.fixtures.ts'; import { writeDecodedFrames, type StubFrame } from './contact-sheet.fixtures.ts'; import { extractRecordingFrames } from './contact-sheet-frames.ts'; +bindTestCommandHost(); + vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ ...(await importOriginal()), runCmd: vi.fn(), diff --git a/packages/capture-kit/src/recording/contact-sheet.test.ts b/packages/capture-kit/src/recording/contact-sheet.test.ts index 464f65dd3b..384630b2aa 100644 --- a/packages/capture-kit/src/recording/contact-sheet.test.ts +++ b/packages/capture-kit/src/recording/contact-sheet.test.ts @@ -12,11 +12,14 @@ import { import { AppError } from '@agent-device/kernel/errors'; import { BLACK, RED, WHITE, paintPng, solidPng } from '../png-pixels.fixtures.ts'; import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; +import { bindTestCommandHost } from '../host-environment.fixtures.ts'; import { decodePng } from '../png.ts'; import { mp4Atom, mp4MovieHeader } from './mp4.fixtures.ts'; import { writeDecodedFrames, type StubFrame } from './contact-sheet.fixtures.ts'; import { buildRecordingContactSheet, recordingContactSheetPath } from './contact-sheet.ts'; +bindTestCommandHost(); + vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ ...(await importOriginal()), runCmd: vi.fn(), diff --git a/packages/capture-kit/src/recording/swift-cache.test.ts b/packages/capture-kit/src/recording/swift-cache.test.ts index 8949f5583e..ba57a7b0a6 100644 --- a/packages/capture-kit/src/recording/swift-cache.test.ts +++ b/packages/capture-kit/src/recording/swift-cache.test.ts @@ -2,6 +2,9 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; +import { bindTestCommandHost } from '../host-environment.fixtures.ts'; + +bindTestCommandHost(); vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ ...(await importOriginal()), @@ -13,7 +16,7 @@ vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ }), })); -import { runCmd } from '@agent-device/host-kit/command'; +import { runCmd, withCommandHost } from '@agent-device/host-kit/command'; import { buildRecordingScriptPathCandidates, compileSwiftSourceFile, @@ -279,3 +282,24 @@ test('recording script candidates include packaged dist apple-runner source', () expect(firstExisting).toBe(scriptPath); }); + +test('the cache root and tool environment come from the command host, not the process', async () => { + const hostCacheDir = path.join(tmpDir, 'host-cache'); + + const executablePath = await withCommandHost( + { + host: { + env: { AGENT_DEVICE_SWIFT_CACHE_DIR: hostCacheDir, FROM_HOST: 'yes' }, + cwd: tmpDir, + }, + requestEnv: undefined, + }, + async () => await compileSwiftSourceText({ source: 'print("host")', cacheName: 'host-helper' }), + ); + + expect(path.dirname(executablePath)).toBe(path.join(hostCacheDir, 'bin')); + expect(mockRunCmd.mock.calls[0]?.[2]?.env).toMatchObject({ + FROM_HOST: 'yes', + HOME: path.join(hostCacheDir, 'home'), + }); +}); diff --git a/packages/capture-kit/src/recording/swift-cache.ts b/packages/capture-kit/src/recording/swift-cache.ts index e3a36e1bb9..8e4b7773ae 100644 --- a/packages/capture-kit/src/recording/swift-cache.ts +++ b/packages/capture-kit/src/recording/swift-cache.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { trimEdgeDashes } from '@agent-device/kernel/collections'; import { AppError } from '@agent-device/kernel/errors'; -import { runCmd } from '@agent-device/host-kit/command'; +import { commandHostEnvironment, runCmd } from '@agent-device/host-kit/command'; import { readProcessStartTime } from '@agent-device/host-kit/process'; import { acquireProcessLock, @@ -48,7 +48,7 @@ export function resolveRecordingScriptPath( scriptName: string, projectRoot: string, moduleDir = path.dirname(fileURLToPath(import.meta.url)), - cwd = process.cwd(), + cwd = commandHostEnvironment().cwd, ): string { const scriptCandidates = buildRecordingScriptPathCandidates( scriptName, @@ -69,8 +69,10 @@ export function resolveRecordingScriptPath( }); } -export function buildSwiftToolEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - const root = getSwiftCacheRoot(); +export function buildSwiftToolEnv( + env: NodeJS.ProcessEnv = commandHostEnvironment().env, +): NodeJS.ProcessEnv { + const root = getSwiftCacheRoot(env); const homePath = path.join(root, 'home'); const moduleCachePath = path.join(root, 'module-cache'); fs.mkdirSync(homePath, { recursive: true }); @@ -136,8 +138,8 @@ export async function compileSwiftSourceText(params: { return executablePath; } -function getSwiftCacheRoot(): string { - const configured = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR?.trim(); +function getSwiftCacheRoot(env: NodeJS.ProcessEnv = commandHostEnvironment().env): string { + const configured = env.AGENT_DEVICE_SWIFT_CACHE_DIR?.trim(); if (configured) { return path.resolve(configured); } diff --git a/packages/daemon/src/resources/__tests__/app-log.test.ts b/packages/daemon/src/resources/__tests__/app-log.test.ts index c96feea65a..82a90bf615 100644 --- a/packages/daemon/src/resources/__tests__/app-log.test.ts +++ b/packages/daemon/src/resources/__tests__/app-log.test.ts @@ -1,9 +1,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; -import { assertThrowsAppError, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + assertThrowsAppError, + bindTestCommandHost, + mkdtempForTestSync, +} from '@agent-device/test-kit'; import { appendAppLogMarker, clearAppLogFiles, getAppLogPathMetadata } from '../app-log.ts'; +bindTestCommandHost(); + // Pinned as a literal on purpose — see the note in packages/host-kit/src/internal/verified-file.test.ts. const NOT_REGULAR_FILE_HINT = 'agent-device only reads and writes regular files at this path. Remove the symbolic link or special file there and retry.'; diff --git a/src/platform-runtime-app-log-output.test.ts b/src/platform-runtime-app-log-output.test.ts index 6be7e1f424..f7fad57bf6 100644 --- a/src/platform-runtime-app-log-output.test.ts +++ b/src/platform-runtime-app-log-output.test.ts @@ -3,7 +3,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { afterEach, expect, test } from 'vitest'; import { openAppLogOutput, readAppLogOutputTail } from './platform-runtime-app-log-output.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; + +bindTestCommandHost(); const roots: string[] = []; From 1217c6770409c18f8237cd6e9abf8288ca11effe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 06:56:50 +0200 Subject: [PATCH 02/27] refactor(provision-kit): read proxy settings from the command host Co-Authored-By: Claude Sonnet 5.5 --- packages/maestro/package.json | 1 + .../__tests__/run-script-http.test.ts | 3 +++ packages/maestro/tsconfig.json | 3 ++- packages/provision-kit/package.json | 1 + .../src/install-source-network-transport.test.ts | 16 ++++++++++++++++ .../src/install-source-network-transport.ts | 3 ++- packages/provision-kit/tsconfig.json | 7 ++++++- pnpm-lock.yaml | 6 ++++++ 8 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/maestro/package.json b/packages/maestro/package.json index 280734aa4f..f7e8331b14 100644 --- a/packages/maestro/package.json +++ b/packages/maestro/package.json @@ -33,6 +33,7 @@ } }, "devDependencies": { + "@agent-device/test-kit": "workspace:*", "undici": "7.29.1", "yaml": "^2.9.0" } diff --git a/packages/maestro/src/daemon-port/__tests__/run-script-http.test.ts b/packages/maestro/src/daemon-port/__tests__/run-script-http.test.ts index 4f121ee7fa..363a6ec305 100644 --- a/packages/maestro/src/daemon-port/__tests__/run-script-http.test.ts +++ b/packages/maestro/src/daemon-port/__tests__/run-script-http.test.ts @@ -5,6 +5,7 @@ import { gzipSync } from 'node:zlib'; import { afterEach, beforeEach, test, vi } from 'vitest'; import type { Dispatcher } from 'undici'; import { AppError } from '@agent-device/kernel/errors'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import { closeLoopbackServer, listenOnLoopback, @@ -23,6 +24,8 @@ import { PublicFetchDispatcher, } from '../run-script-http.ts'; +bindTestCommandHost(); + beforeEach(() => { vi.resetAllMocks(); }); diff --git a/packages/maestro/tsconfig.json b/packages/maestro/tsconfig.json index 6614d7d44e..79afdd3f29 100644 --- a/packages/maestro/tsconfig.json +++ b/packages/maestro/tsconfig.json @@ -17,6 +17,7 @@ { "path": "../host-kit" }, { "path": "../kernel" }, { "path": "../provision-kit" }, - { "path": "../selectors" } + { "path": "../selectors" }, + { "path": "../test-kit" } ] } diff --git a/packages/provision-kit/package.json b/packages/provision-kit/package.json index da21bc6aa4..53e882eec1 100644 --- a/packages/provision-kit/package.json +++ b/packages/provision-kit/package.json @@ -44,6 +44,7 @@ } }, "devDependencies": { + "@agent-device/test-kit": "workspace:*", "ipaddr.js": "^2.5.0", "undici": "7.29.1" } diff --git a/packages/provision-kit/src/install-source-network-transport.test.ts b/packages/provision-kit/src/install-source-network-transport.test.ts index e3616b7e11..e569986718 100644 --- a/packages/provision-kit/src/install-source-network-transport.test.ts +++ b/packages/provision-kit/src/install-source-network-transport.test.ts @@ -3,12 +3,16 @@ import http from 'node:http'; import type { AddressInfo } from 'node:net'; import { text } from 'node:stream/consumers'; import { test, vi } from 'vitest'; +import { withCommandHost } from '@agent-device/host-kit/command'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import { matchesNoProxy, requestApprovedUrl, resolveProxyForUrl, } from './install-source-network-transport.ts'; +bindTestCommandHost(); + test('lowercase proxy variables override uppercase even when empty', () => { assert.equal( resolveProxyForUrl(new URL('http://example.net'), { @@ -27,6 +31,18 @@ test('lowercase proxy variables override uppercase even when empty', () => { ); }); +test('proxy variables come from the command host, not the process', async () => { + const proxy = await withCommandHost( + { + host: { env: { https_proxy: 'http://host-proxy' }, cwd: process.cwd() }, + requestEnv: undefined, + }, + async () => resolveProxyForUrl(new URL('https://example.net')), + ); + + assert.equal(proxy, 'http://host-proxy'); +}); + test('HTTPS proxy selection falls back to the selected HTTP proxy', () => { assert.equal( resolveProxyForUrl(new URL('https://example.net'), { http_proxy: 'http://proxy' }), diff --git a/packages/provision-kit/src/install-source-network-transport.ts b/packages/provision-kit/src/install-source-network-transport.ts index 39491de4b6..907879e6cf 100644 --- a/packages/provision-kit/src/install-source-network-transport.ts +++ b/packages/provision-kit/src/install-source-network-transport.ts @@ -1,4 +1,5 @@ import net from 'node:net'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { Agent, ProxyAgent, request, type Dispatcher } from 'undici'; export type InstallSourceNetworkResponse = { @@ -45,7 +46,7 @@ export async function requestApprovedUrl(params: { export function resolveProxyForUrl( url: URL, - environment: NodeJS.ProcessEnv = process.env, + environment: NodeJS.ProcessEnv = commandHostEnvironment().env, ): string | undefined { const noProxy = environment.no_proxy ?? environment.NO_PROXY; if (matchesNoProxy(url, noProxy)) return undefined; diff --git a/packages/provision-kit/tsconfig.json b/packages/provision-kit/tsconfig.json index 247a915b90..380be0b4f5 100644 --- a/packages/provision-kit/tsconfig.json +++ b/packages/provision-kit/tsconfig.json @@ -10,5 +10,10 @@ "tsBuildInfoFile": "./dist-types/index.tsbuildinfo" }, "include": ["src"], - "references": [{ "path": "../contracts" }, { "path": "../host-kit" }, { "path": "../kernel" }] + "references": [ + { "path": "../contracts" }, + { "path": "../host-kit" }, + { "path": "../kernel" }, + { "path": "../test-kit" } + ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cf558c119..01f80baea1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -458,6 +458,9 @@ importers: specifier: workspace:* version: link:../selectors devDependencies: + '@agent-device/test-kit': + specifier: workspace:* + version: link:../test-kit undici: specifier: 7.29.1 version: 7.29.1 @@ -680,6 +683,9 @@ importers: specifier: workspace:* version: link:../kernel devDependencies: + '@agent-device/test-kit': + specifier: workspace:* + version: link:../test-kit ipaddr.js: specifier: ^2.5.0 version: 2.5.0 From 3a4c59b50dc2f6677a0f57714b168c5a2a1ab659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:01:56 +0200 Subject: [PATCH 03/27] refactor(react-native): read dev-server settings from the command host The detached companion process starts with an explicit env and cwd, and its process entry captures its host from its own spawn environment. Co-Authored-By: Claude Sonnet 5.5 --- .../react-native/src/companion/client-companion-tunnel.ts | 6 ++++-- .../src/companion/client-metro-companion.test.ts | 4 +++- packages/react-native/src/companion/companion-tunnel.ts | 6 +++++- packages/react-native/src/index-metro.test.ts | 3 +++ .../src/metro/client-metro-auto-companion.test.ts | 4 +++- .../src/metro/client-metro-startup-cleanup.test.ts | 4 +++- packages/react-native/src/metro/client-metro.ts | 7 ++++--- packages/react-native/src/metro/metro-session-hints.ts | 7 +++++-- 8 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/react-native/src/companion/client-companion-tunnel.ts b/packages/react-native/src/companion/client-companion-tunnel.ts index 8506d79cb5..fe5f5a93a6 100644 --- a/packages/react-native/src/companion/client-companion-tunnel.ts +++ b/packages/react-native/src/companion/client-companion-tunnel.ts @@ -16,7 +16,7 @@ import { ENV_COMPANION_TUNNEL_UNREGISTER_PATH, } from './client-companion-tunnel-contract.ts'; import { normalizeBaseUrl } from '../metro/base-url.ts'; -import { runCmdDetached } from '@agent-device/host-kit/command'; +import { commandHostEnvironment, runCmdDetached } from '@agent-device/host-kit/command'; import { confirmedProcessStopDetails, describeUnconfirmedProcessStop, @@ -400,8 +400,10 @@ function spawnCompanionProcess( const logFd = fs.openSync(logPath, 'a'); let pid = 0; try { + const host = commandHostEnvironment(); pid = runCmdDetached(process.execPath, [...execArgs, modulePath, options.definition.runArg], { - env: buildCompanionEnv(options, options.env ?? process.env), + cwd: host.cwd, + env: buildCompanionEnv(options, options.env ?? host.env), stdio: ['ignore', logFd, logFd], }); } finally { diff --git a/packages/react-native/src/companion/client-metro-companion.test.ts b/packages/react-native/src/companion/client-metro-companion.test.ts index 2e2a17b988..34e7c96dc4 100644 --- a/packages/react-native/src/companion/client-metro-companion.test.ts +++ b/packages/react-native/src/companion/client-metro-companion.test.ts @@ -2,9 +2,11 @@ import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; import type { AppError } from '@agent-device/kernel/errors'; +bindTestCommandHost(); + vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ ...(await importOriginal()), runCmdDetached: vi.fn(), diff --git a/packages/react-native/src/companion/companion-tunnel.ts b/packages/react-native/src/companion/companion-tunnel.ts index ec189ec57d..92e9193a9a 100644 --- a/packages/react-native/src/companion/companion-tunnel.ts +++ b/packages/react-native/src/companion/companion-tunnel.ts @@ -1,7 +1,11 @@ +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { MissingCompanionEnvError } from './client-companion-tunnel-contract.ts'; import { runCompanionTunnelProcessFromEnv } from './client-companion-tunnel-worker.ts'; -void runCompanionTunnelProcessFromEnv(process.argv.slice(2), process.env).catch((error) => { +void runCompanionTunnelProcessFromEnv( + process.argv.slice(2), + captureHostEnvironment(process).env, +).catch((error) => { if (error instanceof MissingCompanionEnvError) { console.error(error.message); process.exitCode = 1; diff --git a/packages/react-native/src/index-metro.test.ts b/packages/react-native/src/index-metro.test.ts index 724731a967..7d41674f74 100644 --- a/packages/react-native/src/index-metro.test.ts +++ b/packages/react-native/src/index-metro.test.ts @@ -8,6 +8,7 @@ import path from 'node:path'; import { prepareMetroRuntime, reloadMetro } from './index.ts'; import { AppError } from '@agent-device/kernel/errors'; import { + bindTestCommandHost, closeLoopbackServer, mkdtempForTestSync, writeFakeMetroNpx, @@ -15,6 +16,8 @@ import { stopTestMetroProcess, } from '@agent-device/test-kit'; +bindTestCommandHost(); + const TEST_TOKEN = 'agent-device-proxy-test-token'; for (const { configFileName, commandName } of [ diff --git a/packages/react-native/src/metro/client-metro-auto-companion.test.ts b/packages/react-native/src/metro/client-metro-auto-companion.test.ts index 59b1a101aa..dbf84bfaa5 100644 --- a/packages/react-native/src/metro/client-metro-auto-companion.test.ts +++ b/packages/react-native/src/metro/client-metro-auto-companion.test.ts @@ -2,9 +2,11 @@ import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; import { AppError } from '@agent-device/kernel/errors'; +bindTestCommandHost(); + vi.mock('../companion/client-metro-companion.ts', () => ({ ensureMetroCompanion: vi.fn(), })); diff --git a/packages/react-native/src/metro/client-metro-startup-cleanup.test.ts b/packages/react-native/src/metro/client-metro-startup-cleanup.test.ts index fe1e921666..9e007d619e 100644 --- a/packages/react-native/src/metro/client-metro-startup-cleanup.test.ts +++ b/packages/react-native/src/metro/client-metro-startup-cleanup.test.ts @@ -2,9 +2,11 @@ import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; import type { AppError } from '@agent-device/kernel/errors'; +bindTestCommandHost(); + vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ ...(await importOriginal()), runCmdDetached: vi.fn(), diff --git a/packages/react-native/src/metro/client-metro.ts b/packages/react-native/src/metro/client-metro.ts index c5676cadc7..ae2a3d37cd 100644 --- a/packages/react-native/src/metro/client-metro.ts +++ b/packages/react-native/src/metro/client-metro.ts @@ -10,7 +10,7 @@ import type { } from '@agent-device/contracts/remote'; import fs from 'node:fs'; import path from 'node:path'; -import { runCmdSync, runCmdDetached } from '@agent-device/host-kit/command'; +import { commandHostEnvironment, runCmdSync, runCmdDetached } from '@agent-device/host-kit/command'; import { confirmedProcessStopDetails, describeUnconfirmedProcessStop, @@ -710,8 +710,9 @@ function resolveMetroLogPath( function resolveMetroPrepareSettings( input: PrepareMetroRuntimeOptions, ): ResolvedMetroPrepareSettings { - const env = input.env ?? process.env; - const cwd = process.cwd(); + const host = commandHostEnvironment(); + const env = input.env ?? host.env; + const cwd = host.cwd; const projectRoot = resolvePath(input.projectRoot ?? cwd, env, cwd); const requestedKind = input.kind ?? 'auto'; let packageJson: PackageJsonShape | null = null; diff --git a/packages/react-native/src/metro/metro-session-hints.ts b/packages/react-native/src/metro/metro-session-hints.ts index ae3c13c047..57cccbc07e 100644 --- a/packages/react-native/src/metro/metro-session-hints.ts +++ b/packages/react-native/src/metro/metro-session-hints.ts @@ -3,6 +3,7 @@ import type { SessionRuntimeHints } from '@agent-device/kernel/contracts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import fs from 'node:fs'; import path from 'node:path'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { safeSessionName } from '@agent-device/host-kit/session-paths'; import { resolveMetroServerUrl, type MetroReloadTargetInput } from './metro-reload-endpoints.ts'; @@ -17,9 +18,11 @@ export type MetroSessionScope = { stateDir: string; session: string }; export type MetroSessionOptions = { stateDir?: string; session: string }; function resolveMetroSessionScope(options: MetroSessionOptions): MetroSessionScope { + const host = commandHostEnvironment(); return { - stateDir: resolveDaemonPaths(options.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR, { - cwd: process.cwd(), + stateDir: resolveDaemonPaths(options.stateDir ?? host.env.AGENT_DEVICE_STATE_DIR, { + env: host.env, + cwd: host.cwd, }).baseDir, session: options.session, }; From 6df731e96d8a2ca0ab32937ba799104bfbcd9e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:01:56 +0200 Subject: [PATCH 04/27] fix(host): run the dev-server helpers inside the client's and the SDK's host The helpers moved from process reads to the command host, so the released agent-device/metro functions and the client bind one at their boundary. Co-Authored-By: Claude Sonnet 5.5 --- .../agent-device-client-metro-host.test.ts | 26 +++++++++++++++++ src/agent-device-client.ts | 29 ++++++++++++++----- src/sdk/metro-public.test.ts | 9 ++++++ src/sdk/metro.ts | 25 ++++++++++++++-- 4 files changed, 78 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/agent-device-client-metro-host.test.ts diff --git a/src/__tests__/agent-device-client-metro-host.test.ts b/src/__tests__/agent-device-client-metro-host.test.ts new file mode 100644 index 0000000000..2163e8adae --- /dev/null +++ b/src/__tests__/agent-device-client-metro-host.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; +import { createTransport } from './client-transport-fixture.ts'; + +test('closing a session drops its dev-server binding from the client host state dir', async () => { + const stateDir = mkdtempForTestSync('agent-device-client-metro-host-'); + const hintsPath = path.join(stateDir, 'metro-sessions', 'qa.json'); + fs.mkdirSync(path.dirname(hintsPath), { recursive: true }); + fs.writeFileSync(hintsPath, JSON.stringify({ controlBaseUrl: 'http://127.0.0.1:8081' })); + const setup = createTransport(async () => ({ ok: true, data: { session: 'qa' } })); + const client = createAgentDeviceClient( + { ...setup.config, stateDir: undefined }, + { + transport: setup.transport, + host: { env: { AGENT_DEVICE_STATE_DIR: stateDir }, cwd: stateDir }, + }, + ); + + await client.sessions.close(); + + assert.equal(fs.existsSync(hintsPath), false); +}); diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 07f0635f74..6720b656c7 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -61,14 +61,7 @@ import { import type { CommandResult } from '@agent-device/command-registry/command-result'; import { createDaemonTransport } from './daemon-client/daemon-client.ts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { - prepareMetroRuntime, - reloadMetro, - clearMetroSessionHintsQuietly, - recordPreparedMetroSession, - recordOpenedMetroSession, - readMetroSessionRuntime, -} from '@agent-device/react-native'; +import * as devServer from '@agent-device/react-native'; import { asOptionalRecord, readPresentedSurfaceFact, @@ -76,6 +69,7 @@ import { } from '@agent-device/kernel/record'; import { readSnapshotViewportSize } from '@agent-device/kernel/rect'; import { readResponseWarnings } from '@agent-device/kernel/success-text'; +import { withCommandHost } from '@agent-device/host-kit/command'; import { captureHostEnvironment } from '@agent-device/host-kit/process'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { createLeaseClient } from './client/lease-client.ts'; @@ -90,6 +84,25 @@ export function createAgentDeviceClient( const transport = deps.transport ?? createDaemonTransport(host); // A typo in a JSON or JS config must not fall through to the default and manage a daemon. readDaemonLifecycle(config.daemonLifecycle); + // The dev-server helpers read their environment from the command host; this client's host owns it. + const inHost = async (fn: () => Promise | T): Promise => + await withCommandHost({ host, requestEnv: undefined }, async () => await fn()); + const prepareMetroRuntime = (input: Parameters[0]) => + inHost(() => devServer.prepareMetroRuntime(input)); + const reloadMetro = (input: Parameters[0]) => + inHost(() => devServer.reloadMetro(input)); + const clearMetroSessionHintsQuietly = ( + options: Parameters[0], + ) => inHost(() => devServer.clearMetroSessionHintsQuietly(options)); + const recordPreparedMetroSession = ( + ...args: Parameters + ) => inHost(() => devServer.recordPreparedMetroSession(...args)); + const recordOpenedMetroSession = ( + ...args: Parameters + ) => inHost(() => devServer.recordOpenedMetroSession(...args)); + const readMetroSessionRuntime = ( + options: Parameters[0], + ) => inHost(() => devServer.readMetroSessionRuntime(options)); // A non-default responseLevel (digest/full) makes the daemon return a leveled // shape; the per-command client normalizers assume the default shape, so the diff --git a/src/sdk/metro-public.test.ts b/src/sdk/metro-public.test.ts index bb55581e4f..27a90f8764 100644 --- a/src/sdk/metro-public.test.ts +++ b/src/sdk/metro-public.test.ts @@ -68,3 +68,12 @@ test('public metro entrypoint does not expose runtime hint builders', async () = status: 200, headers: { 'content-type': 'application/json' }, }) satisfies MetroTunnelResponseMessage; + +test('public prepareMetroRuntime runs without a daemon or CLI host around it', async () => { + const { prepareMetroRuntime } = await import('./metro.ts'); + + await assert.rejects( + prepareMetroRuntime({}), + (error: unknown) => (error as { code?: string }).code === 'INVALID_ARGS', + ); +}); diff --git a/src/sdk/metro.ts b/src/sdk/metro.ts index ac475fd369..5905583b38 100644 --- a/src/sdk/metro.ts +++ b/src/sdk/metro.ts @@ -1,11 +1,30 @@ +import * as devServer from '@agent-device/react-native'; +import { withCommandHost } from '@agent-device/host-kit/command'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; + export { normalizeBaseUrl, buildBundleUrl, resolveRuntimeTransport, - stopMetroTunnel, - prepareMetroRuntime, - reloadMetro, type MetroBridgeDescriptor, type MetroTunnelRequestMessage, type MetroTunnelResponseMessage, } from '@agent-device/react-native'; + +// A JS host calls these without a daemon or CLI around it, so each call runs as a command host +// that reads the process's current environment, as these functions always have. +async function inProcessHost(fn: () => Promise): Promise { + return await withCommandHost( + { host: captureHostEnvironment(process), requestEnv: undefined }, + fn, + ); +} + +export const prepareMetroRuntime: typeof devServer.prepareMetroRuntime = async (input) => + await inProcessHost(() => devServer.prepareMetroRuntime(input)); + +export const reloadMetro: typeof devServer.reloadMetro = async (input) => + await inProcessHost(() => devServer.reloadMetro(input)); + +export const stopMetroTunnel: typeof devServer.stopMetroTunnel = async (options) => + await inProcessHost(() => devServer.stopMetroTunnel(options)); From 9bae094b526c275114598ac47eb67bf126a1719c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:09:20 +0200 Subject: [PATCH 05/27] refactor(platform-apple): give detached runner and bridge children an explicit env and cwd Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/runner-process-launch.test.ts | 16 +++++++++ .../src/runner/runner-process-launch.ts | 6 +++- .../src/snapshot-source/host.test.ts | 34 ++++++++++++++++++- .../src/snapshot-source/host.ts | 6 +++- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts b/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts index 703610720f..44284f0eb3 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts @@ -49,6 +49,22 @@ test("the runner is spawned detached onto its own log file, not onto this proces assert.equal(args[args.indexOf('-derivedDataPath') + 1], '/tmp/runner-derived'); }); +test('the detached runner gets its environment and working directory as plain values', () => { + mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); + const host = { env: { FROM_HOST: 'yes' }, cwd: '/tmp/host-cwd' }; + appleRunnerTestHost.update({ commandHostEnvironment: () => host }); + + launchRunnerProcess({ + device: IOS_SIMULATOR, + xctestrunPath: '/tmp/runner.xctestrun', + derivedPath: '/tmp/runner-derived', + logPath: runnerLogPath(), + }); + + const options = mockRunCmdBackground.mock.calls[0]?.[2] as { env?: unknown; cwd?: string }; + assert.deepEqual({ env: options.env, cwd: options.cwd }, host); +}); + test('test-without-building resolves a scoped-set simulator in its own set', () => { mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); diff --git a/packages/platform-apple/src/runner/runner-process-launch.ts b/packages/platform-apple/src/runner/runner-process-launch.ts index 80ce456b4e..4fb8c73f41 100644 --- a/packages/platform-apple/src/runner/runner-process-launch.ts +++ b/packages/platform-apple/src/runner/runner-process-launch.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { runCmdBackground } from './host.ts'; +import { commandHostEnvironment, runCmdBackground } from './host.ts'; import type { ExecBackgroundResult } from '@agent-device/host-kit/command'; import { resolveRunnerDestination } from './apple-runner-platform.ts'; import { resolveRunnerMaxConcurrentDestinationsFlag } from './runner-cache-metadata.ts'; @@ -59,6 +59,8 @@ export function launchRunnerProcess(input: LaunchRunnerProcessInput): LaunchedRu } catch {} }); const listenerReady = createRunnerListenerReadySignal(); + // The runner outlives this process, so it gets its environment and directory as plain values. + const { env, cwd } = commandHostEnvironment(); try { const launched = runCmdBackground( 'xcodebuild', @@ -85,6 +87,8 @@ export function launchRunnerProcess(input: LaunchRunnerProcessInput): LaunchedRu { allowFailure: true, detached: true, + env, + cwd, signal: input.signal, stdio: ['ignore', outputFd, outputFd], captureOutput: false, diff --git a/packages/platform-apple/src/snapshot-source/host.test.ts b/packages/platform-apple/src/snapshot-source/host.test.ts index 989e0feaef..82d70aed7b 100644 --- a/packages/platform-apple/src/snapshot-source/host.test.ts +++ b/packages/platform-apple/src/snapshot-source/host.test.ts @@ -1,10 +1,13 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { test, vi } from 'vitest'; -import { runCmdBackground } from '@agent-device/host-kit/command'; +import { runCmdBackground, withCommandHost } from '@agent-device/host-kit/command'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import { simulatorAddressFor } from '../core/simctl.ts'; import { createSnapshotSourceHost, snapshotSourceSocketPath } from './host.ts'; +bindTestCommandHost(); + vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ ...(await importOriginal()), runCmdBackground: vi.fn(), @@ -71,3 +74,32 @@ test.each([ ], ]); }); + +test('the detached bridge gets its environment and working directory as plain values', async () => { + const spawn = vi.mocked(runCmdBackground); + spawn.mockReset(); + spawn.mockReturnValue({ + child: Object.assign(new EventEmitter(), { pid: 4243, exitCode: null, signalCode: null }), + wait: new Promise(() => {}), + } as unknown as ReturnType); + const host = { env: { FROM_HOST: 'yes' }, cwd: '/tmp/host-cwd' }; + + await withCommandHost({ host, requestEnv: undefined }, async () => { + createSnapshotSourceHost().start( + simulatorAddressFor({ + platform: 'apple', + id: 'simulator-1', + name: 'iPhone 17', + kind: 'simulator', + target: 'mobile', + }), + '/tmp/bridge', + '/tmp/bridge.sock', + ); + }); + + assert.deepEqual( + { env: spawn.mock.calls[0]?.[2]?.env, cwd: spawn.mock.calls[0]?.[2]?.cwd }, + { env: host.env, cwd: host.cwd }, + ); +}); diff --git a/packages/platform-apple/src/snapshot-source/host.ts b/packages/platform-apple/src/snapshot-source/host.ts index b89fac9ad8..fe006334db 100644 --- a/packages/platform-apple/src/snapshot-source/host.ts +++ b/packages/platform-apple/src/snapshot-source/host.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import net from 'node:net'; import path from 'node:path'; -import { runCmd, runCmdBackground } from '@agent-device/host-kit/command'; +import { commandHostEnvironment, runCmd, runCmdBackground } from '@agent-device/host-kit/command'; import { hostHomeDirectory } from '@agent-device/host-kit/host-file'; import { signalProcessGroupBestEffort } from '@agent-device/host-kit/process'; import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; @@ -44,6 +44,8 @@ function startSnapshotBridge( if (options.signal?.aborted) { throw snapshotSourceError('cancelled', 'abort-signal'); } + // The bridge outlives this call, so it gets its environment and directory as plain values. + const { env, cwd } = commandHostEnvironment(); const started = runCmdBackground( 'xcrun', buildSimctlArgsForAddress(simulator, [ @@ -61,6 +63,8 @@ function startSnapshotBridge( allowFailure: true, captureOutput: false, detached: true, + env, + cwd, }, ); const pid = started.child.pid ?? 0; From 94fbb686178a39657ee0d49c06ee11aad5832369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:09:20 +0200 Subject: [PATCH 06/27] refactor(platform-apple): read runner settings from the command host The runner reaches the host through its port. The container bundle ids were fixed at import time from the process environment; they are now resolved from the host a caller passes in. Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/physical-device-files.test.ts | 3 ++ .../src/core/__tests__/screenshot.test.ts | 3 ++ .../src/core/physical-device-files.ts | 5 ++- .../platform-apple/src/core/runner-host.ts | 2 + .../platform-apple/src/core/screenshot.ts | 5 ++- .../src/runner/__tests__/host-setup.ts | 5 +++ .../runner-session-stale-bundles.test.ts | 4 +- packages/platform-apple/src/runner/host.ts | 2 + packages/platform-apple/src/runner/index.ts | 2 +- .../src/runner/runner-adoption.ts | 11 ++++- .../src/runner/runner-artifact.ts | 10 +++-- .../src/runner/runner-cache-metadata.ts | 44 ++++++++++--------- .../src/runner/runner-cache-retention.ts | 8 ++-- .../platform-apple/src/runner/runner-cache.ts | 5 ++- .../src/runner/runner-disposal.ts | 5 ++- .../platform-apple/src/runner/runner-lease.ts | 5 ++- .../src/runner/runner-retention.ts | 4 +- .../src/runner/runner-session.ts | 5 ++- .../src/runner/runner-xctestrun.ts | 2 +- scripts/write-xcuitest-cache-metadata.ts | 8 +++- scripts/xcuitest-build-settings.ts | 2 +- ...screen-recording-apple-runner-host.test.ts | 2 +- ...time-screen-recording-apple-runner-host.ts | 5 ++- 23 files changed, 94 insertions(+), 53 deletions(-) diff --git a/packages/platform-apple/src/core/__tests__/physical-device-files.test.ts b/packages/platform-apple/src/core/__tests__/physical-device-files.test.ts index b06dabb3f2..51a982b47c 100644 --- a/packages/platform-apple/src/core/__tests__/physical-device-files.test.ts +++ b/packages/platform-apple/src/core/__tests__/physical-device-files.test.ts @@ -1,4 +1,5 @@ import { test } from 'vitest'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { AppError } from '@agent-device/kernel/errors'; import { copyCoreDeviceRunnerFile } from '../physical-device-files.ts'; @@ -11,6 +12,8 @@ import { withFakeAppleTool, type FakeAppleToolScript } from '../../__tests__/fak * candidate bundle ids in order and reports only what the last one said. The shapes below pin the * retry boundary (one success stops the loop) and each source of the reported message. */ +bindTestCommandHost(); + test('copyCoreDeviceRunnerFile copies with the app data container domain', async () => { const calls = await runCopy(() => ''); assert.equal(calls.length, 1); diff --git a/packages/platform-apple/src/core/__tests__/screenshot.test.ts b/packages/platform-apple/src/core/__tests__/screenshot.test.ts index 01960d1c13..1d7c1f7430 100644 --- a/packages/platform-apple/src/core/__tests__/screenshot.test.ts +++ b/packages/platform-apple/src/core/__tests__/screenshot.test.ts @@ -3,8 +3,11 @@ import assert from 'node:assert/strict'; import { promises as fs, readFileSync } from 'node:fs'; import path from 'node:path'; import type { RunnerScreenCaptureMetadata } from '@agent-device/contracts/screen-capture-contract'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import { mkdtempForTest } from '../../__tests__/tmp-dir.ts'; +bindTestCommandHost(); + vi.mock('@agent-device/host-kit/command', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runCmd: vi.fn(actual.runCmd) }; diff --git a/packages/platform-apple/src/core/physical-device-files.ts b/packages/platform-apple/src/core/physical-device-files.ts index d9ffd0978b..ddb0ce3413 100644 --- a/packages/platform-apple/src/core/physical-device-files.ts +++ b/packages/platform-apple/src/core/physical-device-files.ts @@ -1,8 +1,9 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { Deadline } from '@agent-device/host-kit/retry'; import { IOS_RUNNER_SCREENSHOT_COPY_TIMEOUT_MS } from './config.ts'; -import { IOS_RUNNER_CONTAINER_BUNDLE_IDS } from '../runner/index.ts'; +import { resolveRunnerContainerBundleIds } from '../runner/index.ts'; import { runXcrun } from './tool-provider.ts'; export async function copyCoreDeviceRunnerFile( @@ -13,7 +14,7 @@ export async function copyCoreDeviceRunnerFile( ): Promise { const deadline = Deadline.fromTimeoutMs(timeoutMs); let copyResult = { exitCode: 1, stdout: '', stderr: '' }; - for (const bundleId of IOS_RUNNER_CONTAINER_BUNDLE_IDS) { + for (const bundleId of resolveRunnerContainerBundleIds(commandHostEnvironment().env)) { copyResult = await runXcrun( [ 'devicectl', diff --git a/packages/platform-apple/src/core/runner-host.ts b/packages/platform-apple/src/core/runner-host.ts index 6790d89a7c..39895aa879 100644 --- a/packages/platform-apple/src/core/runner-host.ts +++ b/packages/platform-apple/src/core/runner-host.ts @@ -3,6 +3,7 @@ import { publishFileSync, acquireProcessLock, withProcessLock } from '@agent-dev import { commandDeveloperDir, + commandHostEnvironment, isCommandTimeoutError, requireExecSuccess, runCmdBackground, @@ -71,6 +72,7 @@ export const appleRunnerHost: AppleRunnerHost = { requireExecSuccess, isCommandTimeoutError, commandDeveloperDir, + commandHostEnvironment, shellQuote, emitDiagnostic, withDiagnosticTimer, diff --git a/packages/platform-apple/src/core/screenshot.ts b/packages/platform-apple/src/core/screenshot.ts index 23bc3e3627..717a09b507 100644 --- a/packages/platform-apple/src/core/screenshot.ts +++ b/packages/platform-apple/src/core/screenshot.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { copyHostFile } from '@agent-device/host-kit/host-file'; import { Deadline, retryWithPolicy } from '@agent-device/host-kit/retry'; @@ -23,7 +24,7 @@ import { } from './config.ts'; import { runAppleRunnerCommand } from './runner-client.ts'; import { - IOS_RUNNER_CONTAINER_BUNDLE_IDS, + resolveRunnerContainerBundleIds, type AppleRunnerCommandOptions, } from '../runner/index.ts'; import { prepareSimulatorStatusBarForScreenshot } from './screenshot-status-bar.ts'; @@ -297,7 +298,7 @@ async function copyRunnerScreenshotFromSimulator( lastError = cachedCopy.error; iosSimulatorRunnerContainerCache.delete(device.id); } - for (const bundleId of IOS_RUNNER_CONTAINER_BUNDLE_IDS) { + for (const bundleId of resolveRunnerContainerBundleIds(commandHostEnvironment().env)) { const containerResult = await runSimctlForDevice( device, ['get_app_container', device.id, bundleId, 'data'], diff --git a/packages/platform-apple/src/runner/__tests__/host-setup.ts b/packages/platform-apple/src/runner/__tests__/host-setup.ts index 1cc5ebb13e..d506a521fb 100644 --- a/packages/platform-apple/src/runner/__tests__/host-setup.ts +++ b/packages/platform-apple/src/runner/__tests__/host-setup.ts @@ -1,4 +1,5 @@ import { beforeEach } from 'vitest'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import { appleRunnerTestHost, installAppleRunnerTestHost, @@ -11,6 +12,10 @@ import { // retry/lock/exec semantics and stub individual capabilities per test. installAppleRunnerTestHost(await loadAppleRunnerHost()); +// Runner code reads its settings from the command host; tests run as one whose environment is the +// live process environment, so `vi.stubEnv` reaches it. +bindTestCommandHost(); + beforeEach(() => { appleRunnerTestHost.reset(); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-stale-bundles.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-stale-bundles.test.ts index 42a09daf41..7a9e2780a4 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-stale-bundles.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-stale-bundles.test.ts @@ -95,7 +95,7 @@ vi.mock('../runner-startup-transport.ts', async () => { }); import { abortAllIosRunnerSessions, ensureRunnerSession } from '../runner-session.ts'; -import { IOS_RUNNER_CONTAINER_BUNDLE_IDS } from '../runner-xctestrun.ts'; +import { resolveRunnerContainerBundleIds } from '../runner-xctestrun.ts'; import { AppError } from '@agent-device/kernel/errors'; const TEST_OWNER_START_TIME = 'fixed-test-owner-start-time'; @@ -223,7 +223,7 @@ test('stale bundle uninstalls start concurrently', async () => { // Both container bundle uninstalls must be in flight before either resolves; // against the sequential pre-fix loop this wait times out with one gate held. await vi.waitFor(() => - assert.equal(uninstallGates.length, IOS_RUNNER_CONTAINER_BUNDLE_IDS.length), + assert.equal(uninstallGates.length, resolveRunnerContainerBundleIds(process.env).length), ); for (const resolve of uninstallGates) { resolve({ exitCode: 0, stdout: '', stderr: '' }); diff --git a/packages/platform-apple/src/runner/host.ts b/packages/platform-apple/src/runner/host.ts index 37be783617..c126ffbc64 100644 --- a/packages/platform-apple/src/runner/host.ts +++ b/packages/platform-apple/src/runner/host.ts @@ -58,6 +58,7 @@ export type AppleRunnerHost = Pick< | 'requireExecSuccess' | 'isCommandTimeoutError' | 'commandDeveloperDir' + | 'commandHostEnvironment' > & Pick & Pick & @@ -158,6 +159,7 @@ export const runCmdBackground = delegate('runCmdBackground'); export const requireExecSuccess = delegate('requireExecSuccess'); export const isCommandTimeoutError = delegate('isCommandTimeoutError'); export const commandDeveloperDir = delegate('commandDeveloperDir'); +export const commandHostEnvironment = delegate('commandHostEnvironment'); export const shellQuote = delegate('shellQuote'); export const emitDiagnostic = delegate('emitDiagnostic'); export const withDiagnosticTimer = delegate('withDiagnosticTimer'); diff --git a/packages/platform-apple/src/runner/index.ts b/packages/platform-apple/src/runner/index.ts index f61f67599e..8639f05c84 100644 --- a/packages/platform-apple/src/runner/index.ts +++ b/packages/platform-apple/src/runner/index.ts @@ -19,4 +19,4 @@ export { type AppleRunnerProvider, } from './runner-provider.ts'; export { buildRunnerSequenceCommand, parseRunnerSequenceResult } from './runner-sequence.ts'; -export { IOS_RUNNER_CONTAINER_BUNDLE_IDS } from './runner-cache-metadata.ts'; +export { resolveRunnerContainerBundleIds } from './runner-cache-metadata.ts'; diff --git a/packages/platform-apple/src/runner/runner-adoption.ts b/packages/platform-apple/src/runner/runner-adoption.ts index 3048c2a6a7..56fdb38038 100644 --- a/packages/platform-apple/src/runner/runner-adoption.ts +++ b/packages/platform-apple/src/runner/runner-adoption.ts @@ -1,5 +1,10 @@ import path from 'node:path'; -import { emitDiagnostic, isProcessAlive, parseBooleanLiteral } from './host.ts'; +import { + commandHostEnvironment, + emitDiagnostic, + isProcessAlive, + parseBooleanLiteral, +} from './host.ts'; import type { ExecResult } from '@agent-device/host-kit/command'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { isRequestCanceledError } from '@agent-device/kernel/errors'; @@ -61,7 +66,9 @@ const RUNNER_ADOPTION_EXIT_POLL_INTERVAL_MS = 1_000; // Kill switch for the runner handoff across daemon restarts: disables both // detaching healthy runners on graceful shutdown and adopting them on the next // startup, in every handoff lane (#2681). -export function isIosRunnerDetachEnabled(env: NodeJS.ProcessEnv = process.env): boolean { +export function isIosRunnerDetachEnabled( + env: NodeJS.ProcessEnv = commandHostEnvironment().env, +): boolean { return parseBooleanLiteral(env.AGENT_DEVICE_IOS_RUNNER_DETACH ?? '') !== false; } diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index b15275d240..1d78175556 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -15,6 +15,7 @@ import { emitRequestProgress, findProjectRoot, isCommandTimeoutError, + commandHostEnvironment, } from './host.ts'; import type { ExecBackgroundResult } from '@agent-device/host-kit/command'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -872,16 +873,17 @@ async function buildRunnerXctestrun( // teardown or a last-waiter cancellation reaches this same line again, and admission is what // refuses it. Checking at registration would be a check after the child exists (#3220). assertRunnerStartAdmitsPreparation(device.id, options.startAdmission); - const runnerBundleBuildSettings = resolveRunnerBundleBuildSettings(process.env); + const { env } = commandHostEnvironment(); + const runnerBundleBuildSettings = resolveRunnerBundleBuildSettings(env); const signingBuildSettings = resolveRunnerSigningBuildSettings( - process.env, + env, device.kind === 'device', device, ); const provisioningArgs = device.kind === 'device' ? ['-allowProvisioningUpdates'] : []; const performanceBuildSettings = resolveRunnerPerformanceBuildSettings(); - const archBuildSettings = resolveRunnerArchBuildSettings(process.env); - const sandboxBuildArgs = resolveRunnerSandboxBuildArgs(); + const archBuildSettings = resolveRunnerArchBuildSettings(env); + const sandboxBuildArgs = resolveRunnerSandboxBuildArgs(env); try { await runCmdStreaming( 'xcodebuild', diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts index 9f5bb6acba..310e3eed49 100644 --- a/packages/platform-apple/src/runner/runner-cache-metadata.ts +++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { isEnvTruthy, findProjectRoot, readVersion } from './host.ts'; +import { commandHostEnvironment, isEnvTruthy, findProjectRoot, readVersion } from './host.ts'; import { resolveRunnerBuildDestinationFamily, resolveRunnerDerivedBaseName, @@ -88,14 +88,16 @@ function normalizeBundleId(value: string | undefined): string { return value?.trim() ?? ''; } -export function resolveRunnerAppBundleId(env: NodeJS.ProcessEnv = process.env): string { +export function resolveRunnerAppBundleId( + env: NodeJS.ProcessEnv = commandHostEnvironment().env, +): string { const configured = normalizeBundleId(env.AGENT_DEVICE_IOS_BUNDLE_ID) || normalizeBundleId(env.AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID); return configured || DEFAULT_IOS_RUNNER_APP_BUNDLE_ID; } -function resolveRunnerTestBundleId(env: NodeJS.ProcessEnv = process.env): string { +function resolveRunnerTestBundleId(env: NodeJS.ProcessEnv = commandHostEnvironment().env): string { const configured = normalizeBundleId(env.AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID); if (configured) { return configured; @@ -103,7 +105,7 @@ function resolveRunnerTestBundleId(env: NodeJS.ProcessEnv = process.env): string return `${resolveRunnerAppBundleId(env)}.uitests`; } -function resolveRunnerContainerBundleIds(env: NodeJS.ProcessEnv = process.env): string[] { +export function resolveRunnerContainerBundleIds(env: NodeJS.ProcessEnv): string[] { const appBundleId = resolveRunnerAppBundleId(env); const testBundleId = resolveRunnerTestBundleId(env); return Array.from( @@ -117,16 +119,13 @@ function resolveRunnerContainerBundleIds(env: NodeJS.ProcessEnv = process.env): ); } -export const IOS_RUNNER_CONTAINER_BUNDLE_IDS: string[] = resolveRunnerContainerBundleIds( - process.env, -); - export function resolveExpectedRunnerCacheMetadata( device: DeviceInfo, projectRoot: string = findProjectRoot(), budget?: RunnerPhaseBudget, ): RunnerXctestrunCacheMetadata { const platformName = resolveRunnerPlatformName(device); + const { env } = commandHostEnvironment(); return { schemaVersion: RUNNER_CACHE_SCHEMA_VERSION, packageVersion: readVersion(projectRoot), @@ -136,15 +135,15 @@ export function resolveExpectedRunnerCacheMetadata( deviceKind: device.kind, target: device.target ?? 'mobile', buildDestinationFamily: resolveRunnerBuildDestinationFamily(device), - runnerBundleBuildSettings: resolveRunnerBundleBuildSettings(process.env), + runnerBundleBuildSettings: resolveRunnerBundleBuildSettings(env), runnerSigningBuildSettings: resolveRunnerSigningBuildSettings( - process.env, + env, device.kind === 'device', device, ), runnerPerformanceBuildSettings: resolveRunnerPerformanceBuildSettings(), - runnerArchBuildSettings: resolveRunnerArchBuildSettings(process.env), - runnerSandboxBuildArgs: resolveRunnerSandboxBuildArgs(), + runnerArchBuildSettings: resolveRunnerArchBuildSettings(env), + runnerSandboxBuildArgs: resolveRunnerSandboxBuildArgs(env), }; } @@ -152,7 +151,7 @@ export function resolveRunnerDerivedPath( device: DeviceInfo, metadata: RunnerXctestrunCacheMetadata, ): string { - const override = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim(); + const override = commandHostEnvironment().env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim(); if (override) { return path.resolve(override); } @@ -256,7 +255,7 @@ export function resolveRunnerMaxConcurrentDestinationsFlag(device: DeviceInfo): } export function resolveRunnerSigningBuildSettings( - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = commandHostEnvironment().env, forDevice = false, device: Pick = { platform: 'apple' }, ): string[] { @@ -285,7 +284,9 @@ export function resolveRunnerSigningBuildSettings( return args; } -export function resolveRunnerBundleBuildSettings(env: NodeJS.ProcessEnv = process.env): string[] { +export function resolveRunnerBundleBuildSettings( + env: NodeJS.ProcessEnv = commandHostEnvironment().env, +): string[] { const appBundleId = resolveRunnerAppBundleId(env); const testBundleId = resolveRunnerTestBundleId(env); return [ @@ -310,7 +311,9 @@ export function resolveRunnerPerformanceBuildSettings(): string[] { * 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[] { +export function resolveRunnerArchBuildSettings( + env: NodeJS.ProcessEnv = commandHostEnvironment().env, +): string[] { const archs = env.AGENT_DEVICE_XCUITEST_ARCHS?.trim(); return archs ? [`ARCHS=${archs}`] : []; } @@ -329,11 +332,10 @@ export function resolveRunnerBuildLocationSettings(derived: string): string[] { ]; } -export function resolveRunnerSandboxBuildArgs(): string[] { - return [ - ...RUNNER_SANDBOX_BUILD_ARGS, - `OTHER_SWIFT_FLAGS=${resolveRunnerSwiftFlags(process.env)}`, - ]; +export function resolveRunnerSandboxBuildArgs( + env: NodeJS.ProcessEnv = commandHostEnvironment().env, +): string[] { + return [...RUNNER_SANDBOX_BUILD_ARGS, `OTHER_SWIFT_FLAGS=${resolveRunnerSwiftFlags(env)}`]; } function resolveRunnerSwiftFlags(env: NodeJS.ProcessEnv): string { diff --git a/packages/platform-apple/src/runner/runner-cache-retention.ts b/packages/platform-apple/src/runner/runner-cache-retention.ts index 5db90afcf0..7e06c3c1d8 100644 --- a/packages/platform-apple/src/runner/runner-cache-retention.ts +++ b/packages/platform-apple/src/runner/runner-cache-retention.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { emitDiagnostic } from './host.ts'; +import { commandHostEnvironment, emitDiagnostic } from './host.ts'; import { acquireRunnerXctestrunCacheLock, emitRunnerXctestrunDecision, @@ -16,7 +16,9 @@ const MIN_IDLE_MS = 24 * 60 * 60_000; const CACHE_KEY_DIRECTORY = /^cache-[0-9a-f]{16}$/; /** How many runner cache keys per platform folder survive, the current one included; 0 keeps them all. */ -export function resolveRunnerCacheKeepCount(env: NodeJS.ProcessEnv = process.env): number { +export function resolveRunnerCacheKeepCount( + env: NodeJS.ProcessEnv = commandHostEnvironment().env, +): number { const raw = env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP?.trim(); if (!raw || !/^\d+$/.test(raw)) return DEFAULT_RUNNER_CACHE_KEEP; return Number(raw); @@ -33,7 +35,7 @@ export function resolveRunnerCacheKeepCount(env: NodeJS.ProcessEnv = process.env */ export async function evictStaleRunnerCaches( currentDerived: string, - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = commandHostEnvironment().env, nowMs: number = Date.now(), ): Promise { const keep = resolveRunnerCacheKeepCount(env); diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts index fbb60aba68..8519707150 100644 --- a/packages/platform-apple/src/runner/runner-cache.ts +++ b/packages/platform-apple/src/runner/runner-cache.ts @@ -8,6 +8,7 @@ import { withProcessLock, isEnvTruthy, findProjectRoot, + commandHostEnvironment, } from './host.ts'; import { RUNNER_CACHE_METADATA_FILE, @@ -284,12 +285,12 @@ function evaluateRunnerCacheMetadata( } function shouldCleanDerived(): boolean { - return isEnvTruthy(process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED); + return isEnvTruthy(commandHostEnvironment().env.AGENT_DEVICE_IOS_CLEAN_DERIVED); } export function assertSafeDerivedCleanup( derivedPath: string, - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = commandHostEnvironment().env, ): void { const override = env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim(); if (!override) { diff --git a/packages/platform-apple/src/runner/runner-disposal.ts b/packages/platform-apple/src/runner/runner-disposal.ts index 297bc4735f..3152b03d81 100644 --- a/packages/platform-apple/src/runner/runner-disposal.ts +++ b/packages/platform-apple/src/runner/runner-disposal.ts @@ -1,4 +1,5 @@ import { + commandHostEnvironment, emitDiagnostic, isProcessAlive, isProcessGroupAlive, @@ -24,7 +25,7 @@ import { } from './runner-lease.ts'; import { forgetRunnerPrepProcess, - IOS_RUNNER_CONTAINER_BUNDLE_IDS, + resolveRunnerContainerBundleIds, runnerPrepProcessChildren, runnerPrepProcessChildrenWithoutLiveOwner, } from './runner-xctestrun.ts'; @@ -289,7 +290,7 @@ async function terminateRunnerSimulatorApps(device: DeviceInfo): Promise { if (device.kind !== 'simulator') return; await Promise.allSettled( - IOS_RUNNER_CONTAINER_BUNDLE_IDS.map(async (bundleId) => { + resolveRunnerContainerBundleIds(commandHostEnvironment().env).map(async (bundleId) => { try { await runXcrun(buildSimctlArgsForDevice(device, ['terminate', device.id, bundleId]), { allowFailure: true, diff --git a/packages/platform-apple/src/runner/runner-lease.ts b/packages/platform-apple/src/runner/runner-lease.ts index 18f3d77742..04ef2c12fd 100644 --- a/packages/platform-apple/src/runner/runner-lease.ts +++ b/packages/platform-apple/src/runner/runner-lease.ts @@ -14,6 +14,7 @@ import { classifyOwnerLiveness, leaseOwnerStateDir, shellQuote, + commandHostEnvironment, } from './host.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -301,7 +302,7 @@ function logicalLeaseContextMatchesDevice( function readCurrentStateDir(): string | undefined { const configured = leaseOwnerStateDir(); if (configured) return configured; - return process.env.AGENT_DEVICE_STATE_DIR?.trim() || undefined; + return commandHostEnvironment().env.AGENT_DEVICE_STATE_DIR?.trim() || undefined; } function buildBusyRunnerLeaseHint( @@ -470,7 +471,7 @@ function resolveRunnerLeasePath(deviceId: string): string { } function resolveRunnerLeaseRoot(): string { - const override = process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR?.trim(); + const override = commandHostEnvironment().env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR?.trim(); if (override) return path.resolve(override); return path.join(os.homedir(), '.agent-device', 'apple-runner', 'leases'); } diff --git a/packages/platform-apple/src/runner/runner-retention.ts b/packages/platform-apple/src/runner/runner-retention.ts index 1e818afef6..ba66c66d90 100644 --- a/packages/platform-apple/src/runner/runner-retention.ts +++ b/packages/platform-apple/src/runner/runner-retention.ts @@ -1,6 +1,6 @@ import net from 'node:net'; import { isIosFamily } from '@agent-device/kernel/device'; -import { emitDiagnostic } from './host.ts'; +import { commandHostEnvironment, emitDiagnostic } from './host.ts'; import { isRunnerMainThreadOccupied, type RunnerSession, @@ -117,7 +117,7 @@ function needsListenerWatch(session: RunnerSession): boolean { return session.device.kind === 'simulator' && isIosFamily(session.device); } -function resolveRunnerIdleStopMs(env: NodeJS.ProcessEnv = process.env): number { +function resolveRunnerIdleStopMs(env: NodeJS.ProcessEnv = commandHostEnvironment().env): number { const raw = env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS?.trim(); if (raw) { const parsed = Number(raw); diff --git a/packages/platform-apple/src/runner/runner-session.ts b/packages/platform-apple/src/runner/runner-session.ts index 16b00c4043..2b3d58ffa5 100644 --- a/packages/platform-apple/src/runner/runner-session.ts +++ b/packages/platform-apple/src/runner/runner-session.ts @@ -6,6 +6,7 @@ import { emitDiagnostic, buildSimctlArgsForDevice, runXcrun, + commandHostEnvironment, } from './host.ts'; import { isApplePlatform, type DeviceInfo } from '@agent-device/kernel/device'; import type { RunnerLogicalLeaseContext } from '@agent-device/contracts/runner-lease-context'; @@ -18,7 +19,7 @@ import { ensureXctestrunArtifact, fenceRunnerStartAdmissionsForTeardown, finishRunnerStartAdmission, - IOS_RUNNER_CONTAINER_BUNDLE_IDS, + resolveRunnerContainerBundleIds, openRunnerStartAdmission, prepareXctestrunWithEnv, readmitRunnerStartAdmission, @@ -524,7 +525,7 @@ async function cleanupStaleSimulatorRunnerBundles(device: DeviceInfo): Promise { + resolveRunnerContainerBundleIds(commandHostEnvironment().env).map(async (bundleId) => { // Best-effort cleanup only; xcodebuild may still be able to install. await uninstallStaleSimulatorRunnerBundle(device, bundleId); }), diff --git a/packages/platform-apple/src/runner/runner-xctestrun.ts b/packages/platform-apple/src/runner/runner-xctestrun.ts index 3ed2714ced..4b19e8b3c9 100644 --- a/packages/platform-apple/src/runner/runner-xctestrun.ts +++ b/packages/platform-apple/src/runner/runner-xctestrun.ts @@ -27,9 +27,9 @@ export { type RunnerXctestrunCacheKind, } from './runner-cache.ts'; export { - IOS_RUNNER_CONTAINER_BUNDLE_IDS, resolveExpectedRunnerCacheMetadata, resolveRunnerAppBundleId, + resolveRunnerContainerBundleIds, resolveRunnerDerivedPath, } from './runner-cache-metadata.ts'; export { diff --git a/scripts/write-xcuitest-cache-metadata.ts b/scripts/write-xcuitest-cache-metadata.ts index f4b8e8c4dd..d3d68c33e8 100644 --- a/scripts/write-xcuitest-cache-metadata.ts +++ b/scripts/write-xcuitest-cache-metadata.ts @@ -1,6 +1,8 @@ #!/usr/bin/env node import path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { withCommandHost } from '@agent-device/host-kit/command'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import type { RunnerXctestrunCacheMetadata } from '@agent-device/platform-apple/runner/operations'; import { findRunnerXctestrun, @@ -70,7 +72,11 @@ async function writeXcuitestCacheMetadata( if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { - await writeXcuitestCacheMetadata(process.argv.slice(2), process.cwd()); + const host = captureHostEnvironment(process); + await withCommandHost( + { host, requestEnv: undefined }, + async () => await writeXcuitestCacheMetadata(process.argv.slice(2), host.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 index 9ab9f596db..4d6782083a 100644 --- a/scripts/xcuitest-build-settings.ts +++ b/scripts/xcuitest-build-settings.ts @@ -28,7 +28,7 @@ function resolveXcuitestBuildSettings( return [ ...resolveRunnerPerformanceBuildSettings(), ...resolveRunnerArchBuildSettings(env), - ...resolveRunnerSandboxBuildArgs(), + ...resolveRunnerSandboxBuildArgs(env), ...resolveRunnerBundleBuildSettings(env), ...resolveRunnerSigningBuildSettings(env, device.kind === 'device', device), ]; diff --git a/src/platform-runtime-screen-recording-apple-runner-host.test.ts b/src/platform-runtime-screen-recording-apple-runner-host.test.ts index 10c38fdb00..e475499a4b 100644 --- a/src/platform-runtime-screen-recording-apple-runner-host.test.ts +++ b/src/platform-runtime-screen-recording-apple-runner-host.test.ts @@ -11,7 +11,7 @@ vi.mock('@agent-device/platform-apple/runner/operations', () => ({ runAppleRunnerCommand: runnerClient.run, })); vi.mock('@agent-device/platform-apple/runner', () => ({ - IOS_RUNNER_CONTAINER_BUNDLE_IDS: ['com.callstack.agentdevice.runner'], + resolveRunnerContainerBundleIds: () => ['com.callstack.agentdevice.runner'], })); const simulator = { diff --git a/src/platform-runtime-screen-recording-apple-runner-host.ts b/src/platform-runtime-screen-recording-apple-runner-host.ts index 9f238f6b88..069909c012 100644 --- a/src/platform-runtime-screen-recording-apple-runner-host.ts +++ b/src/platform-runtime-screen-recording-apple-runner-host.ts @@ -106,6 +106,7 @@ export async function captureAppleClockAnchor( } export async function isAppleRunnerBundleId(bundleId: string): Promise { - const { IOS_RUNNER_CONTAINER_BUNDLE_IDS } = await import('@agent-device/platform-apple/runner'); - return IOS_RUNNER_CONTAINER_BUNDLE_IDS.includes(bundleId); + const { resolveRunnerContainerBundleIds } = await import('@agent-device/platform-apple/runner'); + const { commandHostEnvironment } = await import('@agent-device/host-kit/command'); + return resolveRunnerContainerBundleIds(commandHostEnvironment().env).includes(bundleId); } From b064bc64929de01af10739ead406824fff0034a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:15:39 +0200 Subject: [PATCH 07/27] fix(host): run the daemon clean script's runner lease cleanup inside its own host Co-Authored-By: Claude Sonnet 5.5 --- scripts/clean-daemon.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/clean-daemon.ts b/scripts/clean-daemon.ts index 7343aafedf..444ab02d9d 100644 --- a/scripts/clean-daemon.ts +++ b/scripts/clean-daemon.ts @@ -2,6 +2,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; +import { withCommandHost } from '@agent-device/host-kit/command'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { readRegisteredDaemonIdentity } from '@agent-device/daemon-contracts/daemon-registration'; import { @@ -39,7 +41,10 @@ if (retirement.status === 'retained') { if (retirement.status === 'retired') { const { cleanupRunnerLeasesForOwner } = await import('@agent-device/platform-apple/runner/operations'); - await cleanupRunnerLeasesForOwner(retirement.termination.identity); + await withCommandHost( + { host: captureHostEnvironment(process), requestEnv: undefined }, + async () => await cleanupRunnerLeasesForOwner(retirement.termination.identity), + ); } if (process.argv.includes(PRUNE_DEV_FLAG)) { From 20f00ed7df75e43c30d1ab70daa63adf15b6841b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:39:10 +0200 Subject: [PATCH 08/27] refactor(host): hand leaf modules their environment instead of importing the command host The eager-closure budgets forbid the host-kit command import in the install source download, the app-log file policy and the Maestro HTTP helper, so their callers pass the environment they already hold. Client tests that change PATH give the client a host instead. Co-Authored-By: Claude Sonnet 5.5 --- .../src/capture-admission/app-log-files.ts | 6 +- packages/daemon/src/resources/app-log.ts | 3 +- .../src/server/http/http-server-proxy.test.ts | 15 ++- .../internal/__tests__/session-logs.test.ts | 3 + packages/maestro/package.json | 1 - .../__tests__/run-script-http.test.ts | 120 ++++++++++-------- .../src/daemon-port/run-script-http-child.ts | 2 + .../src/daemon-port/run-script-http.ts | 20 ++- packages/maestro/tsconfig.json | 3 +- .../platform-android/src/install-artifact.ts | 2 + .../src/core/install-artifact.ts | 2 + packages/provision-kit/package.json | 1 - .../src/install-source-download.test.ts | 9 ++ .../src/install-source-download.ts | 5 +- .../install-source-network-transport.test.ts | 17 +-- .../src/install-source-network-transport.ts | 10 +- packages/provision-kit/src/install-source.ts | 23 +++- packages/provision-kit/tsconfig.json | 7 +- pnpm-lock.yaml | 6 - src/__tests__/install-source.test.ts | 6 +- src/agent-device-client-metro.test.ts | 18 +-- .../react-devtools-connection.test.ts | 8 +- src/platform-runtime-app-log-output.ts | 3 +- 23 files changed, 165 insertions(+), 125 deletions(-) diff --git a/packages/capture-kit/src/capture-admission/app-log-files.ts b/packages/capture-kit/src/capture-admission/app-log-files.ts index 6de833b822..d3be442797 100644 --- a/packages/capture-kit/src/capture-admission/app-log-files.ts +++ b/packages/capture-kit/src/capture-admission/app-log-files.ts @@ -1,6 +1,5 @@ import fs from 'node:fs'; import path from 'node:path'; -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { AppError } from '@agent-device/kernel/errors'; import { lstatIfPresent, NOT_REGULAR_FILE_HINT } from '@agent-device/host-kit/file'; @@ -13,10 +12,7 @@ export type AppLogRotationConfig = Readonly<{ }>; /** Shared lower-level file policy for daemon markers and runtime output sinks. */ -export function ensureAppLogPath( - outPath: string, - env: NodeJS.ProcessEnv = commandHostEnvironment().env, -): void { +export function ensureAppLogPath(outPath: string, env: NodeJS.ProcessEnv): void { const directory = path.dirname(outPath); if (!fs.existsSync(directory)) fs.mkdirSync(directory, { recursive: true }); assertAppLogFileIsNotSymbolicLink(outPath); diff --git a/packages/daemon/src/resources/app-log.ts b/packages/daemon/src/resources/app-log.ts index 31ef5a3fbf..a7c5b5a18f 100644 --- a/packages/daemon/src/resources/app-log.ts +++ b/packages/daemon/src/resources/app-log.ts @@ -1,5 +1,6 @@ import { ensureAppLogPath } from '@agent-device/capture-kit/app-log-files'; import fs from 'node:fs'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import path from 'node:path'; import { openVerifiedFileForAppend, @@ -27,7 +28,7 @@ export function getAppLogPathMetadata(outPath: string): { } export function appendAppLogMarker(outPath: string, marker: string): void { - ensureAppLogPath(outPath); + ensureAppLogPath(outPath, commandHostEnvironment().env); const line = `[agent-device][mark][${new Date().toISOString()}] ${marker.trim() || 'marker'}\n`; const descriptor = openVerifiedFileForAppend(outPath); try { diff --git a/packages/daemon/src/server/http/http-server-proxy.test.ts b/packages/daemon/src/server/http/http-server-proxy.test.ts index b670de57a9..0ccf3755bb 100644 --- a/packages/daemon/src/server/http/http-server-proxy.test.ts +++ b/packages/daemon/src/server/http/http-server-proxy.test.ts @@ -183,12 +183,15 @@ test('proxy enforces public-only Maestro HTTP policy on a local daemon', async ( const url = request.positionals[0] ?? ''; return { ok: true, - data: await executeRunScriptHttpRequest({ - method: 'GET', - url, - headers: {}, - publicNetworkOnly: request.internal?.publicNetworkOnly === true, - }), + data: await executeRunScriptHttpRequest( + { + method: 'GET', + url, + headers: {}, + publicNetworkOnly: request.internal?.publicNetworkOnly === true, + }, + process.env, + ), }; }, }); diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts index ae717ea6b0..a8150557e4 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts @@ -12,6 +12,7 @@ import { } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { + bindTestCommandHost, createTestAppLogLiveHandle, unavailableDeploymentSnapshotAndShutdownOperationFacts, createTestHostEnvironment, @@ -48,6 +49,8 @@ type RuntimeHarness = ReturnType; let runtime: RuntimeHarness; let admissionLedger: AppLogAdmissionLedger; +bindTestCommandHost(); + beforeEach(() => { runtime = createRuntimeHarness(); admissionLedger = createAppLogAdmissionLedger(); diff --git a/packages/maestro/package.json b/packages/maestro/package.json index f7e8331b14..280734aa4f 100644 --- a/packages/maestro/package.json +++ b/packages/maestro/package.json @@ -33,7 +33,6 @@ } }, "devDependencies": { - "@agent-device/test-kit": "workspace:*", "undici": "7.29.1", "yaml": "^2.9.0" } diff --git a/packages/maestro/src/daemon-port/__tests__/run-script-http.test.ts b/packages/maestro/src/daemon-port/__tests__/run-script-http.test.ts index 363a6ec305..5a610a8aa6 100644 --- a/packages/maestro/src/daemon-port/__tests__/run-script-http.test.ts +++ b/packages/maestro/src/daemon-port/__tests__/run-script-http.test.ts @@ -5,7 +5,6 @@ import { gzipSync } from 'node:zlib'; import { afterEach, beforeEach, test, vi } from 'vitest'; import type { Dispatcher } from 'undici'; import { AppError } from '@agent-device/kernel/errors'; -import { bindTestCommandHost } from '@agent-device/test-kit'; import { closeLoopbackServer, listenOnLoopback, @@ -24,8 +23,6 @@ import { PublicFetchDispatcher, } from '../run-script-http.ts'; -bindTestCommandHost(); - beforeEach(() => { vi.resetAllMocks(); }); @@ -43,13 +40,16 @@ test('keeps local requests on the existing Fetch path', async () => { ); vi.stubGlobal('fetch', fetchMock); - const result = await executeRunScriptHttpRequest({ - method: 'POST', - url: 'http://127.0.0.1:8080/local', - headers: { authorization: 'secret' }, - body: '{}', - publicNetworkOnly: false, - }); + const result = await executeRunScriptHttpRequest( + { + method: 'POST', + url: 'http://127.0.0.1:8080/local', + headers: { authorization: 'secret' }, + body: '{}', + publicNetworkOnly: false, + }, + process.env, + ); assert.deepEqual(result, { status: 201, @@ -76,13 +76,16 @@ test('preserves Fetch decompression for public requests', async (t) => { const port = await listenOnLoopback(server); approveDownloadSourceUrl.mockResolvedValue({ address: '127.0.0.1', family: 4 }); - const result = await executeRunScriptHttpRequest({ - method: 'POST', - url: `http://public.test:${port}/data`, - headers: {}, - body: '{}', - publicNetworkOnly: true, - }); + const result = await executeRunScriptHttpRequest( + { + method: 'POST', + url: `http://public.test:${port}/data`, + headers: {}, + body: '{}', + publicNetworkOnly: true, + }, + process.env, + ); assert.equal(result.body, 'decoded'); } finally { @@ -112,16 +115,19 @@ test('uses Fetch cross-origin 307 header and body semantics', async (t) => { const port = await listenOnLoopback(server); approveDownloadSourceUrl.mockResolvedValue({ address: '127.0.0.1', family: 4 }); - const result = await executeRunScriptHttpRequest({ - method: 'POST', - url: `http://public.test:${port}/start`, - headers: { - authorization: 'secret', - 'content-type': 'application/json', + const result = await executeRunScriptHttpRequest( + { + method: 'POST', + url: `http://public.test:${port}/start`, + headers: { + authorization: 'secret', + 'content-type': 'application/json', + }, + body: '{}', + publicNetworkOnly: true, }, - body: '{}', - publicNetworkOnly: true, - }); + process.env, + ); assert.equal(result.body, 'ok'); assert.equal(redirectedHeaders?.authorization, undefined); @@ -149,12 +155,15 @@ test('reapproves a redirect before its connection', async (t) => { .mockRejectedValueOnce(new AppError('INVALID_ARGS', 'non-public address')); await assert.rejects( - executeRunScriptHttpRequest({ - method: 'GET', - url: `http://public.test:${port}/start`, - headers: {}, - publicNetworkOnly: true, - }), + executeRunScriptHttpRequest( + { + method: 'GET', + url: `http://public.test:${port}/start`, + headers: {}, + publicNetworkOnly: true, + }, + process.env, + ), /non-public address/, ); assert.equal(requests, 1); @@ -167,12 +176,15 @@ test('validates an IP literal before connecting', async () => { approveDownloadSourceUrl.mockRejectedValue(new AppError('INVALID_ARGS', 'non-public address')); await assert.rejects( - executeRunScriptHttpRequest({ - method: 'GET', - url: 'http://169.254.169.254/latest/meta-data', - headers: {}, - publicNetworkOnly: true, - }), + executeRunScriptHttpRequest( + { + method: 'GET', + url: 'http://169.254.169.254/latest/meta-data', + headers: {}, + publicNetworkOnly: true, + }, + process.env, + ), /non-public address/, ); assert.equal(approveDownloadSourceUrl.mock.calls.length, 1); @@ -206,12 +218,15 @@ test('preserves configured proxy routing while pinning the approved destination' vi.stubEnv('NO_PROXY', ''); approveDownloadSourceUrl.mockResolvedValue({ address: '127.0.0.1', family: 4 }); - const result = await executeRunScriptHttpRequest({ - method: 'GET', - url: `http://public.test:${targetPort}/data`, - headers: {}, - publicNetworkOnly: true, - }); + const result = await executeRunScriptHttpRequest( + { + method: 'GET', + url: `http://public.test:${targetPort}/data`, + headers: {}, + publicNetworkOnly: true, + }, + process.env, + ); assert.equal(result.body, 'proxied'); assert.equal(connectTarget, `127.0.0.1:${targetPort}`); @@ -223,7 +238,7 @@ test('preserves configured proxy routing while pinning the approved destination' }); test('rejects an HTTPS downgrade before approval or dispatch', async () => { - const dispatcher = new PublicFetchDispatcher(true, AbortSignal.timeout(1_000)); + const dispatcher = new PublicFetchDispatcher(true, AbortSignal.timeout(1_000), process.env); const error = await new Promise((resolve) => { dispatcher.dispatch({ origin: 'http://example.test', path: '/redirected', method: 'GET' }, { onError: resolve, @@ -245,12 +260,15 @@ test('caps the decoded public response body', async (t) => { approveDownloadSourceUrl.mockResolvedValue({ address: '127.0.0.1', family: 4 }); await assert.rejects( - executeRunScriptHttpRequest({ - method: 'GET', - url: `http://public.test:${port}/large`, - headers: {}, - publicNetworkOnly: true, - }), + executeRunScriptHttpRequest( + { + method: 'GET', + url: `http://public.test:${port}/large`, + headers: {}, + publicNetworkOnly: true, + }, + process.env, + ), new RegExp(`response exceeded ${MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES} bytes`), ); } finally { diff --git a/packages/maestro/src/daemon-port/run-script-http-child.ts b/packages/maestro/src/daemon-port/run-script-http-child.ts index 5a91ff25c3..95310069e1 100644 --- a/packages/maestro/src/daemon-port/run-script-http-child.ts +++ b/packages/maestro/src/daemon-port/run-script-http-child.ts @@ -1,11 +1,13 @@ import { readFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import type { RunScriptHttpRequest } from './run-script-http.ts'; import { executeRunScriptHttpRequest } from './run-script-http.ts'; export async function runScriptHttpChild(): Promise { const response = await executeRunScriptHttpRequest( JSON.parse(readFileSync(0, 'utf8')) as RunScriptHttpRequest, + captureHostEnvironment(process).env, ); process.stdout.write(JSON.stringify(response)); } diff --git a/packages/maestro/src/daemon-port/run-script-http.ts b/packages/maestro/src/daemon-port/run-script-http.ts index ccf9af1807..11d5fb1f8e 100644 --- a/packages/maestro/src/daemon-port/run-script-http.ts +++ b/packages/maestro/src/daemon-port/run-script-http.ts @@ -22,9 +22,10 @@ export type RunScriptHttpResponse = { export async function executeRunScriptHttpRequest( input: RunScriptHttpRequest, + environment: NodeJS.ProcessEnv, ): Promise { if (!input.publicNetworkOnly) return await executeLocalRequest(input); - return await executePublicRequest(input); + return await executePublicRequest(input, environment); } async function executeLocalRequest(input: RunScriptHttpRequest): Promise { @@ -35,10 +36,17 @@ async function executeLocalRequest(input: RunScriptHttpRequest): Promise { +async function executePublicRequest( + input: RunScriptHttpRequest, + environment: NodeJS.ProcessEnv, +): Promise { const initialUrl = parsePublicUrl(input.url); const signal = AbortSignal.timeout(30_000); - const dispatcher = new PublicFetchDispatcher(initialUrl.protocol === 'https:', signal); + const dispatcher = new PublicFetchDispatcher( + initialUrl.protocol === 'https:', + signal, + environment, + ); try { const response = await undiciFetch(initialUrl, { ...fetchInit(input), @@ -69,11 +77,13 @@ export class PublicFetchDispatcher extends Dispatcher { readonly #dispatchers = new Set(); #httpsRequired: boolean; readonly #signal: AbortSignal; + readonly #environment: NodeJS.ProcessEnv; - constructor(httpsRequired: boolean, signal: AbortSignal) { + constructor(httpsRequired: boolean, signal: AbortSignal, environment: NodeJS.ProcessEnv) { super(); this.#httpsRequired = httpsRequired; this.#signal = signal; + this.#environment = environment; } override dispatch( @@ -100,7 +110,7 @@ export class PublicFetchDispatcher extends Dispatcher { } if (url.protocol === 'https:') this.#httpsRequired = true; const approved = await approveDownloadSourceUrl(url, this.#signal); - const proxyUrl = resolveProxyForUrl(url); + const proxyUrl = resolveProxyForUrl(url, this.#environment); const dispatcher = proxyUrl ? proxyDispatcher(proxyUrl, url) : directDispatcher(approved.address, approved.family); diff --git a/packages/maestro/tsconfig.json b/packages/maestro/tsconfig.json index 79afdd3f29..6614d7d44e 100644 --- a/packages/maestro/tsconfig.json +++ b/packages/maestro/tsconfig.json @@ -17,7 +17,6 @@ { "path": "../host-kit" }, { "path": "../kernel" }, { "path": "../provision-kit" }, - { "path": "../selectors" }, - { "path": "../test-kit" } + { "path": "../selectors" } ] } diff --git a/packages/platform-android/src/install-artifact.ts b/packages/platform-android/src/install-artifact.ts index 5ec4b3cc19..2dc0224524 100644 --- a/packages/platform-android/src/install-artifact.ts +++ b/packages/platform-android/src/install-artifact.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { LocalInstallSource } from '@agent-device/kernel/contracts'; import { materializeInstallablePath } from '@agent-device/provision-kit/install-source'; import * as manifest from './manifest.ts'; @@ -20,6 +21,7 @@ export async function prepareAndroidInstallArtifact( stat.isFile() && isAndroidInstallablePath(candidatePath), installableLabel: 'Android installable (.apk or .aab)', signal: options?.signal, + environment: commandHostEnvironment().env, }); try { const identity = diff --git a/packages/platform-apple/src/core/install-artifact.ts b/packages/platform-apple/src/core/install-artifact.ts index 5fe729906d..5708549af7 100644 --- a/packages/platform-apple/src/core/install-artifact.ts +++ b/packages/platform-apple/src/core/install-artifact.ts @@ -14,6 +14,7 @@ import { noteInstallArtifactArchiveDepth, withInstallArtifactArchiveScope, } from '@agent-device/provision-kit/install-artifact-archive-context'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { materializeInstallablePath } from '@agent-device/provision-kit/install-source'; import { makeHostTemporaryDirectory, @@ -62,6 +63,7 @@ async function prepareIosInstallArtifactInScope( (stat.isFile() && candidatePath.toLowerCase().endsWith('.ipa')), installableLabel: 'iOS installable (.app or .ipa)', signal: options?.signal, + environment: commandHostEnvironment().env, }); let resolved: Awaited> | undefined; diff --git a/packages/provision-kit/package.json b/packages/provision-kit/package.json index 53e882eec1..da21bc6aa4 100644 --- a/packages/provision-kit/package.json +++ b/packages/provision-kit/package.json @@ -44,7 +44,6 @@ } }, "devDependencies": { - "@agent-device/test-kit": "workspace:*", "ipaddr.js": "^2.5.0", "undici": "7.29.1" } diff --git a/packages/provision-kit/src/install-source-download.test.ts b/packages/provision-kit/src/install-source-download.test.ts index 119a18684a..c9e46506a8 100644 --- a/packages/provision-kit/src/install-source-download.test.ts +++ b/packages/provision-kit/src/install-source-download.test.ts @@ -36,6 +36,7 @@ test('download redirects revalidate destinations and strip sensitive cross-origi 'user-agent': 'agent-device-test', }, signal: new AbortController().signal, + environment: process.env, }); assert.equal(await fs.readFile(result, 'utf8'), 'apk'); assert.equal(lookup.mock.calls.length, 2); @@ -70,12 +71,14 @@ test('download sends a default user-agent that a caller header replaces', async tempDir: tempRoot, url: 'https://example.com/default.apk', signal: new AbortController().signal, + environment: process.env, }); await downloadInstallSource({ tempDir: tempRoot, url: 'https://example.com/custom.apk', headers: { 'User-Agent': 'caller-agent' }, signal: new AbortController().signal, + environment: process.env, }); const defaultHeaders = requestMock.mock.calls[0]![0].headers; const customHeaders = requestMock.mock.calls[1]![0].headers; @@ -108,6 +111,7 @@ test('download errors do not disclose URL credentials or query values', async () url: 'https://private-user:private-pass@example.com/app?token=private-query', headers: { authorization: 'private-header' }, signal: new AbortController().signal, + environment: process.env, }).catch((error: unknown) => error); assert.match((error as Error).message, /credentials are not allowed/i); const serialized = JSON.stringify(error); @@ -142,6 +146,7 @@ test('download identifies the daemon as the requester and preserves the network tempDir: tempRoot, url: 'https://example.com/app.apk', signal: new AbortController().signal, + environment: process.env, }).catch((error: unknown) => error); assert.ok(error instanceof Error); @@ -178,6 +183,7 @@ test('download omits network error codes that are not identifiers', async () => tempDir: tempRoot, url: 'https://example.com/app.apk', signal: new AbortController().signal, + environment: process.env, }).catch((error: unknown) => error); assert.ok(error instanceof Error); @@ -210,6 +216,7 @@ test('download rejects non-identity content encoding and malformed lengths', asy tempDir: tempRoot, url: 'https://example.com/app', signal: new AbortController().signal, + environment: process.env, }), /content encoding/i, ); @@ -221,6 +228,7 @@ test('download rejects non-identity content encoding and malformed lengths', asy tempDir: tempRoot, url: 'https://example.com/app', signal: new AbortController().signal, + environment: process.env, }), /content-length/i, ); @@ -252,6 +260,7 @@ test('download and the trusted-source check refuse an unparsable source alike', tempDir: '/unused', url: source, signal: new AbortController().signal, + environment: process.env, }), expected, ); diff --git a/packages/provision-kit/src/install-source-download.ts b/packages/provision-kit/src/install-source-download.ts index 9fd53df554..1a035fbc04 100644 --- a/packages/provision-kit/src/install-source-download.ts +++ b/packages/provision-kit/src/install-source-download.ts @@ -33,11 +33,12 @@ export async function downloadInstallSource(params: { url: string; headers?: Record; signal: AbortSignal; + environment: NodeJS.ProcessEnv; }): Promise { let currentUrl = parseSourceUrl(params.url); let headers = withDefaultUserAgent(sanitizeHeaders(params.headers)); for (let redirectCount = 0; ; redirectCount += 1) { - const response = await requestHop(currentUrl, headers, params.signal); + const response = await requestHop(currentUrl, headers, params.signal, params.environment); try { const redirected = readRedirect(response, currentUrl, redirectCount); if (redirected) { @@ -57,6 +58,7 @@ async function requestHop( url: URL, headers: Record, signal: AbortSignal, + environment: NodeJS.ProcessEnv, ): Promise { const approved = await approveDownloadSourceUrl(url, signal); try { @@ -66,6 +68,7 @@ async function requestHop( family: approved.family, headers: { ...headers, 'accept-encoding': 'identity' }, signal, + environment, }); } catch (error) { if (error instanceof AppError) throw error; diff --git a/packages/provision-kit/src/install-source-network-transport.test.ts b/packages/provision-kit/src/install-source-network-transport.test.ts index e569986718..aadd959deb 100644 --- a/packages/provision-kit/src/install-source-network-transport.test.ts +++ b/packages/provision-kit/src/install-source-network-transport.test.ts @@ -3,16 +3,12 @@ import http from 'node:http'; import type { AddressInfo } from 'node:net'; import { text } from 'node:stream/consumers'; import { test, vi } from 'vitest'; -import { withCommandHost } from '@agent-device/host-kit/command'; -import { bindTestCommandHost } from '@agent-device/test-kit'; import { matchesNoProxy, requestApprovedUrl, resolveProxyForUrl, } from './install-source-network-transport.ts'; -bindTestCommandHost(); - test('lowercase proxy variables override uppercase even when empty', () => { assert.equal( resolveProxyForUrl(new URL('http://example.net'), { @@ -31,18 +27,6 @@ test('lowercase proxy variables override uppercase even when empty', () => { ); }); -test('proxy variables come from the command host, not the process', async () => { - const proxy = await withCommandHost( - { - host: { env: { https_proxy: 'http://host-proxy' }, cwd: process.cwd() }, - requestEnv: undefined, - }, - async () => resolveProxyForUrl(new URL('https://example.net')), - ); - - assert.equal(proxy, 'http://host-proxy'); -}); - test('HTTPS proxy selection falls back to the selected HTTP proxy', () => { assert.equal( resolveProxyForUrl(new URL('https://example.net'), { http_proxy: 'http://proxy' }), @@ -73,6 +57,7 @@ test('direct requests connect to the approved address through the real lookup', family: 4, headers: {}, signal: AbortSignal.timeout(5_000), + environment: process.env, }); try { assert.equal(response.statusCode, 200); diff --git a/packages/provision-kit/src/install-source-network-transport.ts b/packages/provision-kit/src/install-source-network-transport.ts index 907879e6cf..b3efb6f7df 100644 --- a/packages/provision-kit/src/install-source-network-transport.ts +++ b/packages/provision-kit/src/install-source-network-transport.ts @@ -1,5 +1,4 @@ import net from 'node:net'; -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { Agent, ProxyAgent, request, type Dispatcher } from 'undici'; export type InstallSourceNetworkResponse = { @@ -15,8 +14,10 @@ export async function requestApprovedUrl(params: { family: 4 | 6; headers: Record; signal: AbortSignal; + /** The host environment whose proxy variables apply to the request. */ + environment: NodeJS.ProcessEnv; }): Promise { - const proxy = resolveProxyForUrl(params.url); + const proxy = resolveProxyForUrl(params.url, params.environment); const dispatcher = proxy ? proxyDispatcher(proxy, params.url) : directDispatcher(params.approvedAddress, params.family); @@ -44,10 +45,7 @@ export async function requestApprovedUrl(params: { } } -export function resolveProxyForUrl( - url: URL, - environment: NodeJS.ProcessEnv = commandHostEnvironment().env, -): string | undefined { +export function resolveProxyForUrl(url: URL, environment: NodeJS.ProcessEnv): string | undefined { const noProxy = environment.no_proxy ?? environment.NO_PROXY; if (matchesNoProxy(url, noProxy)) return undefined; const httpProxy = environment.http_proxy ?? environment.HTTP_PROXY; diff --git a/packages/provision-kit/src/install-source.ts b/packages/provision-kit/src/install-source.ts index 7435a1466f..3fc73d5daf 100644 --- a/packages/provision-kit/src/install-source.ts +++ b/packages/provision-kit/src/install-source.ts @@ -27,6 +27,8 @@ export type MaterializeInstallableOptions = { ) => boolean; installableLabel: string; signal?: AbortSignal; + /** The host environment whose proxy settings apply to a network download. */ + environment: NodeJS.ProcessEnv; }; export type MaterializedInstallable = { @@ -50,7 +52,10 @@ export async function materializeInstallablePath( ): Promise { const cleanupTasks: Array<() => Promise> = []; try { - const localSource = await materializeLocalSource(options.source, { signal: options.signal }); + const localSource = await materializeLocalSource(options.source, { + signal: options.signal, + environment: options.environment, + }); cleanupTasks.push(localSource.cleanup); const resolved = await resolveInstallableCandidate(localSource.localPath, { archivePath: undefined, @@ -85,7 +90,7 @@ function expandSourcePath(inputPath: string): string { async function materializeLocalSource( source: LocalInstallSource, - options?: { signal?: AbortSignal }, + options: { signal?: AbortSignal; environment: NodeJS.ProcessEnv }, ): Promise { if (source.kind === 'path') { return { @@ -112,17 +117,23 @@ async function materializeLocalSource( async function downloadToTempFile( tempDir: string, url: string, - headers?: Record, - options?: { signal?: AbortSignal }, + headers: Record | undefined, + options: { signal?: AbortSignal; environment: NodeJS.ProcessEnv }, ): Promise { - const requestSignal = options?.signal; + const requestSignal = options.signal; if (requestSignal?.aborted) { throw createRequestCanceledError(); } const timeoutSignal = AbortSignal.timeout(DEFAULT_SOURCE_DOWNLOAD_TIMEOUT_MS); const signal = requestSignal ? AbortSignal.any([requestSignal, timeoutSignal]) : timeoutSignal; try { - return await downloadInstallSource({ tempDir, url, headers, signal }); + return await downloadInstallSource({ + tempDir, + url, + headers, + signal, + environment: options.environment, + }); } catch (error) { throw classifyDownloadError( error, diff --git a/packages/provision-kit/tsconfig.json b/packages/provision-kit/tsconfig.json index 380be0b4f5..247a915b90 100644 --- a/packages/provision-kit/tsconfig.json +++ b/packages/provision-kit/tsconfig.json @@ -10,10 +10,5 @@ "tsBuildInfoFile": "./dist-types/index.tsbuildinfo" }, "include": ["src"], - "references": [ - { "path": "../contracts" }, - { "path": "../host-kit" }, - { "path": "../kernel" }, - { "path": "../test-kit" } - ] + "references": [{ "path": "../contracts" }, { "path": "../host-kit" }, { "path": "../kernel" }] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01f80baea1..0cf558c119 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -458,9 +458,6 @@ importers: specifier: workspace:* version: link:../selectors devDependencies: - '@agent-device/test-kit': - specifier: workspace:* - version: link:../test-kit undici: specifier: 7.29.1 version: 7.29.1 @@ -683,9 +680,6 @@ importers: specifier: workspace:* version: link:../kernel devDependencies: - '@agent-device/test-kit': - specifier: workspace:* - version: link:../test-kit ipaddr.js: specifier: ^2.5.0 version: 2.5.0 diff --git a/src/__tests__/install-source.test.ts b/src/__tests__/install-source.test.ts index 182f160ad5..f21a0d69a7 100644 --- a/src/__tests__/install-source.test.ts +++ b/src/__tests__/install-source.test.ts @@ -26,9 +26,11 @@ import { import { prepareIosInstallArtifact } from '@agent-device/platform-apple/install-artifact'; import { AppError } from '@agent-device/kernel/errors'; import { ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE } from './install-source.coverage.ts'; -import { mkdtempForTest } from '@agent-device/test-kit'; +import { bindTestCommandHost, mkdtempForTest } from '@agent-device/test-kit'; import * as networkTransport from '@agent-device/provision-kit/install-source-network-transport'; +bindTestCommandHost(); + test('validateDownloadSourceUrl rejects localhost and private literal addresses by default', async () => { await assert.rejects( async () => await validateDownloadSourceUrl(new URL('http://127.0.0.1/app.apk')), @@ -140,6 +142,7 @@ test( source: { kind: 'path', path: archivePath }, isInstallablePath: (candidatePath, stat) => stat.isFile() && candidatePath.endsWith('.apk'), installableLabel: 'Android installable (.apk or .aab)', + environment: process.env, }); try { @@ -170,6 +173,7 @@ test('materializeInstallablePath extracts tar.gz archives', async () => { source: { kind: 'path', path: archivePath }, isInstallablePath: (candidatePath, stat) => stat.isFile() && candidatePath.endsWith('.apk'), installableLabel: 'Android installable (.apk or .aab)', + environment: process.env, }); try { diff --git a/src/agent-device-client-metro.test.ts b/src/agent-device-client-metro.test.ts index fc03602fed..d1a6852eb4 100644 --- a/src/agent-device-client-metro.test.ts +++ b/src/agent-device-client-metro.test.ts @@ -203,13 +203,14 @@ test('metro reload targets the dev server bound by metro prepare in the same ses if (req.command === 'close') return { ok: true, data: {} }; throw new Error('metro prepare/reload must stay local and never call the daemon'); }, + // The public MetroPrepareOptions doesn't expose env, so the fake npx must be on the host's PATH. + host: { + env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }, + cwd: projectRoot, + }, }, ); - // The public MetroPrepareOptions doesn't expose env, so the fake npx must be on the real PATH. - const previousPath = process.env.PATH; - process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`; - let pid = 0; try { const prepared = await client.metro.prepare({ @@ -247,7 +248,6 @@ test('metro reload targets the dev server bound by metro prepare in the same ses undefined, ); } finally { - process.env.PATH = previousPath; await stopTestMetroProcess(pid); rmSync(tempRoot, { recursive: true, force: true }); } @@ -279,12 +279,13 @@ test('metro prepare keeps a public Expo address separate from the local reload a transport: async () => { throw new Error('metro prepare/reload must stay local and never call the daemon'); }, + host: { + env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }, + cwd: projectRoot, + }, }, ); - const previousPath = process.env.PATH; - process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`; - let pid = 0; try { const prepared = await client.metro.prepare({ @@ -331,7 +332,6 @@ test('metro prepare keeps a public Expo address separate from the local reload a deviceBaseUrls: [`https://127.0.0.1:${metroPort}${publicBasePath}`], }); } finally { - process.env.PATH = previousPath; await stopTestMetroProcess(pid); rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/src/cli/commands/react-devtools-connection.test.ts b/src/cli/commands/react-devtools-connection.test.ts index 8a1d982167..5362a54e7b 100644 --- a/src/cli/commands/react-devtools-connection.test.ts +++ b/src/cli/commands/react-devtools-connection.test.ts @@ -1,9 +1,15 @@ import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import type { CliFlags } from '@agent-device/contracts/command'; -import { mkdtempForTestSync, writeMetroSessionFixture } from '@agent-device/test-kit'; +import { + bindTestCommandHost, + mkdtempForTestSync, + writeMetroSessionFixture, +} from '@agent-device/test-kit'; import { runReactDevtoolsCommand } from './react-devtools.ts'; +bindTestCommandHost(); + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); diff --git a/src/platform-runtime-app-log-output.ts b/src/platform-runtime-app-log-output.ts index e0e1d3346b..34fb017469 100644 --- a/src/platform-runtime-app-log-output.ts +++ b/src/platform-runtime-app-log-output.ts @@ -1,6 +1,7 @@ import { ensureAppLogPath } from '@agent-device/capture-kit/app-log-files'; import type { AppLogOutputSink } from '@agent-device/contracts/app-log-runtime'; import fs from 'node:fs'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { openVerifiedFileForAppend, openVerifiedFileForRead } from '@agent-device/host-kit/file'; import { requireManagedSessionArtifactPath } from '@agent-device/daemon-contracts/session-artifact-paths'; @@ -9,7 +10,7 @@ export async function openAppLogOutput( pathname: string, ): Promise { pathname = requireManagedOutputPath(sessionsDir, pathname); - ensureAppLogPath(pathname); + ensureAppLogPath(pathname, commandHostEnvironment().env); const descriptor = openVerifiedFileForAppend(pathname); let stream: fs.WriteStream; try { From a45b6ec794d747e7b040fe06d650707c3f392475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:59:15 +0200 Subject: [PATCH 09/27] test(host): bind the command host in the install-artifact suites Co-Authored-By: Claude Sonnet 5.5 --- .../src/__tests__/install-artifact-cancellation.test.ts | 3 +++ packages/platform-android/src/install-artifact.test.ts | 3 +++ .../platform-apple/src/core/__tests__/install-artifact.test.ts | 3 +++ 3 files changed, 9 insertions(+) diff --git a/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts b/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts index 4241f7e867..e301abbb4f 100644 --- a/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts +++ b/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts @@ -10,6 +10,9 @@ import * as networkTransport from '@agent-device/provision-kit/install-source-ne import { mkdtempForTest } from './test-utils/tmp-dir.ts'; import { prepareAndroidInstallArtifact } from '../install-artifact.ts'; import * as manifest from '../manifest.ts'; +import { bindTestCommandHost } from '@agent-device/test-kit'; + +bindTestCommandHost(); test('aborts an in-flight Android artifact identity probe', async () => { const tempRoot = await mkdtempForTest('agent-device-android-artifact-abort-'); diff --git a/packages/platform-android/src/install-artifact.test.ts b/packages/platform-android/src/install-artifact.test.ts index 13a1bc283a..d749a1a6a4 100644 --- a/packages/platform-android/src/install-artifact.test.ts +++ b/packages/platform-android/src/install-artifact.test.ts @@ -9,6 +9,9 @@ import { import { installAndroidArtifact } from './deployment/native.ts'; import { prepareAndroidInstallArtifact } from './install-artifact.ts'; import { ANDROID_EMULATOR } from './__tests__/test-utils/device-fixtures.ts'; +import { bindTestCommandHost } from '@agent-device/test-kit'; + +bindTestCommandHost(); test('installing an already-installed APK resolves its package with no aapt reachable', async () => { // The stub host answers `isExecutable` as false, so no `aapt` is reachable and the identity can diff --git a/packages/platform-apple/src/core/__tests__/install-artifact.test.ts b/packages/platform-apple/src/core/__tests__/install-artifact.test.ts index cda3ca4d84..4aaad8f862 100644 --- a/packages/platform-apple/src/core/__tests__/install-artifact.test.ts +++ b/packages/platform-apple/src/core/__tests__/install-artifact.test.ts @@ -7,6 +7,9 @@ import { AppError } from '@agent-device/kernel/errors'; import { createLocalAppleToolProvider, withAppleToolProvider } from '../tool-provider.ts'; import { prepareIosInstallArtifact } from '../install-artifact.ts'; import { mkdtempForTest } from '../../__tests__/tmp-dir.ts'; +import { bindTestCommandHost } from '@agent-device/test-kit'; + +bindTestCommandHost(); /** * An IPA whose `Payload` holds more than one `.app` is legal on disk and ambiguous for the From 52ee0a1df646e4c1705a885f204db6739b822bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 10:14:04 +0200 Subject: [PATCH 10/27] fix(host): answer review of the platform host environment change Redaction patterns come from the command host, the maestro child reads its own environment without a working-directory lookup, local ~ install sources expand against the host environment, and the runner launch resolves its host inside the try that closes its log descriptor. Co-Authored-By: Claude Sonnet 5.5 --- .../capture-kit/src/recording/swift-cache.ts | 3 ++- .../src/server/http/http-server-proxy.test.ts | 2 +- .../src/daemon-port/run-script-http-child.ts | 3 +-- .../src/runner/runner-process-launch.ts | 4 ++-- packages/provision-kit/src/install-source.ts | 6 ++--- src/platform-runtime-app-log-output.test.ts | 23 +++++++++++++++++++ src/platform-runtime-app-log-output.ts | 7 +++--- 7 files changed, 36 insertions(+), 12 deletions(-) diff --git a/packages/capture-kit/src/recording/swift-cache.ts b/packages/capture-kit/src/recording/swift-cache.ts index 8e4b7773ae..8885beb46e 100644 --- a/packages/capture-kit/src/recording/swift-cache.ts +++ b/packages/capture-kit/src/recording/swift-cache.ts @@ -42,7 +42,8 @@ export function buildRecordingScriptPathCandidates( /** * Resolves one recording helper script: checkout, built package, or working-directory fallback. - * The caller names the project root it trusts, so this module stays free of host metadata lookup. + * The caller names the project root it trusts, so this module stays free of host metadata lookup; + * the working directory defaults to the command host's. */ export function resolveRecordingScriptPath( scriptName: string, diff --git a/packages/daemon/src/server/http/http-server-proxy.test.ts b/packages/daemon/src/server/http/http-server-proxy.test.ts index 0ccf3755bb..ca93e9c3a4 100644 --- a/packages/daemon/src/server/http/http-server-proxy.test.ts +++ b/packages/daemon/src/server/http/http-server-proxy.test.ts @@ -190,7 +190,7 @@ test('proxy enforces public-only Maestro HTTP policy on a local daemon', async ( headers: {}, publicNetworkOnly: request.internal?.publicNetworkOnly === true, }, - process.env, + env, ), }; }, diff --git a/packages/maestro/src/daemon-port/run-script-http-child.ts b/packages/maestro/src/daemon-port/run-script-http-child.ts index 95310069e1..8c20de3831 100644 --- a/packages/maestro/src/daemon-port/run-script-http-child.ts +++ b/packages/maestro/src/daemon-port/run-script-http-child.ts @@ -1,13 +1,12 @@ import { readFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; -import { captureHostEnvironment } from '@agent-device/host-kit/process'; import type { RunScriptHttpRequest } from './run-script-http.ts'; import { executeRunScriptHttpRequest } from './run-script-http.ts'; export async function runScriptHttpChild(): Promise { const response = await executeRunScriptHttpRequest( JSON.parse(readFileSync(0, 'utf8')) as RunScriptHttpRequest, - captureHostEnvironment(process).env, + process.env, ); process.stdout.write(JSON.stringify(response)); } diff --git a/packages/platform-apple/src/runner/runner-process-launch.ts b/packages/platform-apple/src/runner/runner-process-launch.ts index 4fb8c73f41..dd90fb498f 100644 --- a/packages/platform-apple/src/runner/runner-process-launch.ts +++ b/packages/platform-apple/src/runner/runner-process-launch.ts @@ -59,9 +59,9 @@ export function launchRunnerProcess(input: LaunchRunnerProcessInput): LaunchedRu } catch {} }); const listenerReady = createRunnerListenerReadySignal(); - // The runner outlives this process, so it gets its environment and directory as plain values. - const { env, cwd } = commandHostEnvironment(); try { + // The runner outlives this process, so it gets its environment and directory as plain values. + const { env, cwd } = commandHostEnvironment(); const launched = runCmdBackground( 'xcodebuild', [ diff --git a/packages/provision-kit/src/install-source.ts b/packages/provision-kit/src/install-source.ts index 3fc73d5daf..136e68e94a 100644 --- a/packages/provision-kit/src/install-source.ts +++ b/packages/provision-kit/src/install-source.ts @@ -84,8 +84,8 @@ export async function materializeInstallablePath( } } -function expandSourcePath(inputPath: string): string { - return expandUserHomePath(inputPath); +function expandSourcePath(inputPath: string, env: NodeJS.ProcessEnv): string { + return expandUserHomePath(inputPath, { env }); } async function materializeLocalSource( @@ -94,7 +94,7 @@ async function materializeLocalSource( ): Promise { if (source.kind === 'path') { return { - localPath: expandSourcePath(source.path), + localPath: expandSourcePath(source.path, options.environment), cleanup: async () => {}, }; } diff --git a/src/platform-runtime-app-log-output.test.ts b/src/platform-runtime-app-log-output.test.ts index f7fad57bf6..d700a0a432 100644 --- a/src/platform-runtime-app-log-output.test.ts +++ b/src/platform-runtime-app-log-output.test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import { withCommandHost } from '@agent-device/host-kit/command'; import { afterEach, expect, test } from 'vitest'; import { openAppLogOutput, readAppLogOutputTail } from './platform-runtime-app-log-output.ts'; import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; @@ -94,3 +95,25 @@ function finalSymlinkFixture(label: string) { fs.symlinkSync(outsidePath, outputPath); return { sessionsDir, outputPath, outsidePath }; } + +test('redacts with the patterns of the command host, not the process', async () => { + const root = mkdtempForTestSync('agent-device-app-log-redact-'); + roots.push(root); + const sessionsDir = path.join(root, 'sessions'); + const outputPath = path.join(sessionsDir, 'one', 'app.log'); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + + await withCommandHost( + { + host: { env: { AGENT_DEVICE_APP_LOG_REDACT_PATTERNS: 'secret-\\d+' }, cwd: root }, + requestEnv: undefined, + }, + async () => { + const sink = await openAppLogOutput(sessionsDir, outputPath); + await sink.write('token secret-42 leaked\n'); + await sink[Symbol.asyncDispose](); + }, + ); + + expect(fs.readFileSync(outputPath, 'utf8')).toBe('token [REDACTED] leaked\n'); +}); diff --git a/src/platform-runtime-app-log-output.ts b/src/platform-runtime-app-log-output.ts index 34fb017469..70c4b3fe59 100644 --- a/src/platform-runtime-app-log-output.ts +++ b/src/platform-runtime-app-log-output.ts @@ -23,7 +23,8 @@ export async function openAppLogOutput( stream.on('error', (error) => { streamError ??= error; }); - const patterns = redactionPatterns(); + const { env } = commandHostEnvironment(); + const patterns = redactionPatterns(env); let pending = ''; let writes = Promise.resolve(); let disposal: Promise | undefined; @@ -90,8 +91,8 @@ function requireManagedOutputPath(sessionsDir: string, pathname: string): string }); } -function redactionPatterns(): RegExp[] { - return (process.env.AGENT_DEVICE_APP_LOG_REDACT_PATTERNS ?? '') +function redactionPatterns(env: NodeJS.ProcessEnv): RegExp[] { + return (env.AGENT_DEVICE_APP_LOG_REDACT_PATTERNS ?? '') .split(',') .map((part) => part.trim()) .filter(Boolean) From 2e0e4f14124e3dfe6036bb74fd6f69268ba91a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 11:11:58 +0200 Subject: [PATCH 11/27] refactor(react-native): take the host explicitly instead of reading the command host Co-Authored-By: Claude Sonnet 5.5 --- .../src/companion/client-companion-tunnel.ts | 11 +- .../companion/client-metro-companion.test.ts | 25 +- packages/react-native/src/index-metro.test.ts | 224 ++++++++++-------- packages/react-native/src/index.ts | 6 +- .../metro/client-metro-auto-companion.test.ts | 149 ++++++------ .../client-metro-startup-cleanup.test.ts | 9 +- .../react-native/src/metro/client-metro.ts | 13 +- .../src/metro/metro-session-hints.ts | 25 +- src/agent-device-client.ts | 34 +-- .../react-devtools-connection.test.ts | 8 +- src/sdk/metro.ts | 24 +- 11 files changed, 302 insertions(+), 226 deletions(-) diff --git a/packages/react-native/src/companion/client-companion-tunnel.ts b/packages/react-native/src/companion/client-companion-tunnel.ts index fe5f5a93a6..8486cb010e 100644 --- a/packages/react-native/src/companion/client-companion-tunnel.ts +++ b/packages/react-native/src/companion/client-companion-tunnel.ts @@ -16,7 +16,7 @@ import { ENV_COMPANION_TUNNEL_UNREGISTER_PATH, } from './client-companion-tunnel-contract.ts'; import { normalizeBaseUrl } from '../metro/base-url.ts'; -import { commandHostEnvironment, runCmdDetached } from '@agent-device/host-kit/command'; +import { runCmdDetached } from '@agent-device/host-kit/command'; import { confirmedProcessStopDetails, describeUnconfirmedProcessStop, @@ -65,7 +65,9 @@ export type EnsureCompanionTunnelOptions = { profileKey?: string; consumerKey?: string; stateDir?: string; - env?: NodeJS.ProcessEnv; + /** The environment and working directory the detached companion process starts with. */ + env: NodeJS.ProcessEnv; + cwd: string; }; export type EnsureCompanionTunnelResult = { @@ -400,10 +402,9 @@ function spawnCompanionProcess( const logFd = fs.openSync(logPath, 'a'); let pid = 0; try { - const host = commandHostEnvironment(); pid = runCmdDetached(process.execPath, [...execArgs, modulePath, options.definition.runArg], { - cwd: host.cwd, - env: buildCompanionEnv(options, options.env ?? host.env), + cwd: options.cwd, + env: buildCompanionEnv(options, options.env), stdio: ['ignore', logFd, logFd], }); } finally { diff --git a/packages/react-native/src/companion/client-metro-companion.test.ts b/packages/react-native/src/companion/client-metro-companion.test.ts index 34e7c96dc4..e7cd226c88 100644 --- a/packages/react-native/src/companion/client-metro-companion.test.ts +++ b/packages/react-native/src/companion/client-metro-companion.test.ts @@ -2,11 +2,9 @@ import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; import type { AppError } from '@agent-device/kernel/errors'; -bindTestCommandHost(); - vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ ...(await importOriginal()), runCmdDetached: vi.fn(), @@ -98,6 +96,8 @@ test('companion ownership is profile-scoped and consumer-counted', async () => { vi.mocked(stopProcessAndConfirmExit).mockResolvedValue(STOPPED); const stagingFirst = await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -108,6 +108,8 @@ test('companion ownership is profile-scoped and consumer-counted', async () => { consumerKey: 'session-a', }); const stagingSecond = await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -118,6 +120,8 @@ test('companion ownership is profile-scoped and consumer-counted', async () => { consumerKey: 'session-b', }); const prod = await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -196,6 +200,8 @@ test('launchUrl changes force a companion respawn for the same profile', async ( vi.mocked(stopProcessAndConfirmExit).mockResolvedValue(STOPPED); const first = await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -206,6 +212,8 @@ test('launchUrl changes force a companion respawn for the same profile', async ( consumerKey: 'session-a', }); const second = await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -241,6 +249,7 @@ test('spawned companion uses neutral env names', async () => { ); await ensureMetroCompanion({ + cwd: process.cwd(), projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -282,6 +291,8 @@ test('state sentinel exists before spawning companion worker', async () => { ); const spawned = await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -438,6 +449,8 @@ test('a companion whose stop was refused keeps its state and is not replaced by vi.mocked(stopProcessAndConfirmExit).mockResolvedValue(RETAINED_ON_TERM); const failure = (await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -481,6 +494,8 @@ test('a half-spawned companion whose stop cannot be confirmed keeps its record', }) as typeof fs.writeFileSync); const failure = (await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -498,6 +513,8 @@ test('a half-spawned companion whose stop cannot be confirmed keeps its record', // second companion beside the one that refused to die. vi.mocked(stopProcessAndConfirmExit).mockClear(); const retry = (await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -541,6 +558,8 @@ test('legacy state without bridge scope is stopped before respawn', async () => vi.mocked(stopProcessAndConfirmExit).mockResolvedValue(STOPPED); const spawned = await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', diff --git a/packages/react-native/src/index-metro.test.ts b/packages/react-native/src/index-metro.test.ts index 7d41674f74..707ad64a52 100644 --- a/packages/react-native/src/index-metro.test.ts +++ b/packages/react-native/src/index-metro.test.ts @@ -8,16 +8,14 @@ import path from 'node:path'; import { prepareMetroRuntime, reloadMetro } from './index.ts'; import { AppError } from '@agent-device/kernel/errors'; import { - bindTestCommandHost, closeLoopbackServer, mkdtempForTestSync, writeFakeMetroNpx, findFreeMetroPort, stopTestMetroProcess, + createTestHostEnvironment, } from '@agent-device/test-kit'; -bindTestCommandHost(); - const TEST_TOKEN = 'agent-device-proxy-test-token'; for (const { configFileName, commandName } of [ @@ -51,18 +49,21 @@ for (const { configFileName, commandName } of [ let pid = 0; try { - const result = await prepareMetroRuntime({ - projectRoot, - publicBaseUrl: `http://127.0.0.1:${metroPort}`, - metroPort, - reuseExisting: false, - installDependenciesIfNeeded: false, - env: { - ...process.env, - AGENT_DEVICE_TEST_NPX_ARGS_FILE: argsFile, - PATH: `${binDir}:${process.env.PATH || ''}`, + const result = await prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: `http://127.0.0.1:${metroPort}`, + metroPort, + reuseExisting: false, + installDependenciesIfNeeded: false, + env: { + ...process.env, + AGENT_DEVICE_TEST_NPX_ARGS_FILE: argsFile, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, }, - }); + createTestHostEnvironment(), + ); pid = result.pid; assert.equal(result.kind, 'repack'); @@ -106,18 +107,21 @@ test('prepareMetroRuntime maps kind=expo to the virtual-metro-entry bundle URL', let pid = 0; try { - const result = await prepareMetroRuntime({ - projectRoot, - kind: 'expo', - publicBaseUrl: `http://127.0.0.1:${metroPort}`, - metroPort, - reuseExisting: false, - installDependenciesIfNeeded: false, - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ''}`, + const result = await prepareMetroRuntime( + { + projectRoot, + kind: 'expo', + publicBaseUrl: `http://127.0.0.1:${metroPort}`, + metroPort, + reuseExisting: false, + installDependenciesIfNeeded: false, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, }, - }); + createTestHostEnvironment(), + ); pid = result.pid; assert.equal(result.kind, 'expo'); @@ -156,18 +160,21 @@ test('prepareMetroRuntime keeps index.bundle for non-expo kinds', async () => { let pid = 0; try { - const result = await prepareMetroRuntime({ - projectRoot, - kind: 'react-native', - publicBaseUrl: `http://127.0.0.1:${metroPort}`, - metroPort, - reuseExisting: false, - installDependenciesIfNeeded: false, - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ''}`, + const result = await prepareMetroRuntime( + { + projectRoot, + kind: 'react-native', + publicBaseUrl: `http://127.0.0.1:${metroPort}`, + metroPort, + reuseExisting: false, + installDependenciesIfNeeded: false, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, }, - }); + createTestHostEnvironment(), + ); pid = result.pid; assert.equal( @@ -207,17 +214,20 @@ test('prepareMetroRuntime detects the package manager from an ancestor lockfile let pid = 0; try { - const result = await prepareMetroRuntime({ - projectRoot, - publicBaseUrl: `http://127.0.0.1:${metroPort}`, - metroPort, - reuseExisting: false, - installDependenciesIfNeeded: true, - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ''}`, + const result = await prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: `http://127.0.0.1:${metroPort}`, + metroPort, + reuseExisting: false, + installDependenciesIfNeeded: true, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, }, - }); + createTestHostEnvironment(), + ); pid = result.pid; assert.equal(result.packageManager, 'yarn'); @@ -249,15 +259,18 @@ test('prepareMetroRuntime install failure hints at --no-install-deps and the det await assert.rejects( () => - prepareMetroRuntime({ - projectRoot, - publicBaseUrl: 'http://127.0.0.1:9', - installDependenciesIfNeeded: true, - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ''}`, + prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: 'http://127.0.0.1:9', + installDependenciesIfNeeded: true, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, }, - }), + createTestHostEnvironment(), + ), (error) => { assert.ok(error instanceof AppError); const hint = error.details?.hint; @@ -291,15 +304,18 @@ test('prepareMetroRuntime detects bun from the text bun.lock lockfile', async () await assert.rejects( () => - prepareMetroRuntime({ - projectRoot, - publicBaseUrl: 'http://127.0.0.1:9', - installDependenciesIfNeeded: true, - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ''}`, + prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: 'http://127.0.0.1:9', + installDependenciesIfNeeded: true, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, }, - }), + createTestHostEnvironment(), + ), (error) => { assert.ok(error instanceof AppError); assert.equal(error.details?.packageManager, 'bun'); @@ -334,15 +350,18 @@ test('prepareMetroRuntime lockfile walk-up stops at the repo root instead of ado await assert.rejects( () => - prepareMetroRuntime({ - projectRoot, - publicBaseUrl: 'http://127.0.0.1:9', - installDependenciesIfNeeded: true, - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ''}`, + prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: 'http://127.0.0.1:9', + installDependenciesIfNeeded: true, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, }, - }), + createTestHostEnvironment(), + ), (error) => { assert.ok(error instanceof AppError); assert.equal(error.details?.packageManager, 'npm'); @@ -356,11 +375,14 @@ test('prepareMetroRuntime lockfile walk-up stops at the repo root instead of ado test('prepareMetroRuntime rejects incomplete proxy configuration', async () => { await assert.rejects( () => - prepareMetroRuntime({ - publicBaseUrl: 'https://sandbox.example.test', - proxyBaseUrl: 'https://proxy.example.test', - env: {}, - }), + prepareMetroRuntime( + { + publicBaseUrl: 'https://sandbox.example.test', + proxyBaseUrl: 'https://proxy.example.test', + env: {}, + }, + createTestHostEnvironment(), + ), (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && @@ -369,10 +391,13 @@ test('prepareMetroRuntime rejects incomplete proxy configuration', async () => { await assert.rejects( () => - prepareMetroRuntime({ - publicBaseUrl: 'https://sandbox.example.test', - env: { AGENT_DEVICE_METRO_BEARER_TOKEN: TEST_TOKEN }, - }), + prepareMetroRuntime( + { + publicBaseUrl: 'https://sandbox.example.test', + env: { AGENT_DEVICE_METRO_BEARER_TOKEN: TEST_TOKEN }, + }, + createTestHostEnvironment(), + ), (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && @@ -381,12 +406,15 @@ test('prepareMetroRuntime rejects incomplete proxy configuration', async () => { await assert.rejects( () => - prepareMetroRuntime({ - publicBaseUrl: 'https://sandbox.example.test', - proxyBaseUrl: 'https://proxy.example.test', - proxyBearerToken: TEST_TOKEN, - env: {}, - }), + prepareMetroRuntime( + { + publicBaseUrl: 'https://sandbox.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: TEST_TOKEN, + env: {}, + }, + createTestHostEnvironment(), + ), (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && @@ -397,11 +425,14 @@ test('prepareMetroRuntime rejects incomplete proxy configuration', async () => { test('prepareMetroRuntime falls back to daemon auth token for proxy auth', async () => { await assert.rejects( () => - prepareMetroRuntime({ - publicBaseUrl: 'https://sandbox.example.test', - proxyBaseUrl: 'https://proxy.example.test', - env: { AGENT_DEVICE_DAEMON_AUTH_TOKEN: TEST_TOKEN }, - }), + prepareMetroRuntime( + { + publicBaseUrl: 'https://sandbox.example.test', + proxyBaseUrl: 'https://proxy.example.test', + env: { AGENT_DEVICE_DAEMON_AUTH_TOKEN: TEST_TOKEN }, + }, + createTestHostEnvironment(), + ), (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && @@ -412,11 +443,14 @@ test('prepareMetroRuntime falls back to daemon auth token for proxy auth', async test('prepareMetroRuntime honors metro bearer token env for proxy auth', async () => { await assert.rejects( () => - prepareMetroRuntime({ - publicBaseUrl: 'https://sandbox.example.test', - proxyBaseUrl: 'https://proxy.example.test', - env: { AGENT_DEVICE_METRO_BEARER_TOKEN: TEST_TOKEN }, - }), + prepareMetroRuntime( + { + publicBaseUrl: 'https://sandbox.example.test', + proxyBaseUrl: 'https://proxy.example.test', + env: { AGENT_DEVICE_METRO_BEARER_TOKEN: TEST_TOKEN }, + }, + createTestHostEnvironment(), + ), (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index c3608a4ed8..747ebba33b 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -1,5 +1,6 @@ import type { MetroSessionOptions, MetroSessionOpenInput } from './metro/metro-session-hints.ts'; import type { SessionRuntimeHints } from '@agent-device/kernel/contracts'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { resolveRuntimeTransportHints } from '@agent-device/host-kit/runtime-transport-hints'; import type { PrepareMetroRuntimeOptions, ReloadMetroOptions } from './metro/client-metro.ts'; import type { PrepareMetroRuntimeResult, ReloadMetroResult } from '@agent-device/contracts/remote'; @@ -34,10 +35,11 @@ export function resolveRuntimeTransport( } export async function prepareMetroRuntime( - input: PrepareMetroRuntimeOptions = {}, + input: PrepareMetroRuntimeOptions, + host: HostEnvironment, ): Promise { const runtime = await import('./metro/client-metro.ts'); - return await runtime.prepareMetroRuntime(input); + return await runtime.prepareMetroRuntime(input, host); } export async function reloadMetro(input: ReloadMetroOptions = {}): Promise { diff --git a/packages/react-native/src/metro/client-metro-auto-companion.test.ts b/packages/react-native/src/metro/client-metro-auto-companion.test.ts index dbf84bfaa5..33bbb9aa84 100644 --- a/packages/react-native/src/metro/client-metro-auto-companion.test.ts +++ b/packages/react-native/src/metro/client-metro-auto-companion.test.ts @@ -2,11 +2,9 @@ import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { AppError } from '@agent-device/kernel/errors'; -bindTestCommandHost(); - vi.mock('../companion/client-metro-companion.ts', () => ({ ensureMetroCompanion: vi.fn(), })); @@ -100,15 +98,18 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee vi.stubGlobal('fetch', fetchMock); try { - const result = await prepareMetroRuntime({ - projectRoot, - proxyBaseUrl: 'https://proxy.example.test', - proxyBearerToken: 'shared-token', - bridgeScope: TEST_BRIDGE_SCOPE, - metroPort: 8081, - reuseExisting: true, - installDependenciesIfNeeded: false, - }); + const result = await prepareMetroRuntime( + { + projectRoot, + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + }, + createTestHostEnvironment(), + ); assert.equal(result.started, false); assert.equal(result.reused, true); @@ -133,7 +134,8 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee launchUrl: undefined, profileKey: undefined, consumerKey: undefined, - env: process.env, + env: { ...process.env }, + cwd: process.cwd(), }); assert.equal(fetchMock.mock.calls.length, 3); assert.equal(fetchMock.mock.calls[1]?.[0], 'https://proxy.example.test/api/metro/bridge'); @@ -210,15 +212,18 @@ test('prepareMetroRuntime rejects bridged descriptors without iOS bundle URLs', try { await assert.rejects( () => - prepareMetroRuntime({ - projectRoot, - proxyBaseUrl: 'https://proxy.example.test', - proxyBearerToken: 'shared-token', - bridgeScope: TEST_BRIDGE_SCOPE, - metroPort: 8081, - reuseExisting: true, - installDependenciesIfNeeded: false, - }), + prepareMetroRuntime( + { + projectRoot, + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + }, + createTestHostEnvironment(), + ), /bridge descriptor is missing ios_runtime\.metro_bundle_url/, ); assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0); @@ -270,17 +275,20 @@ test('prepareMetroRuntime preserves the initial bridge error and companion stop try { await assert.rejects( () => - prepareMetroRuntime({ - projectRoot, - publicBaseUrl: 'https://public.example.test', - proxyBaseUrl: 'https://proxy.example.test', - proxyBearerToken: 'shared-token', - bridgeScope: TEST_BRIDGE_SCOPE, - metroPort: 8081, - reuseExisting: true, - installDependenciesIfNeeded: false, - probeTimeoutMs: 10, - }), + prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: 'https://public.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + probeTimeoutMs: 10, + }, + createTestHostEnvironment(), + ), (error: unknown) => { assert(error instanceof Error); assert.match(error.message, /bridgeError=companion startup failed/); @@ -333,17 +341,20 @@ test('prepareMetroRuntime fails fast when initial bridge failure is non-retryabl try { await assert.rejects( () => - prepareMetroRuntime({ - projectRoot, - publicBaseUrl: 'https://public.example.test', - proxyBaseUrl: 'https://proxy.example.test', - proxyBearerToken: 'shared-token', - bridgeScope: TEST_BRIDGE_SCOPE, - metroPort: 8081, - reuseExisting: true, - installDependenciesIfNeeded: false, - probeTimeoutMs: 10, - }), + prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: 'https://public.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + probeTimeoutMs: 10, + }, + createTestHostEnvironment(), + ), /\/api\/metro\/bridge failed \(401\)/, ); assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0); @@ -396,17 +407,20 @@ test('prepareMetroRuntime fails fast on non-retryable bridge errors after compan try { let settled: unknown = 'pending'; - const preparePromise = prepareMetroRuntime({ - projectRoot, - publicBaseUrl: 'https://public.example.test', - proxyBaseUrl: 'https://proxy.example.test', - proxyBearerToken: 'shared-token', - bridgeScope: TEST_BRIDGE_SCOPE, - metroPort: 8081, - reuseExisting: true, - installDependenciesIfNeeded: false, - probeTimeoutMs: 10, - }); + const preparePromise = prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: 'https://public.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + probeTimeoutMs: 10, + }, + createTestHostEnvironment(), + ); void preparePromise.then( () => { settled = 'resolved'; @@ -506,17 +520,20 @@ test('prepareMetroRuntime retries malformed retryable bridge responses after com vi.useFakeTimers(); try { - const preparePromise = prepareMetroRuntime({ - projectRoot, - publicBaseUrl: 'https://public.example.test', - proxyBaseUrl: 'https://proxy.example.test', - proxyBearerToken: 'shared-token', - bridgeScope: TEST_BRIDGE_SCOPE, - metroPort: 8081, - reuseExisting: true, - installDependenciesIfNeeded: false, - probeTimeoutMs: 10, - }); + const preparePromise = prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: 'https://public.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + probeTimeoutMs: 10, + }, + createTestHostEnvironment(), + ); await vi.advanceTimersByTimeAsync(1_000); const result = await preparePromise; diff --git a/packages/react-native/src/metro/client-metro-startup-cleanup.test.ts b/packages/react-native/src/metro/client-metro-startup-cleanup.test.ts index 9e007d619e..990ffa42e4 100644 --- a/packages/react-native/src/metro/client-metro-startup-cleanup.test.ts +++ b/packages/react-native/src/metro/client-metro-startup-cleanup.test.ts @@ -2,11 +2,9 @@ import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import type { AppError } from '@agent-device/kernel/errors'; -bindTestCommandHost(); - vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ ...(await importOriginal()), runCmdDetached: vi.fn(), @@ -82,7 +80,10 @@ async function runMetroPrepareThroughTimeout(projectRoot: string): Promise { - const settings = resolveMetroPrepareSettings(input); + const settings = resolveMetroPrepareSettings(input, host); const dependencyInstall = settings.installProjectDeps ? installDependenciesIfNeeded(settings.projectRoot, settings.env) : { installed: false as const }; diff --git a/packages/react-native/src/metro/metro-session-hints.ts b/packages/react-native/src/metro/metro-session-hints.ts index 57cccbc07e..58e4cb1095 100644 --- a/packages/react-native/src/metro/metro-session-hints.ts +++ b/packages/react-native/src/metro/metro-session-hints.ts @@ -3,7 +3,7 @@ import type { SessionRuntimeHints } from '@agent-device/kernel/contracts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import fs from 'node:fs'; import path from 'node:path'; -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { safeSessionName } from '@agent-device/host-kit/session-paths'; import { resolveMetroServerUrl, type MetroReloadTargetInput } from './metro-reload-endpoints.ts'; @@ -15,10 +15,15 @@ export type MetroSessionHints = { export type MetroSessionScope = { stateDir: string; session: string }; -export type MetroSessionOptions = { stateDir?: string; session: string }; +export type MetroSessionOptions = { + stateDir?: string; + session: string; + /** The host whose state directory and working directory the session binding is read from. */ + host: HostEnvironment; +}; function resolveMetroSessionScope(options: MetroSessionOptions): MetroSessionScope { - const host = commandHostEnvironment(); + const { host } = options; return { stateDir: resolveDaemonPaths(options.stateDir ?? host.env.AGENT_DEVICE_STATE_DIR, { env: host.env, @@ -102,16 +107,24 @@ export function resolveSessionMetroServerUrl( (input.metroHost !== undefined && input.metroPort !== undefined); return resolveMetroServerUrl({ ...input, - runtime: (complete ? undefined : readMetroSessionRuntime(scope)) ?? input.runtime, + runtime: (complete ? undefined : readScopedMetroSessionRuntime(scope)) ?? input.runtime, }); } +function readScopedMetroSessionRuntime(scope: MetroSessionScope): SessionRuntimeHints | undefined { + try { + const binding = readMetroSessionHints(scope); + return binding ? { bundleUrl: binding.controlBaseUrl } : undefined; + } catch { + return undefined; + } +} + export function readMetroSessionRuntime( options: MetroSessionOptions, ): SessionRuntimeHints | undefined { try { - const binding = readMetroSessionHints(resolveMetroSessionScope(options)); - return binding ? { bundleUrl: binding.controlBaseUrl } : undefined; + return readScopedMetroSessionRuntime(resolveMetroSessionScope(options)); } catch { return undefined; } diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 6720b656c7..dfdbaddbcc 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -69,7 +69,6 @@ import { } from '@agent-device/kernel/record'; import { readSnapshotViewportSize } from '@agent-device/kernel/rect'; import { readResponseWarnings } from '@agent-device/kernel/success-text'; -import { withCommandHost } from '@agent-device/host-kit/command'; import { captureHostEnvironment } from '@agent-device/host-kit/process'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { createLeaseClient } from './client/lease-client.ts'; @@ -84,25 +83,26 @@ export function createAgentDeviceClient( const transport = deps.transport ?? createDaemonTransport(host); // A typo in a JSON or JS config must not fall through to the default and manage a daemon. readDaemonLifecycle(config.daemonLifecycle); - // The dev-server helpers read their environment from the command host; this client's host owns it. - const inHost = async (fn: () => Promise | T): Promise => - await withCommandHost({ host, requestEnv: undefined }, async () => await fn()); + // The dev-server helpers take the client's host explicitly. const prepareMetroRuntime = (input: Parameters[0]) => - inHost(() => devServer.prepareMetroRuntime(input)); - const reloadMetro = (input: Parameters[0]) => - inHost(() => devServer.reloadMetro(input)); - const clearMetroSessionHintsQuietly = ( - options: Parameters[0], - ) => inHost(() => devServer.clearMetroSessionHintsQuietly(options)); + devServer.prepareMetroRuntime(input, host); + const { reloadMetro } = devServer; + const withHost = (options: Options) => ({ + ...options, + host, + }); + const clearMetroSessionHintsQuietly = (options: { session: string; stateDir?: string }) => + devServer.clearMetroSessionHintsQuietly(withHost(options)); const recordPreparedMetroSession = ( - ...args: Parameters - ) => inHost(() => devServer.recordPreparedMetroSession(...args)); + options: { session: string; stateDir?: string }, + result: Parameters[1], + ) => devServer.recordPreparedMetroSession(withHost(options), result); const recordOpenedMetroSession = ( - ...args: Parameters - ) => inHost(() => devServer.recordOpenedMetroSession(...args)); - const readMetroSessionRuntime = ( - options: Parameters[0], - ) => inHost(() => devServer.readMetroSessionRuntime(options)); + options: { session: string; stateDir?: string }, + input: Parameters[1], + ) => devServer.recordOpenedMetroSession(withHost(options), input); + const readMetroSessionRuntime = (options: { session: string; stateDir?: string }) => + devServer.readMetroSessionRuntime(withHost(options)); // A non-default responseLevel (digest/full) makes the daemon return a leveled // shape; the per-command client normalizers assume the default shape, so the diff --git a/src/cli/commands/react-devtools-connection.test.ts b/src/cli/commands/react-devtools-connection.test.ts index 5362a54e7b..8a1d982167 100644 --- a/src/cli/commands/react-devtools-connection.test.ts +++ b/src/cli/commands/react-devtools-connection.test.ts @@ -1,15 +1,9 @@ import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import type { CliFlags } from '@agent-device/contracts/command'; -import { - bindTestCommandHost, - mkdtempForTestSync, - writeMetroSessionFixture, -} from '@agent-device/test-kit'; +import { mkdtempForTestSync, writeMetroSessionFixture } from '@agent-device/test-kit'; import { runReactDevtoolsCommand } from './react-devtools.ts'; -bindTestCommandHost(); - afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); diff --git a/src/sdk/metro.ts b/src/sdk/metro.ts index 5905583b38..24806147b0 100644 --- a/src/sdk/metro.ts +++ b/src/sdk/metro.ts @@ -1,30 +1,20 @@ import * as devServer from '@agent-device/react-native'; -import { withCommandHost } from '@agent-device/host-kit/command'; import { captureHostEnvironment } from '@agent-device/host-kit/process'; export { normalizeBaseUrl, buildBundleUrl, resolveRuntimeTransport, + reloadMetro, + stopMetroTunnel, type MetroBridgeDescriptor, type MetroTunnelRequestMessage, type MetroTunnelResponseMessage, } from '@agent-device/react-native'; -// A JS host calls these without a daemon or CLI around it, so each call runs as a command host -// that reads the process's current environment, as these functions always have. -async function inProcessHost(fn: () => Promise): Promise { - return await withCommandHost( - { host: captureHostEnvironment(process), requestEnv: undefined }, - fn, - ); +/** A JS host calls this with no daemon or CLI around it, so it reads the process's current environment. */ +export async function prepareMetroRuntime( + input: Parameters[0] = {}, +): ReturnType { + return await devServer.prepareMetroRuntime(input, captureHostEnvironment(process)); } - -export const prepareMetroRuntime: typeof devServer.prepareMetroRuntime = async (input) => - await inProcessHost(() => devServer.prepareMetroRuntime(input)); - -export const reloadMetro: typeof devServer.reloadMetro = async (input) => - await inProcessHost(() => devServer.reloadMetro(input)); - -export const stopMetroTunnel: typeof devServer.stopMetroTunnel = async (options) => - await inProcessHost(() => devServer.stopMetroTunnel(options)); From 4f02fcf9899c3479376d89e58ecfd496f857ccc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 11:20:01 +0200 Subject: [PATCH 12/27] refactor(platforms): pass the host explicitly where a platform package reads configuration Android install artifacts read the environment from the Android adb host port, Apple install artifacts take it as an option from the root deployment executor, and the Apple runner names its host port member hostEnvironment. Co-Authored-By: Claude Sonnet 5.5 --- .../install-artifact-cancellation.test.ts | 4 +- .../src/install-artifact.test.ts | 3 - .../platform-android/src/install-artifact.ts | 4 +- .../core/__tests__/install-artifact.test.ts | 17 +++--- .../src/core/install-artifact.ts | 11 ++-- .../platform-apple/src/core/runner-host.ts | 2 +- .../__tests__/runner-process-launch.test.ts | 2 +- packages/platform-apple/src/runner/host.ts | 8 ++- .../src/runner/runner-adoption.ts | 11 +--- .../src/runner/runner-artifact.ts | 4 +- .../src/runner/runner-cache-metadata.ts | 20 +++---- .../src/runner/runner-cache-retention.ts | 6 +- .../platform-apple/src/runner/runner-cache.ts | 6 +- .../src/runner/runner-disposal.ts | 4 +- .../platform-apple/src/runner/runner-lease.ts | 6 +- .../src/runner/runner-process-launch.ts | 4 +- .../src/runner/runner-retention.ts | 4 +- .../src/runner/runner-session.ts | 4 +- src/__tests__/install-source.test.ts | 60 ++++++++++++------- ...tform-runtime-apple-deployment-executor.ts | 6 +- 20 files changed, 99 insertions(+), 87 deletions(-) diff --git a/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts b/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts index e301abbb4f..81cbc87570 100644 --- a/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts +++ b/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts @@ -10,9 +10,9 @@ import * as networkTransport from '@agent-device/provision-kit/install-source-ne import { mkdtempForTest } from './test-utils/tmp-dir.ts'; import { prepareAndroidInstallArtifact } from '../install-artifact.ts'; import * as manifest from '../manifest.ts'; -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindAndroidAdbHostStub } from '../adb-host.fixtures.ts'; -bindTestCommandHost(); +bindAndroidAdbHostStub(); test('aborts an in-flight Android artifact identity probe', async () => { const tempRoot = await mkdtempForTest('agent-device-android-artifact-abort-'); diff --git a/packages/platform-android/src/install-artifact.test.ts b/packages/platform-android/src/install-artifact.test.ts index d749a1a6a4..13a1bc283a 100644 --- a/packages/platform-android/src/install-artifact.test.ts +++ b/packages/platform-android/src/install-artifact.test.ts @@ -9,9 +9,6 @@ import { import { installAndroidArtifact } from './deployment/native.ts'; import { prepareAndroidInstallArtifact } from './install-artifact.ts'; import { ANDROID_EMULATOR } from './__tests__/test-utils/device-fixtures.ts'; -import { bindTestCommandHost } from '@agent-device/test-kit'; - -bindTestCommandHost(); test('installing an already-installed APK resolves its package with no aapt reachable', async () => { // The stub host answers `isExecutable` as false, so no `aapt` is reachable and the identity can diff --git a/packages/platform-android/src/install-artifact.ts b/packages/platform-android/src/install-artifact.ts index 2dc0224524..9f2d6198b7 100644 --- a/packages/platform-android/src/install-artifact.ts +++ b/packages/platform-android/src/install-artifact.ts @@ -1,7 +1,7 @@ import path from 'node:path'; -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { LocalInstallSource } from '@agent-device/kernel/contracts'; import { materializeInstallablePath } from '@agent-device/provision-kit/install-source'; +import { requireAndroidAdbHost } from './adb-host.ts'; import * as manifest from './manifest.ts'; export type PreparedAndroidInstallArtifact = { @@ -21,7 +21,7 @@ export async function prepareAndroidInstallArtifact( stat.isFile() && isAndroidInstallablePath(candidatePath), installableLabel: 'Android installable (.apk or .aab)', signal: options?.signal, - environment: commandHostEnvironment().env, + environment: requireAndroidAdbHost().environment, }); try { const identity = diff --git a/packages/platform-apple/src/core/__tests__/install-artifact.test.ts b/packages/platform-apple/src/core/__tests__/install-artifact.test.ts index 4aaad8f862..10711d088e 100644 --- a/packages/platform-apple/src/core/__tests__/install-artifact.test.ts +++ b/packages/platform-apple/src/core/__tests__/install-artifact.test.ts @@ -7,9 +7,6 @@ import { AppError } from '@agent-device/kernel/errors'; import { createLocalAppleToolProvider, withAppleToolProvider } from '../tool-provider.ts'; import { prepareIosInstallArtifact } from '../install-artifact.ts'; import { mkdtempForTest } from '../../__tests__/tmp-dir.ts'; -import { bindTestCommandHost } from '@agent-device/test-kit'; - -bindTestCommandHost(); /** * An IPA whose `Payload` holds more than one `.app` is legal on disk and ambiguous for the @@ -23,7 +20,7 @@ test('prepareIosInstallArtifact resolves a multi-payload IPA by a bundle-name hi await withMultiPayloadIpa([{ name: 'Alpha' }, { name: 'Beta' }], async (ipaPath) => { const prepared = await prepareIosInstallArtifact( { kind: 'path', path: ipaPath }, - { appIdentifierHint: 'beta' }, + { environment: process.env, appIdentifierHint: 'beta' }, ); try { assert.equal(path.basename(prepared.installablePath), 'Beta.app'); @@ -39,7 +36,7 @@ test('prepareIosInstallArtifact resolves a multi-payload IPA by a dotted bundle- await withMultiPayloadIpa([{ name: 'Alpha' }, { name: 'Beta' }], async (ipaPath) => { const prepared = await prepareIosInstallArtifact( { kind: 'path', path: ipaPath }, - { appIdentifierHint: 'COM.Example.Alpha' }, + { environment: process.env, appIdentifierHint: 'COM.Example.Alpha' }, ); try { assert.equal(path.basename(prepared.installablePath), 'Alpha.app'); @@ -60,7 +57,7 @@ test('prepareIosInstallArtifact lists the available bundles when a dotted hint m async () => await prepareIosInstallArtifact( { kind: 'path', path: ipaPath }, - { appIdentifierHint: 'com.example.gamma' }, + { environment: process.env, appIdentifierHint: 'com.example.gamma' }, ), (error: unknown) => error instanceof AppError && @@ -75,7 +72,11 @@ test('prepareIosInstallArtifact lists the available bundles when a dotted hint m test('prepareIosInstallArtifact lists the available bundles when a hint-free multi-payload IPA fails', async () => { await withMultiPayloadIpa([{ name: 'Alpha' }, { name: 'Beta' }], async (ipaPath) => { await assert.rejects( - async () => await prepareIosInstallArtifact({ kind: 'path', path: ipaPath }), + async () => + await prepareIosInstallArtifact( + { kind: 'path', path: ipaPath }, + { environment: process.env }, + ), /Pass an app identifier or bundle name matching one of: Alpha\.app \(com\.example\.alpha\), Beta\.app \(com\.example\.beta\)/, ); }); @@ -92,7 +93,7 @@ test('prepareIosInstallArtifact keeps a non-dotted hint a name-only match', asyn async () => await prepareIosInstallArtifact( { kind: 'path', path: ipaPath }, - { appIdentifierHint: 'beta-app' }, + { environment: process.env, appIdentifierHint: 'beta-app' }, ), /none matched "beta-app"/, ); diff --git a/packages/platform-apple/src/core/install-artifact.ts b/packages/platform-apple/src/core/install-artifact.ts index 5708549af7..1936b857cd 100644 --- a/packages/platform-apple/src/core/install-artifact.ts +++ b/packages/platform-apple/src/core/install-artifact.ts @@ -14,7 +14,6 @@ import { noteInstallArtifactArchiveDepth, withInstallArtifactArchiveScope, } from '@agent-device/provision-kit/install-artifact-archive-context'; -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { materializeInstallablePath } from '@agent-device/provision-kit/install-source'; import { makeHostTemporaryDirectory, @@ -25,6 +24,8 @@ import { type InstallIosArtifactOptions = { appIdentifierHint?: string; signal?: AbortSignal; + /** The host environment whose proxy settings apply to a network download. */ + environment: NodeJS.ProcessEnv; }; type IosPayloadAppBundle = { @@ -45,7 +46,7 @@ export type PreparedIosInstallArtifact = { export async function prepareIosInstallArtifact( source: LocalInstallSource, - options?: InstallIosArtifactOptions, + options: InstallIosArtifactOptions, ): Promise { return await withInstallArtifactArchiveScope( async () => await prepareIosInstallArtifactInScope(source, options), @@ -54,7 +55,7 @@ export async function prepareIosInstallArtifact( async function prepareIosInstallArtifactInScope( source: LocalInstallSource, - options?: InstallIosArtifactOptions, + options: InstallIosArtifactOptions, ): Promise { const materialized = await materializeInstallablePath({ source, @@ -62,8 +63,8 @@ async function prepareIosInstallArtifactInScope( (stat.isDirectory() && candidatePath.toLowerCase().endsWith('.app')) || (stat.isFile() && candidatePath.toLowerCase().endsWith('.ipa')), installableLabel: 'iOS installable (.app or .ipa)', - signal: options?.signal, - environment: commandHostEnvironment().env, + signal: options.signal, + environment: options.environment, }); let resolved: Awaited> | undefined; diff --git a/packages/platform-apple/src/core/runner-host.ts b/packages/platform-apple/src/core/runner-host.ts index 39895aa879..c6a5534137 100644 --- a/packages/platform-apple/src/core/runner-host.ts +++ b/packages/platform-apple/src/core/runner-host.ts @@ -72,7 +72,7 @@ export const appleRunnerHost: AppleRunnerHost = { requireExecSuccess, isCommandTimeoutError, commandDeveloperDir, - commandHostEnvironment, + hostEnvironment: commandHostEnvironment, shellQuote, emitDiagnostic, withDiagnosticTimer, diff --git a/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts b/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts index 44284f0eb3..1780f825cc 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts @@ -52,7 +52,7 @@ test("the runner is spawned detached onto its own log file, not onto this proces test('the detached runner gets its environment and working directory as plain values', () => { mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); const host = { env: { FROM_HOST: 'yes' }, cwd: '/tmp/host-cwd' }; - appleRunnerTestHost.update({ commandHostEnvironment: () => host }); + appleRunnerTestHost.update({ hostEnvironment: () => host }); launchRunnerProcess({ device: IOS_SIMULATOR, diff --git a/packages/platform-apple/src/runner/host.ts b/packages/platform-apple/src/runner/host.ts index c126ffbc64..ce6af7a57f 100644 --- a/packages/platform-apple/src/runner/host.ts +++ b/packages/platform-apple/src/runner/host.ts @@ -58,7 +58,6 @@ export type AppleRunnerHost = Pick< | 'requireExecSuccess' | 'isCommandTimeoutError' | 'commandDeveloperDir' - | 'commandHostEnvironment' > & Pick & Pick & @@ -88,6 +87,11 @@ export type AppleRunnerHost = Pick< > & Pick & Pick & { + /** + * The environment and working directory the runner reads its configuration from (derived-data + * overrides, lease directory, bundle ids, signing settings) and starts detached children with. + */ + hostEnvironment: typeof HostCommand.commandHostEnvironment; /** * The `Deadline` constructor is a class static, so the port carries the factory alone, typed * to the read side: a test host substitutes its own clock. @@ -159,7 +163,7 @@ export const runCmdBackground = delegate('runCmdBackground'); export const requireExecSuccess = delegate('requireExecSuccess'); export const isCommandTimeoutError = delegate('isCommandTimeoutError'); export const commandDeveloperDir = delegate('commandDeveloperDir'); -export const commandHostEnvironment = delegate('commandHostEnvironment'); +export const hostEnvironment = delegate('hostEnvironment'); export const shellQuote = delegate('shellQuote'); export const emitDiagnostic = delegate('emitDiagnostic'); export const withDiagnosticTimer = delegate('withDiagnosticTimer'); diff --git a/packages/platform-apple/src/runner/runner-adoption.ts b/packages/platform-apple/src/runner/runner-adoption.ts index 56fdb38038..8d7288957e 100644 --- a/packages/platform-apple/src/runner/runner-adoption.ts +++ b/packages/platform-apple/src/runner/runner-adoption.ts @@ -1,10 +1,5 @@ import path from 'node:path'; -import { - commandHostEnvironment, - emitDiagnostic, - isProcessAlive, - parseBooleanLiteral, -} from './host.ts'; +import { hostEnvironment, emitDiagnostic, isProcessAlive, parseBooleanLiteral } from './host.ts'; import type { ExecResult } from '@agent-device/host-kit/command'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { isRequestCanceledError } from '@agent-device/kernel/errors'; @@ -66,9 +61,7 @@ const RUNNER_ADOPTION_EXIT_POLL_INTERVAL_MS = 1_000; // Kill switch for the runner handoff across daemon restarts: disables both // detaching healthy runners on graceful shutdown and adopting them on the next // startup, in every handoff lane (#2681). -export function isIosRunnerDetachEnabled( - env: NodeJS.ProcessEnv = commandHostEnvironment().env, -): boolean { +export function isIosRunnerDetachEnabled(env: NodeJS.ProcessEnv = hostEnvironment().env): boolean { return parseBooleanLiteral(env.AGENT_DEVICE_IOS_RUNNER_DETACH ?? '') !== false; } diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index 1d78175556..b3301914b5 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -15,7 +15,7 @@ import { emitRequestProgress, findProjectRoot, isCommandTimeoutError, - commandHostEnvironment, + hostEnvironment, } from './host.ts'; import type { ExecBackgroundResult } from '@agent-device/host-kit/command'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -873,7 +873,7 @@ async function buildRunnerXctestrun( // teardown or a last-waiter cancellation reaches this same line again, and admission is what // refuses it. Checking at registration would be a check after the child exists (#3220). assertRunnerStartAdmitsPreparation(device.id, options.startAdmission); - const { env } = commandHostEnvironment(); + const { env } = hostEnvironment(); const runnerBundleBuildSettings = resolveRunnerBundleBuildSettings(env); const signingBuildSettings = resolveRunnerSigningBuildSettings( env, diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts index 310e3eed49..4ffc608910 100644 --- a/packages/platform-apple/src/runner/runner-cache-metadata.ts +++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { commandHostEnvironment, isEnvTruthy, findProjectRoot, readVersion } from './host.ts'; +import { hostEnvironment, isEnvTruthy, findProjectRoot, readVersion } from './host.ts'; import { resolveRunnerBuildDestinationFamily, resolveRunnerDerivedBaseName, @@ -88,16 +88,14 @@ function normalizeBundleId(value: string | undefined): string { return value?.trim() ?? ''; } -export function resolveRunnerAppBundleId( - env: NodeJS.ProcessEnv = commandHostEnvironment().env, -): string { +export function resolveRunnerAppBundleId(env: NodeJS.ProcessEnv = hostEnvironment().env): string { const configured = normalizeBundleId(env.AGENT_DEVICE_IOS_BUNDLE_ID) || normalizeBundleId(env.AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID); return configured || DEFAULT_IOS_RUNNER_APP_BUNDLE_ID; } -function resolveRunnerTestBundleId(env: NodeJS.ProcessEnv = commandHostEnvironment().env): string { +function resolveRunnerTestBundleId(env: NodeJS.ProcessEnv = hostEnvironment().env): string { const configured = normalizeBundleId(env.AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID); if (configured) { return configured; @@ -125,7 +123,7 @@ export function resolveExpectedRunnerCacheMetadata( budget?: RunnerPhaseBudget, ): RunnerXctestrunCacheMetadata { const platformName = resolveRunnerPlatformName(device); - const { env } = commandHostEnvironment(); + const { env } = hostEnvironment(); return { schemaVersion: RUNNER_CACHE_SCHEMA_VERSION, packageVersion: readVersion(projectRoot), @@ -151,7 +149,7 @@ export function resolveRunnerDerivedPath( device: DeviceInfo, metadata: RunnerXctestrunCacheMetadata, ): string { - const override = commandHostEnvironment().env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim(); + const override = hostEnvironment().env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim(); if (override) { return path.resolve(override); } @@ -255,7 +253,7 @@ export function resolveRunnerMaxConcurrentDestinationsFlag(device: DeviceInfo): } export function resolveRunnerSigningBuildSettings( - env: NodeJS.ProcessEnv = commandHostEnvironment().env, + env: NodeJS.ProcessEnv = hostEnvironment().env, forDevice = false, device: Pick = { platform: 'apple' }, ): string[] { @@ -285,7 +283,7 @@ export function resolveRunnerSigningBuildSettings( } export function resolveRunnerBundleBuildSettings( - env: NodeJS.ProcessEnv = commandHostEnvironment().env, + env: NodeJS.ProcessEnv = hostEnvironment().env, ): string[] { const appBundleId = resolveRunnerAppBundleId(env); const testBundleId = resolveRunnerTestBundleId(env); @@ -312,7 +310,7 @@ export function resolveRunnerPerformanceBuildSettings(): string[] { * cache identity from this one resolver. */ export function resolveRunnerArchBuildSettings( - env: NodeJS.ProcessEnv = commandHostEnvironment().env, + env: NodeJS.ProcessEnv = hostEnvironment().env, ): string[] { const archs = env.AGENT_DEVICE_XCUITEST_ARCHS?.trim(); return archs ? [`ARCHS=${archs}`] : []; @@ -333,7 +331,7 @@ export function resolveRunnerBuildLocationSettings(derived: string): string[] { } export function resolveRunnerSandboxBuildArgs( - env: NodeJS.ProcessEnv = commandHostEnvironment().env, + env: NodeJS.ProcessEnv = hostEnvironment().env, ): string[] { return [...RUNNER_SANDBOX_BUILD_ARGS, `OTHER_SWIFT_FLAGS=${resolveRunnerSwiftFlags(env)}`]; } diff --git a/packages/platform-apple/src/runner/runner-cache-retention.ts b/packages/platform-apple/src/runner/runner-cache-retention.ts index 7e06c3c1d8..3d543b73bf 100644 --- a/packages/platform-apple/src/runner/runner-cache-retention.ts +++ b/packages/platform-apple/src/runner/runner-cache-retention.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { commandHostEnvironment, emitDiagnostic } from './host.ts'; +import { hostEnvironment, emitDiagnostic } from './host.ts'; import { acquireRunnerXctestrunCacheLock, emitRunnerXctestrunDecision, @@ -17,7 +17,7 @@ const CACHE_KEY_DIRECTORY = /^cache-[0-9a-f]{16}$/; /** How many runner cache keys per platform folder survive, the current one included; 0 keeps them all. */ export function resolveRunnerCacheKeepCount( - env: NodeJS.ProcessEnv = commandHostEnvironment().env, + env: NodeJS.ProcessEnv = hostEnvironment().env, ): number { const raw = env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP?.trim(); if (!raw || !/^\d+$/.test(raw)) return DEFAULT_RUNNER_CACHE_KEEP; @@ -35,7 +35,7 @@ export function resolveRunnerCacheKeepCount( */ export async function evictStaleRunnerCaches( currentDerived: string, - env: NodeJS.ProcessEnv = commandHostEnvironment().env, + env: NodeJS.ProcessEnv = hostEnvironment().env, nowMs: number = Date.now(), ): Promise { const keep = resolveRunnerCacheKeepCount(env); diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts index 8519707150..9a427ab1b3 100644 --- a/packages/platform-apple/src/runner/runner-cache.ts +++ b/packages/platform-apple/src/runner/runner-cache.ts @@ -8,7 +8,7 @@ import { withProcessLock, isEnvTruthy, findProjectRoot, - commandHostEnvironment, + hostEnvironment, } from './host.ts'; import { RUNNER_CACHE_METADATA_FILE, @@ -285,12 +285,12 @@ function evaluateRunnerCacheMetadata( } function shouldCleanDerived(): boolean { - return isEnvTruthy(commandHostEnvironment().env.AGENT_DEVICE_IOS_CLEAN_DERIVED); + return isEnvTruthy(hostEnvironment().env.AGENT_DEVICE_IOS_CLEAN_DERIVED); } export function assertSafeDerivedCleanup( derivedPath: string, - env: NodeJS.ProcessEnv = commandHostEnvironment().env, + env: NodeJS.ProcessEnv = hostEnvironment().env, ): void { const override = env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim(); if (!override) { diff --git a/packages/platform-apple/src/runner/runner-disposal.ts b/packages/platform-apple/src/runner/runner-disposal.ts index 3152b03d81..0e426883bb 100644 --- a/packages/platform-apple/src/runner/runner-disposal.ts +++ b/packages/platform-apple/src/runner/runner-disposal.ts @@ -1,5 +1,5 @@ import { - commandHostEnvironment, + hostEnvironment, emitDiagnostic, isProcessAlive, isProcessGroupAlive, @@ -290,7 +290,7 @@ async function terminateRunnerSimulatorApps(device: DeviceInfo): Promise { if (device.kind !== 'simulator') return; await Promise.allSettled( - resolveRunnerContainerBundleIds(commandHostEnvironment().env).map(async (bundleId) => { + resolveRunnerContainerBundleIds(hostEnvironment().env).map(async (bundleId) => { try { await runXcrun(buildSimctlArgsForDevice(device, ['terminate', device.id, bundleId]), { allowFailure: true, diff --git a/packages/platform-apple/src/runner/runner-lease.ts b/packages/platform-apple/src/runner/runner-lease.ts index 04ef2c12fd..f04f6d959f 100644 --- a/packages/platform-apple/src/runner/runner-lease.ts +++ b/packages/platform-apple/src/runner/runner-lease.ts @@ -14,7 +14,7 @@ import { classifyOwnerLiveness, leaseOwnerStateDir, shellQuote, - commandHostEnvironment, + hostEnvironment, } from './host.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -302,7 +302,7 @@ function logicalLeaseContextMatchesDevice( function readCurrentStateDir(): string | undefined { const configured = leaseOwnerStateDir(); if (configured) return configured; - return commandHostEnvironment().env.AGENT_DEVICE_STATE_DIR?.trim() || undefined; + return hostEnvironment().env.AGENT_DEVICE_STATE_DIR?.trim() || undefined; } function buildBusyRunnerLeaseHint( @@ -471,7 +471,7 @@ function resolveRunnerLeasePath(deviceId: string): string { } function resolveRunnerLeaseRoot(): string { - const override = commandHostEnvironment().env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR?.trim(); + const override = hostEnvironment().env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR?.trim(); if (override) return path.resolve(override); return path.join(os.homedir(), '.agent-device', 'apple-runner', 'leases'); } diff --git a/packages/platform-apple/src/runner/runner-process-launch.ts b/packages/platform-apple/src/runner/runner-process-launch.ts index dd90fb498f..1091769b4c 100644 --- a/packages/platform-apple/src/runner/runner-process-launch.ts +++ b/packages/platform-apple/src/runner/runner-process-launch.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { commandHostEnvironment, runCmdBackground } from './host.ts'; +import { hostEnvironment, runCmdBackground } from './host.ts'; import type { ExecBackgroundResult } from '@agent-device/host-kit/command'; import { resolveRunnerDestination } from './apple-runner-platform.ts'; import { resolveRunnerMaxConcurrentDestinationsFlag } from './runner-cache-metadata.ts'; @@ -61,7 +61,7 @@ export function launchRunnerProcess(input: LaunchRunnerProcessInput): LaunchedRu const listenerReady = createRunnerListenerReadySignal(); try { // The runner outlives this process, so it gets its environment and directory as plain values. - const { env, cwd } = commandHostEnvironment(); + const { env, cwd } = hostEnvironment(); const launched = runCmdBackground( 'xcodebuild', [ diff --git a/packages/platform-apple/src/runner/runner-retention.ts b/packages/platform-apple/src/runner/runner-retention.ts index ba66c66d90..3e7224926d 100644 --- a/packages/platform-apple/src/runner/runner-retention.ts +++ b/packages/platform-apple/src/runner/runner-retention.ts @@ -1,6 +1,6 @@ import net from 'node:net'; import { isIosFamily } from '@agent-device/kernel/device'; -import { commandHostEnvironment, emitDiagnostic } from './host.ts'; +import { hostEnvironment, emitDiagnostic } from './host.ts'; import { isRunnerMainThreadOccupied, type RunnerSession, @@ -117,7 +117,7 @@ function needsListenerWatch(session: RunnerSession): boolean { return session.device.kind === 'simulator' && isIosFamily(session.device); } -function resolveRunnerIdleStopMs(env: NodeJS.ProcessEnv = commandHostEnvironment().env): number { +function resolveRunnerIdleStopMs(env: NodeJS.ProcessEnv = hostEnvironment().env): number { const raw = env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS?.trim(); if (raw) { const parsed = Number(raw); diff --git a/packages/platform-apple/src/runner/runner-session.ts b/packages/platform-apple/src/runner/runner-session.ts index 2b3d58ffa5..22f3fb257e 100644 --- a/packages/platform-apple/src/runner/runner-session.ts +++ b/packages/platform-apple/src/runner/runner-session.ts @@ -6,7 +6,7 @@ import { emitDiagnostic, buildSimctlArgsForDevice, runXcrun, - commandHostEnvironment, + hostEnvironment, } from './host.ts'; import { isApplePlatform, type DeviceInfo } from '@agent-device/kernel/device'; import type { RunnerLogicalLeaseContext } from '@agent-device/contracts/runner-lease-context'; @@ -525,7 +525,7 @@ async function cleanupStaleSimulatorRunnerBundles(device: DeviceInfo): Promise { + resolveRunnerContainerBundleIds(hostEnvironment().env).map(async (bundleId) => { // Best-effort cleanup only; xcodebuild may still be able to install. await uninstallStaleSimulatorRunnerBundle(device, bundleId); }), diff --git a/src/__tests__/install-source.test.ts b/src/__tests__/install-source.test.ts index f21a0d69a7..b6c38e4815 100644 --- a/src/__tests__/install-source.test.ts +++ b/src/__tests__/install-source.test.ts @@ -25,12 +25,11 @@ import { } from '@agent-device/platform-apple/tool-provider'; import { prepareIosInstallArtifact } from '@agent-device/platform-apple/install-artifact'; import { AppError } from '@agent-device/kernel/errors'; +import '../platform-runtime-android-adb-host.ts'; import { ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE } from './install-source.coverage.ts'; -import { bindTestCommandHost, mkdtempForTest } from '@agent-device/test-kit'; +import { mkdtempForTest } from '@agent-device/test-kit'; import * as networkTransport from '@agent-device/provision-kit/install-source-network-transport'; -bindTestCommandHost(); - test('validateDownloadSourceUrl rejects localhost and private literal addresses by default', async () => { await assert.rejects( async () => await validateDownloadSourceUrl(new URL('http://127.0.0.1/app.apk')), @@ -202,10 +201,13 @@ test('prepareIosInstallArtifact extracts a URL archive from any public host', as async () => { await withIosBundleInfo('com.example.signedurl', 'Signed URL', async () => { await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { - const result = await prepareIosInstallArtifact({ - kind: 'url', - url: 'https://artifacts.example.com/signed/build.zip?sig=abc', - }); + const result = await prepareIosInstallArtifact( + { + kind: 'url', + url: 'https://artifacts.example.com/signed/build.zip?sig=abc', + }, + { environment: process.env }, + ); try { assert.equal(path.basename(result.installablePath), 'Demo.app'); @@ -389,10 +391,13 @@ test('prepareIosInstallArtifact extracts GitHub artifact ZIP containing nested a async () => { await withIosBundleInfo('com.example.githubtar', 'GitHub Tar', async () => { await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { - const result = await prepareIosInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/789/zip', - }); + const result = await prepareIosInstallArtifact( + { + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/789/zip', + }, + { environment: process.env }, + ); try { assert.equal(path.basename(result.installablePath), 'Demo.app'); @@ -430,10 +435,13 @@ test('prepareIosInstallArtifact extracts GitHub artifact ZIP containing one IPA' async () => { await withIosBundleInfo('com.example.githubipa', 'GitHub IPA', async () => { await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { - const result = await prepareIosInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/987/zip', - }); + const result = await prepareIosInstallArtifact( + { + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/987/zip', + }, + { environment: process.env }, + ); try { assert.equal(path.basename(result.installablePath), 'Demo.app'); @@ -466,10 +474,13 @@ test('prepareIosInstallArtifact names the zip a simulator app arrived in as its async () => { await withIosBundleInfo('com.example.githubapp', 'GitHub App', async () => { await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { - const result = await prepareIosInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/989/zip', - }); + const result = await prepareIosInstallArtifact( + { + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/989/zip', + }, + { environment: process.env }, + ); try { assert.equal(path.basename(result.installablePath), 'Demo.app'); @@ -508,10 +519,13 @@ test('prepareIosInstallArtifact cleans URL materialization when IPA payload reso await withIsolatedInstallTempRoot(async (materializeTempRoot) => { await assert.rejects( async () => - await prepareIosInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/988/zip', - }), + await prepareIosInstallArtifact( + { + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/988/zip', + }, + { environment: process.env }, + ), /found 2 .app bundles/, ); assert.deepEqual(await fs.readdir(materializeTempRoot), []); diff --git a/src/platform-runtime-apple-deployment-executor.ts b/src/platform-runtime-apple-deployment-executor.ts index 5099809cc1..c32d8a10d2 100644 --- a/src/platform-runtime-apple-deployment-executor.ts +++ b/src/platform-runtime-apple-deployment-executor.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { AppleAppDeploymentExecutor, MaterializeAppSourceInput, @@ -17,7 +18,10 @@ export function createAppleAppDeploymentExecutor(): AppleAppDeploymentExecutor { prepareArtifact: async (input: MaterializeAppSourceInput, options) => { const { prepareIosInstallArtifact } = await import('@agent-device/platform-apple/install-artifact'); - return await prepareIosInstallArtifact(input.source, options); + return await prepareIosInstallArtifact(input.source, { + ...options, + environment: commandHostEnvironment().env, + }); }, resolveAppBundleId: async (device, app, options) => { const { resolveIosApp } = await import('@agent-device/platform-apple/app-resolution'); From 7ef76904f3f1aa1e230e38bd1d6654d289ab49bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 11:43:17 +0200 Subject: [PATCH 13/27] refactor(daemon): hand the app-log marker the request host's environment Co-Authored-By: Claude Sonnet 5.5 --- .../daemon/src/resources/__tests__/app-log.test.ts | 12 +++--------- packages/daemon/src/resources/app-log.ts | 5 ++--- .../internal/__tests__/session-logs.test.ts | 3 --- .../internal/session-observability.ts | 5 +++-- 4 files changed, 8 insertions(+), 17 deletions(-) diff --git a/packages/daemon/src/resources/__tests__/app-log.test.ts b/packages/daemon/src/resources/__tests__/app-log.test.ts index 82a90bf615..40630c37c6 100644 --- a/packages/daemon/src/resources/__tests__/app-log.test.ts +++ b/packages/daemon/src/resources/__tests__/app-log.test.ts @@ -1,15 +1,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; -import { - assertThrowsAppError, - bindTestCommandHost, - mkdtempForTestSync, -} from '@agent-device/test-kit'; +import { assertThrowsAppError, mkdtempForTestSync } from '@agent-device/test-kit'; import { appendAppLogMarker, clearAppLogFiles, getAppLogPathMetadata } from '../app-log.ts'; -bindTestCommandHost(); - // Pinned as a literal on purpose — see the note in packages/host-kit/src/internal/verified-file.test.ts. const NOT_REGULAR_FILE_HINT = 'agent-device only reads and writes regular files at this path. Remove the symbolic link or special file there and retry.'; @@ -18,7 +12,7 @@ test('marker and clear operations keep app-log file ownership in the daemon', () const root = mkdtempForTestSync('agent-device-app-log-files-'); const outPath = path.join(root, 'session', 'app.log'); - appendAppLogMarker(outPath, 'checkpoint'); + appendAppLogMarker(outPath, 'checkpoint', process.env); fs.writeFileSync(`${outPath}.1`, 'rotated'); expect(fs.readFileSync(outPath, 'utf8')).toMatch(/\[agent-device\]\[mark\].* checkpoint/); @@ -50,7 +44,7 @@ test.each(['metadata', 'mark', 'clear'] as const)( assertThrowsAppError( () => { if (operation === 'metadata') getAppLogPathMetadata(outPath); - else if (operation === 'mark') appendAppLogMarker(outPath, 'checkpoint'); + else if (operation === 'mark') appendAppLogMarker(outPath, 'checkpoint', process.env); else clearAppLogFiles(outPath); }, { code: 'COMMAND_FAILED', message: expectedMessage, hint: NOT_REGULAR_FILE_HINT }, diff --git a/packages/daemon/src/resources/app-log.ts b/packages/daemon/src/resources/app-log.ts index a7c5b5a18f..3887a794b9 100644 --- a/packages/daemon/src/resources/app-log.ts +++ b/packages/daemon/src/resources/app-log.ts @@ -1,6 +1,5 @@ import { ensureAppLogPath } from '@agent-device/capture-kit/app-log-files'; import fs from 'node:fs'; -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import path from 'node:path'; import { openVerifiedFileForAppend, @@ -27,8 +26,8 @@ export function getAppLogPathMetadata(outPath: string): { } } -export function appendAppLogMarker(outPath: string, marker: string): void { - ensureAppLogPath(outPath, commandHostEnvironment().env); +export function appendAppLogMarker(outPath: string, marker: string, env: NodeJS.ProcessEnv): void { + ensureAppLogPath(outPath, env); const line = `[agent-device][mark][${new Date().toISOString()}] ${marker.trim() || 'marker'}\n`; const descriptor = openVerifiedFileForAppend(outPath); try { diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts index a8150557e4..ae717ea6b0 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts @@ -12,7 +12,6 @@ import { } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { - bindTestCommandHost, createTestAppLogLiveHandle, unavailableDeploymentSnapshotAndShutdownOperationFacts, createTestHostEnvironment, @@ -49,8 +48,6 @@ type RuntimeHarness = ReturnType; let runtime: RuntimeHarness; let admissionLedger: AppLogAdmissionLedger; -bindTestCommandHost(); - beforeEach(() => { runtime = createRuntimeHarness(); admissionLedger = createAppLogAdmissionLedger(); diff --git a/packages/daemon/src/sessions/session-observability/internal/session-observability.ts b/packages/daemon/src/sessions/session-observability/internal/session-observability.ts index d19ce34029..b73244b47a 100644 --- a/packages/daemon/src/sessions/session-observability/internal/session-observability.ts +++ b/packages/daemon/src/sessions/session-observability/internal/session-observability.ts @@ -211,7 +211,7 @@ async function executeLogsRuntimePlan( return await handleLogsStop(params); case 'mark': await params.bindDevice(params.session.device, plan.use); - return handleLogsMark(plan.marker, params.sessionName, params.sessionStore); + return handleLogsMark(plan.marker, params.sessionName, params.sessionStore, params.host.env); case 'clear': await params.bindDevice(params.session.device, plan.use); return handleLogsClear(params); @@ -301,9 +301,10 @@ function handleLogsMark( marker: string, sessionName: string, sessionStore: SessionStore, + env: NodeJS.ProcessEnv, ): DaemonResponse { const logPath = sessionStore.resolveAppLogPath(sessionName); - appendAppLogMarker(logPath, marker); + appendAppLogMarker(logPath, marker, env); return { ok: true, data: { path: logPath, marked: true } }; } From 8bfc940399787d83a1225ed1d444bdfc1bdc5d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 13:06:43 +0200 Subject: [PATCH 14/27] fix(capture-kit): report an unavailable PNG worker in the first diagnostics scope that sees it A startup pre-warm runs outside any scope, where the event is dropped; it no longer latches the one-time report. Co-Authored-By: Claude Sonnet 5.5 --- .../src/png-worker-client-unavailable.test.ts | 30 +++++++++++++++++ packages/capture-kit/src/png-worker-client.ts | 32 +++++++++++++++---- 2 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 packages/capture-kit/src/png-worker-client-unavailable.test.ts diff --git a/packages/capture-kit/src/png-worker-client-unavailable.test.ts b/packages/capture-kit/src/png-worker-client-unavailable.test.ts new file mode 100644 index 0000000000..bb73fdb2fd --- /dev/null +++ b/packages/capture-kit/src/png-worker-client-unavailable.test.ts @@ -0,0 +1,30 @@ +import { expect, test, vi } from 'vitest'; +import { PNG } from './png.ts'; + +test('an unavailable worker is reported by the first diagnostics scope that reaches the fallback', async () => { + vi.resetModules(); + vi.doMock('./internal-entry.ts', () => ({ resolveInternalEntryModulePath: () => null })); + // Loaded after the reset so the client and the scope share one diagnostics module. + const { countDiagnosticEventsByPhase, withDiagnosticsScope } = + await import('@agent-device/host-kit/diagnostics'); + const { decodePngAsync, prewarmPngWorker } = await import('./png-worker-client.ts'); + const png = PNG.sync.write(new PNG({ width: 1, height: 1 })); + + // The daemon pre-warms outside any request scope, where the report would be dropped. + prewarmPngWorker(); + + const reported = await withDiagnosticsScope({ command: 'screenshot' }, async () => { + await decodePngAsync(png, 'first'); + const afterFirst = countDiagnosticEventsByPhase(['png_worker_unavailable']); + await decodePngAsync(png, 'second'); + return { afterFirst, afterSecond: countDiagnosticEventsByPhase(['png_worker_unavailable']) }; + }); + const laterScope = await withDiagnosticsScope({ command: 'screenshot' }, async () => { + await decodePngAsync(png, 'third'); + return countDiagnosticEventsByPhase(['png_worker_unavailable']); + }); + + expect(reported).toEqual({ afterFirst: 1, afterSecond: 1 }); + expect(laterScope).toBe(0); + vi.doUnmock('./internal-entry.ts'); +}); diff --git a/packages/capture-kit/src/png-worker-client.ts b/packages/capture-kit/src/png-worker-client.ts index c18f02abac..f3949aa7d8 100644 --- a/packages/capture-kit/src/png-worker-client.ts +++ b/packages/capture-kit/src/png-worker-client.ts @@ -1,5 +1,5 @@ import { Worker } from 'node:worker_threads'; -import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { emitDiagnostic, getDiagnosticsMeta } from '@agent-device/host-kit/diagnostics'; import { AppError, toAppErrorCode } from '@agent-device/kernel/errors'; import type { Rect } from '@agent-device/kernel/snapshot'; import { resolveInternalEntryModulePath } from './internal-entry.ts'; @@ -39,16 +39,31 @@ type PendingJob = { let worker: Worker | null = null; let workerUnavailable = false; -let warnedWorkerUnavailable = false; +let workerUnavailableReason: string | undefined; +let reportedWorkerUnavailable = false; let nextJobId = 0; const pendingJobs = new Map(); -/** Permanently degrades to the sync path and reports the reason once. */ +/** Permanently degrades to the sync path; the reason is reported by the first scope that sees it. */ function markWorkerUnavailable(reason: string): void { workerUnavailable = true; - if (warnedWorkerUnavailable) return; - warnedWorkerUnavailable = true; - emitDiagnostic({ level: 'warn', phase: 'png_worker_unavailable', data: { reason } }); + workerUnavailableReason ??= reason; + reportWorkerUnavailable(); +} + +/** + * A pre-warm at daemon startup runs outside any diagnostics scope, where the event would be + * dropped, so the report waits for the first request scope that reaches the degraded path. + */ +function reportWorkerUnavailable(): void { + if (reportedWorkerUnavailable || workerUnavailableReason === undefined) return; + if (Object.keys(getDiagnosticsMeta()).length === 0) return; + reportedWorkerUnavailable = true; + emitDiagnostic({ + level: 'warn', + phase: 'png_worker_unavailable', + data: { reason: workerUnavailableReason }, + }); } function handleWorkerMessage(message: PngWorkerResponse): void { @@ -97,7 +112,10 @@ function updateWorkerRef(): void { } function obtainWorker(): Worker | null { - if (workerUnavailable) return null; + if (workerUnavailable) { + reportWorkerUnavailable(); + return null; + } if (worker) return worker; const modulePath = resolveInternalEntryModulePath(import.meta.url, PNG_WORKER_ENTRYPOINT); if (!modulePath) { From 774d7053ffef7d6612a1c4a3e045285e8f88942d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 13:19:30 +0200 Subject: [PATCH 15/27] test(host): share the test command host binding through test-kit and test the runner's bound host behaviorally test-kit no longer depends on capture-kit: its app-log live-handle alias is gone (callers use the capture-kit constructor) and its keyboard fixture builds its own snapshot state. capture-kit's tests take the helper from test-kit. Co-Authored-By: Claude Sonnet 5.5 --- packages/capture-kit/package.json | 1 + .../src/host-environment.fixtures.ts | 16 ---------- .../src/recording/__tests__/overlay.test.ts | 2 +- .../recording/contact-sheet-frames.test.ts | 2 +- .../src/recording/contact-sheet.test.ts | 2 +- .../src/recording/swift-cache.test.ts | 2 +- packages/capture-kit/tsconfig.json | 7 ++++- .../src/device-claim-reconciliation.test.ts | 5 ++- .../support/handlers/session-test-harness.ts | 6 ++-- .../request-runtime-binding-router.test.ts | 9 ++++-- .../app-log-resource-recovery.test.ts | 5 ++- .../app-log-session-resource.test.ts | 7 +++-- .../__tests__/daemon-runtime-app-log.test.ts | 6 ++-- .../internal/__tests__/session-logs.test.ts | 9 ++++-- .../__tests__/runner-process-launch.test.ts | 26 +++++++++++----- .../src/snapshot-source/host.test.ts | 31 +------------------ packages/test-kit/package.json | 1 - packages/test-kit/src/app-log-live-handle.ts | 20 ------------ packages/test-kit/src/index.ts | 1 - .../src/keyboard-occlusion.fixtures.ts | 14 +++++++-- packages/test-kit/tsconfig.json | 7 +---- pnpm-lock.yaml | 6 ++-- .../ios-alert-settings.test.ts | 11 ++++--- 23 files changed, 78 insertions(+), 118 deletions(-) delete mode 100644 packages/capture-kit/src/host-environment.fixtures.ts delete mode 100644 packages/test-kit/src/app-log-live-handle.ts diff --git a/packages/capture-kit/package.json b/packages/capture-kit/package.json index 93b2b54e02..ea7f7749ef 100644 --- a/packages/capture-kit/package.json +++ b/packages/capture-kit/package.json @@ -356,6 +356,7 @@ } }, "devDependencies": { + "@agent-device/test-kit": "workspace:*", "@types/pngjs": "^6.0.5", "fast-check": "^4.9.0", "jpeg-js": "^0.4.4", diff --git a/packages/capture-kit/src/host-environment.fixtures.ts b/packages/capture-kit/src/host-environment.fixtures.ts deleted file mode 100644 index 9bea7aa097..0000000000 --- a/packages/capture-kit/src/host-environment.fixtures.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { aroundEach } from 'vitest'; -import { withCommandHost } from '@agent-device/host-kit/command'; - -/** - * Runs every test of the calling suite as a command host whose environment is the live process - * environment. Test-kit owns the shared helper; capture-kit is a dependency of test-kit, so it - * keeps this copy local. - */ -export function bindTestCommandHost(): void { - aroundEach(async (runTest) => { - await withCommandHost( - { host: { env: process.env, cwd: process.cwd() }, requestEnv: undefined }, - async () => await runTest(), - ); - }); -} diff --git a/packages/capture-kit/src/recording/__tests__/overlay.test.ts b/packages/capture-kit/src/recording/__tests__/overlay.test.ts index 892c629b57..bf94c4beda 100644 --- a/packages/capture-kit/src/recording/__tests__/overlay.test.ts +++ b/packages/capture-kit/src/recording/__tests__/overlay.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts'; -import { bindTestCommandHost } from '../../host-environment.fixtures.ts'; +import { bindTestCommandHost } from '@agent-device/test-kit'; bindTestCommandHost(); diff --git a/packages/capture-kit/src/recording/contact-sheet-frames.test.ts b/packages/capture-kit/src/recording/contact-sheet-frames.test.ts index 9cb949a8dd..c4ba002bab 100644 --- a/packages/capture-kit/src/recording/contact-sheet-frames.test.ts +++ b/packages/capture-kit/src/recording/contact-sheet-frames.test.ts @@ -7,7 +7,7 @@ import { } from './contact-sheet-report.ts'; import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors'; import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; -import { bindTestCommandHost } from '../host-environment.fixtures.ts'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import { solidPng } from '../png-pixels.fixtures.ts'; import { writeDecodedFrames, type StubFrame } from './contact-sheet.fixtures.ts'; import { extractRecordingFrames } from './contact-sheet-frames.ts'; diff --git a/packages/capture-kit/src/recording/contact-sheet.test.ts b/packages/capture-kit/src/recording/contact-sheet.test.ts index 384630b2aa..6cdad63eba 100644 --- a/packages/capture-kit/src/recording/contact-sheet.test.ts +++ b/packages/capture-kit/src/recording/contact-sheet.test.ts @@ -12,7 +12,7 @@ import { import { AppError } from '@agent-device/kernel/errors'; import { BLACK, RED, WHITE, paintPng, solidPng } from '../png-pixels.fixtures.ts'; import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; -import { bindTestCommandHost } from '../host-environment.fixtures.ts'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import { decodePng } from '../png.ts'; import { mp4Atom, mp4MovieHeader } from './mp4.fixtures.ts'; import { writeDecodedFrames, type StubFrame } from './contact-sheet.fixtures.ts'; diff --git a/packages/capture-kit/src/recording/swift-cache.test.ts b/packages/capture-kit/src/recording/swift-cache.test.ts index ba57a7b0a6..9c6438df5c 100644 --- a/packages/capture-kit/src/recording/swift-cache.test.ts +++ b/packages/capture-kit/src/recording/swift-cache.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; -import { bindTestCommandHost } from '../host-environment.fixtures.ts'; +import { bindTestCommandHost } from '@agent-device/test-kit'; bindTestCommandHost(); diff --git a/packages/capture-kit/tsconfig.json b/packages/capture-kit/tsconfig.json index 247a915b90..380be0b4f5 100644 --- a/packages/capture-kit/tsconfig.json +++ b/packages/capture-kit/tsconfig.json @@ -10,5 +10,10 @@ "tsBuildInfoFile": "./dist-types/index.tsbuildinfo" }, "include": ["src"], - "references": [{ "path": "../contracts" }, { "path": "../host-kit" }, { "path": "../kernel" }] + "references": [ + { "path": "../contracts" }, + { "path": "../host-kit" }, + { "path": "../kernel" }, + { "path": "../test-kit" } + ] } diff --git a/packages/daemon-contracts/src/device-claim-reconciliation.test.ts b/packages/daemon-contracts/src/device-claim-reconciliation.test.ts index 8596db4d48..7dd2e83e9e 100644 --- a/packages/daemon-contracts/src/device-claim-reconciliation.test.ts +++ b/packages/daemon-contracts/src/device-claim-reconciliation.test.ts @@ -3,7 +3,7 @@ import { appLogResourceStore } from '@agent-device/capture-kit/app-log-durable-r import fs from 'node:fs'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; -import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { createDurableResourceEnvelope, createAppLogLiveHandle } from '@agent-device/capture-kit'; import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; import { type DeviceRuntimeGateway, @@ -11,7 +11,6 @@ import { } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { - createTestAppLogLiveHandle, mkdtempForTestSync, unavailableDeploymentSnapshotAndShutdownOperationFacts, } from '@agent-device/test-kit'; @@ -42,7 +41,7 @@ test('reconciles the dead owner session resources through their exact runtime ow }), ); const forceCleanup = vi.fn(async () => ({ status: 'cleaned' as const })); - const handle = createTestAppLogLiveHandle({ + const handle = createAppLogLiveHandle({ inspect: () => ({ backend: 'android', state: 'recovering', startedAt: 1 }), finish: async () => ({ status: 'completed', diff --git a/packages/daemon/src/__tests__/support/handlers/session-test-harness.ts b/packages/daemon/src/__tests__/support/handlers/session-test-harness.ts index 325beec697..04fdb942db 100644 --- a/packages/daemon/src/__tests__/support/handlers/session-test-harness.ts +++ b/packages/daemon/src/__tests__/support/handlers/session-test-harness.ts @@ -2,10 +2,10 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { isMacOs } from '@agent-device/kernel/device'; import { legacyDispatchCapture } from '../legacy-snapshot-capture-fixture.ts'; import { expect, vi, beforeEach } from 'vitest'; -import { createTestAppLogLiveHandle, mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; import type { AppLogLiveState } from '@agent-device/contracts/app-log-runtime'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; -import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { createDurableResourceEnvelope, createAppLogLiveHandle } from '@agent-device/capture-kit'; import type { LogBackend } from '@agent-device/contracts/observability'; vi.mock('node:timers/promises', async (importOriginal) => { @@ -201,7 +201,7 @@ export function makeTestAppLogResource( }, ): NonNullable { const outputPath = options.outputPath ?? '/tmp/app.log'; - const handle = createTestAppLogLiveHandle({ + const handle = createAppLogLiveHandle({ inspect: () => ({ backend: options.backend, state: options.state ?? 'active', diff --git a/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts b/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts index d281803aac..0d984b6f20 100644 --- a/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts +++ b/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts @@ -13,9 +13,12 @@ import { localRuntimeOwner, } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; -import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { - createTestAppLogLiveHandle, + createAppLogStartResult, + createDurableResourceEnvelope, + createAppLogLiveHandle, +} from '@agent-device/capture-kit'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts, createTestHostEnvironment, } from '@agent-device/test-kit'; @@ -172,7 +175,7 @@ function request(positionals: string[]) { function makeGateway(disposeError?: Error) { const owner = localRuntimeOwner('android'); const forceCleanup = vi.fn(async () => ({ status: 'cleaned' as const })); - const handle = createTestAppLogLiveHandle({ + const handle = createAppLogLiveHandle({ inspect: () => ({ backend: 'android', state: 'active', startedAt: 1 }), finish: async () => ({ status: 'completed', diff --git a/packages/daemon/src/resources/__tests__/app-log-resource-recovery.test.ts b/packages/daemon/src/resources/__tests__/app-log-resource-recovery.test.ts index 9092621873..445692554c 100644 --- a/packages/daemon/src/resources/__tests__/app-log-resource-recovery.test.ts +++ b/packages/daemon/src/resources/__tests__/app-log-resource-recovery.test.ts @@ -11,9 +11,8 @@ import { localRuntimeOwner, } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; -import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { createDurableResourceEnvelope, createAppLogLiveHandle } from '@agent-device/capture-kit'; import { - createTestAppLogLiveHandle, mkdtempForTestSync, unavailableDeploymentSnapshotAndShutdownOperationFacts, } from '@agent-device/test-kit'; @@ -395,7 +394,7 @@ function createHandle( status: 'cleaned', })), ) { - return createTestAppLogLiveHandle({ + return createAppLogLiveHandle({ inspect: () => ({ backend: 'android', state: 'recovering', startedAt: 1 }), finish: async () => ({ status: 'completed', diff --git a/packages/daemon/src/resources/__tests__/app-log-session-resource.test.ts b/packages/daemon/src/resources/__tests__/app-log-session-resource.test.ts index 2db2ebda52..fe2f21a57b 100644 --- a/packages/daemon/src/resources/__tests__/app-log-session-resource.test.ts +++ b/packages/daemon/src/resources/__tests__/app-log-session-resource.test.ts @@ -8,9 +8,10 @@ import { createAppLogLiveHandleFromFinish, createAppLogStartResult, createDurableResourceEnvelope, + createAppLogLiveHandle, } from '@agent-device/capture-kit'; import { PendingTransferGuard } from '@agent-device/contracts/async-lifecycle'; -import { createTestAppLogLiveHandle, mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; @@ -433,7 +434,7 @@ test.each(['rebuild', 'retire', 'replace-resource'] as const)( result: { backend: 'android' as const, outputPath: '/tmp/app.log', completedAt: 2 }, }; }); - const handle = createTestAppLogLiveHandle({ + const handle = createAppLogLiveHandle({ inspect: () => ({ backend: 'android', state: 'active', startedAt: 1 }), finish, forceCleanup: async () => ({ status: 'cleaned' }), @@ -510,7 +511,7 @@ function makeStartResult( envelopeDevice?: Parameters[0]['device'], ) { const forceCleanup = vi.fn(async () => cleanup); - const handle = createTestAppLogLiveHandle({ + const handle = createAppLogLiveHandle({ inspect: () => ({ backend: 'android', state: 'active', startedAt: 1 }), finish: async () => ({ status: 'completed', diff --git a/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts b/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts index 532ae85cde..b162d5b74d 100644 --- a/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts +++ b/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts @@ -6,8 +6,8 @@ import path from 'node:path'; import { expect, test, vi } from 'vitest'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import type { ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; -import { createTestAppLogLiveHandle, mkdtempForTestSync } from '@agent-device/test-kit'; +import { createDurableResourceEnvelope, createAppLogLiveHandle } from '@agent-device/capture-kit'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { flushDaemonStartupDiagnostics, @@ -113,7 +113,7 @@ test('daemon shutdown settles fenced app-log cleanup before finalization can rel await cleanupReleased; return { status: 'cleaned' as const }; }); - const handle = createTestAppLogLiveHandle({ + const handle = createAppLogLiveHandle({ inspect: () => ({ backend: 'android', state: 'active', startedAt: 1 }), finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), forceCleanup, diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts index ae717ea6b0..8211d38672 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts @@ -12,11 +12,14 @@ import { } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { - createTestAppLogLiveHandle, unavailableDeploymentSnapshotAndShutdownOperationFacts, createTestHostEnvironment, } from '@agent-device/test-kit'; -import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { + createAppLogStartResult, + createDurableResourceEnvelope, + createAppLogLiveHandle, +} from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { @@ -345,7 +348,7 @@ function createRuntimeHarness(options: { inspectAvailable?: boolean } = {}) { status: 'cleaned', })); const start = vi.fn(async (input) => { - const handle = createTestAppLogLiveHandle({ + const handle = createAppLogLiveHandle({ inspect: () => ({ backend: 'ios-simulator', state: 'active', diff --git a/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts b/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts index 1780f825cc..8d7d9b3efb 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts @@ -49,20 +49,32 @@ test("the runner is spawned detached onto its own log file, not onto this proces assert.equal(args[args.indexOf('-derivedDataPath') + 1], '/tmp/runner-derived'); }); -test('the detached runner gets its environment and working directory as plain values', () => { - mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); - const host = { env: { FROM_HOST: 'yes' }, cwd: '/tmp/host-cwd' }; - appleRunnerTestHost.update({ hostEnvironment: () => host }); +test('the detached runner starts in the bound host environment and directory, not the process', async () => { + const root = mkdtempForTestSync('runner-launch-host-'); + const bin = path.join(root, 'bin'); + const cwd = path.join(root, 'cwd'); + const evidence = path.join(root, 'evidence.txt'); + fs.mkdirSync(bin); + fs.mkdirSync(cwd); + fs.writeFileSync( + path.join(bin, 'xcodebuild'), + '#!/bin/sh\nprintf "%s\\n%s\\n" "$FROM_HOST" "$PWD" > "$EVIDENCE"\n', + { mode: 0o755 }, + ); + appleRunnerTestHost.update({ + runCmdBackground: appleRunnerTestHost.defaults().runCmdBackground, + hostEnvironment: () => ({ env: { PATH: bin, FROM_HOST: 'yes', EVIDENCE: evidence }, cwd }), + }); - launchRunnerProcess({ + const launched = launchRunnerProcess({ device: IOS_SIMULATOR, xctestrunPath: '/tmp/runner.xctestrun', derivedPath: '/tmp/runner-derived', logPath: runnerLogPath(), }); + await launched.wait; - const options = mockRunCmdBackground.mock.calls[0]?.[2] as { env?: unknown; cwd?: string }; - assert.deepEqual({ env: options.env, cwd: options.cwd }, host); + assert.equal(fs.readFileSync(evidence, 'utf8'), `yes\n${fs.realpathSync(cwd)}\n`); }); test('test-without-building resolves a scoped-set simulator in its own set', () => { diff --git a/packages/platform-apple/src/snapshot-source/host.test.ts b/packages/platform-apple/src/snapshot-source/host.test.ts index 82d70aed7b..1302037d87 100644 --- a/packages/platform-apple/src/snapshot-source/host.test.ts +++ b/packages/platform-apple/src/snapshot-source/host.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { test, vi } from 'vitest'; -import { runCmdBackground, withCommandHost } from '@agent-device/host-kit/command'; +import { runCmdBackground } from '@agent-device/host-kit/command'; import { bindTestCommandHost } from '@agent-device/test-kit'; import { simulatorAddressFor } from '../core/simctl.ts'; import { createSnapshotSourceHost, snapshotSourceSocketPath } from './host.ts'; @@ -74,32 +74,3 @@ test.each([ ], ]); }); - -test('the detached bridge gets its environment and working directory as plain values', async () => { - const spawn = vi.mocked(runCmdBackground); - spawn.mockReset(); - spawn.mockReturnValue({ - child: Object.assign(new EventEmitter(), { pid: 4243, exitCode: null, signalCode: null }), - wait: new Promise(() => {}), - } as unknown as ReturnType); - const host = { env: { FROM_HOST: 'yes' }, cwd: '/tmp/host-cwd' }; - - await withCommandHost({ host, requestEnv: undefined }, async () => { - createSnapshotSourceHost().start( - simulatorAddressFor({ - platform: 'apple', - id: 'simulator-1', - name: 'iPhone 17', - kind: 'simulator', - target: 'mobile', - }), - '/tmp/bridge', - '/tmp/bridge.sock', - ); - }); - - assert.deepEqual( - { env: spawn.mock.calls[0]?.[2]?.env, cwd: spawn.mock.calls[0]?.[2]?.cwd }, - { env: host.env, cwd: host.cwd }, - ); -}); diff --git a/packages/test-kit/package.json b/packages/test-kit/package.json index 3d65015612..6c709f4a8d 100644 --- a/packages/test-kit/package.json +++ b/packages/test-kit/package.json @@ -5,7 +5,6 @@ "type": "module", "description": "Private shared test helpers (temp dirs, device/session/snapshot fixtures, mocked processes, loopback servers) for the root and workspace-package test trees. One entry, named exports; test-only, never bundled, never importing root `src/`, `platform-*`, `provider-*` or the daemon.", "dependencies": { - "@agent-device/capture-kit": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/host-kit": "workspace:*", "@agent-device/kernel": "workspace:*" diff --git a/packages/test-kit/src/app-log-live-handle.ts b/packages/test-kit/src/app-log-live-handle.ts deleted file mode 100644 index 68bc7da4bb..0000000000 --- a/packages/test-kit/src/app-log-live-handle.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { - AppLogCompletion, - AppLogLiveHandle, - AppLogLiveSnapshot, -} from '@agent-device/contracts/app-log-runtime'; -import type { CleanupOutcome, FinishOutcome } from '@agent-device/contracts/durable-resource'; -import { createAppLogLiveHandle } from '@agent-device/capture-kit'; - -type AppLogLiveHandleImplementation = Readonly<{ - inspect(): AppLogLiveSnapshot; - finish(): Promise>; - forceCleanup(): Promise; -}>; - -/** Test-only constructor for owners that need independently controlled finish and cleanup outcomes. */ -export function createTestAppLogLiveHandle( - implementation: AppLogLiveHandleImplementation, -): AppLogLiveHandle { - return createAppLogLiveHandle(implementation); -} diff --git a/packages/test-kit/src/index.ts b/packages/test-kit/src/index.ts index 78dabdb0d2..1c648d6da2 100644 --- a/packages/test-kit/src/index.ts +++ b/packages/test-kit/src/index.ts @@ -3,7 +3,6 @@ // '@agent-device/test-kit', never from a file path. export { assertRejectsAppError, assertThrowsAppError } from './app-error.ts'; -export { createTestAppLogLiveHandle } from './app-log-live-handle.ts'; export { BOUNDARY_COMMAND_CLASSES, BOUNDARY_FAULT_MATRIX, diff --git a/packages/test-kit/src/keyboard-occlusion.fixtures.ts b/packages/test-kit/src/keyboard-occlusion.fixtures.ts index 9b2cf6e741..c7a62e8d55 100644 --- a/packages/test-kit/src/keyboard-occlusion.fixtures.ts +++ b/packages/test-kit/src/keyboard-occlusion.fixtures.ts @@ -1,5 +1,9 @@ -import { makeSnapshotState } from '@agent-device/capture-kit/snapshot-state-fixtures'; -import type { Rect, SnapshotState } from '@agent-device/kernel/snapshot'; +import { + attachRefs, + type RawSnapshotNode, + type Rect, + type SnapshotState, +} from '@agent-device/kernel/snapshot'; /** The #2589 capture's app-owned tab bar, and the band the captured keyboard docks to. */ export const KEYBOARD_COVERED_TAB_BAR_RECT: Rect = { x: 148, y: 791, width: 104, height: 83 }; @@ -33,7 +37,7 @@ export function keyboardCoveredTabBarSnapshot( } function keyboardCoveredTabBarTree(): SnapshotState { - return makeSnapshotState([ + return keyboardSnapshotState([ { index: 0, depth: 0, @@ -96,3 +100,7 @@ function keyboardCoveredTabBarTree(): SnapshotState { }, ]); } + +function keyboardSnapshotState(raw: RawSnapshotNode[]): SnapshotState { + return { nodes: attachRefs(raw), createdAt: Date.now() }; +} diff --git a/packages/test-kit/tsconfig.json b/packages/test-kit/tsconfig.json index 3431c632a3..247a915b90 100644 --- a/packages/test-kit/tsconfig.json +++ b/packages/test-kit/tsconfig.json @@ -10,10 +10,5 @@ "tsBuildInfoFile": "./dist-types/index.tsbuildinfo" }, "include": ["src"], - "references": [ - { "path": "../capture-kit" }, - { "path": "../contracts" }, - { "path": "../host-kit" }, - { "path": "../kernel" } - ] + "references": [{ "path": "../contracts" }, { "path": "../host-kit" }, { "path": "../kernel" }] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cf558c119..9780173f06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -257,6 +257,9 @@ importers: specifier: workspace:* version: link:../kernel devDependencies: + '@agent-device/test-kit': + specifier: workspace:* + version: link:../test-kit '@types/pngjs': specifier: ^6.0.5 version: 6.0.5 @@ -837,9 +840,6 @@ importers: packages/test-kit: dependencies: - '@agent-device/capture-kit': - specifier: workspace:* - version: link:../capture-kit '@agent-device/contracts': specifier: workspace:* version: link:../contracts diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index b56efec39e..c85bf8f722 100644 --- a/test/integration/provider-scenarios/ios-alert-settings.test.ts +++ b/test/integration/provider-scenarios/ios-alert-settings.test.ts @@ -9,11 +9,12 @@ import { type RuntimeFacts, } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '@agent-device/test-kit'; import { - createTestAppLogLiveHandle, - unavailableDeploymentSnapshotAndShutdownOperationFacts, -} from '@agent-device/test-kit'; -import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; + createAppLogStartResult, + createDurableResourceEnvelope, + createAppLogLiveHandle, +} from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { applePlugin } from '@agent-device/platform-apple'; import type { AlertRuntimeInput } from '@agent-device/contracts/alert-runtime'; @@ -259,7 +260,7 @@ function createRecordingPlatformRuntimeGateway(params: { }, }; })()); - const handle = createTestAppLogLiveHandle({ + const handle = createAppLogLiveHandle({ inspect: () => ({ backend: 'ios-simulator', state: 'active', From 58232c59de91ae0838eacf5c5b854046e77f3daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 13:27:54 +0200 Subject: [PATCH 16/27] refactor(capture-kit): expose the app-log live handle adapter through its own entry Co-Authored-By: Claude Sonnet 5.5 --- packages/capture-kit/package.json | 4 ++++ packages/capture-kit/src/index.ts | 2 +- .../src/device-claim-reconciliation.test.ts | 3 ++- .../src/__tests__/support/handlers/session-test-harness.ts | 3 ++- .../core/__tests__/request-runtime-binding-router.test.ts | 7 ++----- .../resources/__tests__/app-log-resource-recovery.test.ts | 3 ++- .../resources/__tests__/app-log-session-resource.test.ts | 2 +- .../src/server/__tests__/daemon-runtime-app-log.test.ts | 3 ++- .../internal/__tests__/session-logs.test.ts | 7 ++----- .../provider-scenarios/ios-alert-settings.test.ts | 7 ++----- 10 files changed, 20 insertions(+), 21 deletions(-) diff --git a/packages/capture-kit/package.json b/packages/capture-kit/package.json index ea7f7749ef..94d89c4bd5 100644 --- a/packages/capture-kit/package.json +++ b/packages/capture-kit/package.json @@ -22,6 +22,10 @@ "types": "./src/capture-admission/app-log-durable-resource.ts", "default": "./src/capture-admission/app-log-durable-resource.ts" }, + "./app-log-live-handle": { + "types": "./src/app-log-live-handle.ts", + "default": "./src/app-log-live-handle.ts" + }, "./app-log-files": { "types": "./src/capture-admission/app-log-files.ts", "default": "./src/capture-admission/app-log-files.ts" diff --git a/packages/capture-kit/src/index.ts b/packages/capture-kit/src/index.ts index 5c83dd4f4e..9006c680e4 100644 --- a/packages/capture-kit/src/index.ts +++ b/packages/capture-kit/src/index.ts @@ -7,7 +7,7 @@ export { createAppLogStartResult, decodeAppLogProcessMarker, } from './app-log-runtime.ts'; -export { createAppLogLiveHandle, createAppLogLiveHandleFromFinish } from './app-log-live-handle.ts'; +export { createAppLogLiveHandleFromFinish } from './app-log-live-handle.ts'; export { createHostAudioProbeCaptureOperations } from './audio-probe-runtime.ts'; export { decodeDurableDescriptor } from './durable-descriptor-codec.ts'; export { createScreenRecordingLiveHandle } from './screen-recording-live-handle.ts'; diff --git a/packages/daemon-contracts/src/device-claim-reconciliation.test.ts b/packages/daemon-contracts/src/device-claim-reconciliation.test.ts index 7dd2e83e9e..20d16a1548 100644 --- a/packages/daemon-contracts/src/device-claim-reconciliation.test.ts +++ b/packages/daemon-contracts/src/device-claim-reconciliation.test.ts @@ -1,9 +1,10 @@ import { createDeviceClaimReconciler } from './device-claim-reconciliation.ts'; +import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import { appLogResourceStore } from '@agent-device/capture-kit/app-log-durable-resource'; import fs from 'node:fs'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; -import { createDurableResourceEnvelope, createAppLogLiveHandle } from '@agent-device/capture-kit'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; import { type DeviceRuntimeGateway, diff --git a/packages/daemon/src/__tests__/support/handlers/session-test-harness.ts b/packages/daemon/src/__tests__/support/handlers/session-test-harness.ts index 04fdb942db..469ca56ac1 100644 --- a/packages/daemon/src/__tests__/support/handlers/session-test-harness.ts +++ b/packages/daemon/src/__tests__/support/handlers/session-test-harness.ts @@ -1,11 +1,12 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import { isMacOs } from '@agent-device/kernel/device'; import { legacyDispatchCapture } from '../legacy-snapshot-capture-fixture.ts'; import { expect, vi, beforeEach } from 'vitest'; import { mkdtempForTestSync } from '@agent-device/test-kit'; import type { AppLogLiveState } from '@agent-device/contracts/app-log-runtime'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; -import { createDurableResourceEnvelope, createAppLogLiveHandle } from '@agent-device/capture-kit'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import type { LogBackend } from '@agent-device/contracts/observability'; vi.mock('node:timers/promises', async (importOriginal) => { diff --git a/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts b/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts index 0d984b6f20..3f2cce9af0 100644 --- a/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts +++ b/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import { expect, test, vi } from 'vitest'; import { clearRequestCanceled, @@ -13,11 +14,7 @@ import { localRuntimeOwner, } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; -import { - createAppLogStartResult, - createDurableResourceEnvelope, - createAppLogLiveHandle, -} from '@agent-device/capture-kit'; +import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { unavailableDeploymentSnapshotAndShutdownOperationFacts, createTestHostEnvironment, diff --git a/packages/daemon/src/resources/__tests__/app-log-resource-recovery.test.ts b/packages/daemon/src/resources/__tests__/app-log-resource-recovery.test.ts index 445692554c..9481d56489 100644 --- a/packages/daemon/src/resources/__tests__/app-log-resource-recovery.test.ts +++ b/packages/daemon/src/resources/__tests__/app-log-resource-recovery.test.ts @@ -1,4 +1,5 @@ import { appLogResourceStore } from '@agent-device/capture-kit/app-log-durable-resource'; +import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import { recoverAppLogResourcesAfterDaemonLock } from '@agent-device/capture-kit/app-log-resource-recovery'; import fs from 'node:fs'; import path from 'node:path'; @@ -11,7 +12,7 @@ import { localRuntimeOwner, } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; -import { createDurableResourceEnvelope, createAppLogLiveHandle } from '@agent-device/capture-kit'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { mkdtempForTestSync, unavailableDeploymentSnapshotAndShutdownOperationFacts, diff --git a/packages/daemon/src/resources/__tests__/app-log-session-resource.test.ts b/packages/daemon/src/resources/__tests__/app-log-session-resource.test.ts index fe2f21a57b..92414e0256 100644 --- a/packages/daemon/src/resources/__tests__/app-log-session-resource.test.ts +++ b/packages/daemon/src/resources/__tests__/app-log-session-resource.test.ts @@ -1,4 +1,5 @@ import { appLogResourceStore } from '@agent-device/capture-kit/app-log-durable-resource'; +import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import fs from 'node:fs'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; @@ -8,7 +9,6 @@ import { createAppLogLiveHandleFromFinish, createAppLogStartResult, createDurableResourceEnvelope, - createAppLogLiveHandle, } from '@agent-device/capture-kit'; import { PendingTransferGuard } from '@agent-device/contracts/async-lifecycle'; import { mkdtempForTestSync } from '@agent-device/test-kit'; diff --git a/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts b/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts index b162d5b74d..89e8e94e81 100644 --- a/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts +++ b/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts @@ -1,4 +1,5 @@ import { appLogResourceStore } from '@agent-device/capture-kit/app-log-durable-resource'; +import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import { recoverAppLogResourcesAfterDaemonLock } from '@agent-device/capture-kit/app-log-resource-recovery'; import { platformResourceCleanupFixture } from '../../__tests__/support/platform-resource-cleanup-fixture.ts'; import fs from 'node:fs'; @@ -6,7 +7,7 @@ import path from 'node:path'; import { expect, test, vi } from 'vitest'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import type { ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { createDurableResourceEnvelope, createAppLogLiveHandle } from '@agent-device/capture-kit'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { mkdtempForTestSync } from '@agent-device/test-kit'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts index 8211d38672..c0610f82b6 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts @@ -1,4 +1,5 @@ import { appLogResourceStore } from '@agent-device/capture-kit/app-log-durable-resource'; +import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import { daemonPlatformServicesFixture } from '../../../../__tests__/support/platform-services-fixture.ts'; import fs from 'node:fs'; import { beforeEach, expect, test, vi } from 'vitest'; @@ -15,11 +16,7 @@ import { unavailableDeploymentSnapshotAndShutdownOperationFacts, createTestHostEnvironment, } from '@agent-device/test-kit'; -import { - createAppLogStartResult, - createDurableResourceEnvelope, - createAppLogLiveHandle, -} from '@agent-device/capture-kit'; +import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index c85bf8f722..ea86c5158c 100644 --- a/test/integration/provider-scenarios/ios-alert-settings.test.ts +++ b/test/integration/provider-scenarios/ios-alert-settings.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; @@ -10,11 +11,7 @@ import { } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '@agent-device/test-kit'; -import { - createAppLogStartResult, - createDurableResourceEnvelope, - createAppLogLiveHandle, -} from '@agent-device/capture-kit'; +import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { applePlugin } from '@agent-device/platform-apple'; import type { AlertRuntimeInput } from '@agent-device/contracts/alert-runtime'; From 96556c0de8d7c0fff9fd23521f4a2dee35e42a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 13:35:01 +0200 Subject: [PATCH 17/27] refactor(test-kit): build the test app-log handle without importing capture-kit Replaces the capture-kit subpath entry, which grew the declared export surface. Co-Authored-By: Claude Sonnet 5.5 --- packages/capture-kit/package.json | 4 -- .../src/device-claim-reconciliation.test.ts | 4 +- .../support/handlers/session-test-harness.ts | 5 +- .../request-runtime-binding-router.test.ts | 4 +- .../app-log-resource-recovery.test.ts | 4 +- .../app-log-session-resource.test.ts | 7 ++- .../__tests__/daemon-runtime-app-log.test.ts | 5 +- .../internal/__tests__/session-logs.test.ts | 4 +- packages/test-kit/src/app-log-live-handle.ts | 47 +++++++++++++++++++ packages/test-kit/src/index.ts | 1 + .../ios-alert-settings.test.ts | 8 ++-- 11 files changed, 68 insertions(+), 25 deletions(-) create mode 100644 packages/test-kit/src/app-log-live-handle.ts diff --git a/packages/capture-kit/package.json b/packages/capture-kit/package.json index 94d89c4bd5..ea7f7749ef 100644 --- a/packages/capture-kit/package.json +++ b/packages/capture-kit/package.json @@ -22,10 +22,6 @@ "types": "./src/capture-admission/app-log-durable-resource.ts", "default": "./src/capture-admission/app-log-durable-resource.ts" }, - "./app-log-live-handle": { - "types": "./src/app-log-live-handle.ts", - "default": "./src/app-log-live-handle.ts" - }, "./app-log-files": { "types": "./src/capture-admission/app-log-files.ts", "default": "./src/capture-admission/app-log-files.ts" diff --git a/packages/daemon-contracts/src/device-claim-reconciliation.test.ts b/packages/daemon-contracts/src/device-claim-reconciliation.test.ts index 20d16a1548..8596db4d48 100644 --- a/packages/daemon-contracts/src/device-claim-reconciliation.test.ts +++ b/packages/daemon-contracts/src/device-claim-reconciliation.test.ts @@ -1,5 +1,4 @@ import { createDeviceClaimReconciler } from './device-claim-reconciliation.ts'; -import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import { appLogResourceStore } from '@agent-device/capture-kit/app-log-durable-resource'; import fs from 'node:fs'; import path from 'node:path'; @@ -12,6 +11,7 @@ import { } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { + createTestAppLogLiveHandle, mkdtempForTestSync, unavailableDeploymentSnapshotAndShutdownOperationFacts, } from '@agent-device/test-kit'; @@ -42,7 +42,7 @@ test('reconciles the dead owner session resources through their exact runtime ow }), ); const forceCleanup = vi.fn(async () => ({ status: 'cleaned' as const })); - const handle = createAppLogLiveHandle({ + const handle = createTestAppLogLiveHandle({ inspect: () => ({ backend: 'android', state: 'recovering', startedAt: 1 }), finish: async () => ({ status: 'completed', diff --git a/packages/daemon/src/__tests__/support/handlers/session-test-harness.ts b/packages/daemon/src/__tests__/support/handlers/session-test-harness.ts index 469ca56ac1..325beec697 100644 --- a/packages/daemon/src/__tests__/support/handlers/session-test-harness.ts +++ b/packages/daemon/src/__tests__/support/handlers/session-test-harness.ts @@ -1,9 +1,8 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; -import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import { isMacOs } from '@agent-device/kernel/device'; import { legacyDispatchCapture } from '../legacy-snapshot-capture-fixture.ts'; import { expect, vi, beforeEach } from 'vitest'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { createTestAppLogLiveHandle, mkdtempForTestSync } from '@agent-device/test-kit'; import type { AppLogLiveState } from '@agent-device/contracts/app-log-runtime'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; @@ -202,7 +201,7 @@ export function makeTestAppLogResource( }, ): NonNullable { const outputPath = options.outputPath ?? '/tmp/app.log'; - const handle = createAppLogLiveHandle({ + const handle = createTestAppLogLiveHandle({ inspect: () => ({ backend: options.backend, state: options.state ?? 'active', diff --git a/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts b/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts index 3f2cce9af0..d281803aac 100644 --- a/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts +++ b/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts @@ -1,5 +1,4 @@ import fs from 'node:fs'; -import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import { expect, test, vi } from 'vitest'; import { clearRequestCanceled, @@ -16,6 +15,7 @@ import { import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { + createTestAppLogLiveHandle, unavailableDeploymentSnapshotAndShutdownOperationFacts, createTestHostEnvironment, } from '@agent-device/test-kit'; @@ -172,7 +172,7 @@ function request(positionals: string[]) { function makeGateway(disposeError?: Error) { const owner = localRuntimeOwner('android'); const forceCleanup = vi.fn(async () => ({ status: 'cleaned' as const })); - const handle = createAppLogLiveHandle({ + const handle = createTestAppLogLiveHandle({ inspect: () => ({ backend: 'android', state: 'active', startedAt: 1 }), finish: async () => ({ status: 'completed', diff --git a/packages/daemon/src/resources/__tests__/app-log-resource-recovery.test.ts b/packages/daemon/src/resources/__tests__/app-log-resource-recovery.test.ts index 9481d56489..9092621873 100644 --- a/packages/daemon/src/resources/__tests__/app-log-resource-recovery.test.ts +++ b/packages/daemon/src/resources/__tests__/app-log-resource-recovery.test.ts @@ -1,5 +1,4 @@ import { appLogResourceStore } from '@agent-device/capture-kit/app-log-durable-resource'; -import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import { recoverAppLogResourcesAfterDaemonLock } from '@agent-device/capture-kit/app-log-resource-recovery'; import fs from 'node:fs'; import path from 'node:path'; @@ -14,6 +13,7 @@ import { import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { + createTestAppLogLiveHandle, mkdtempForTestSync, unavailableDeploymentSnapshotAndShutdownOperationFacts, } from '@agent-device/test-kit'; @@ -395,7 +395,7 @@ function createHandle( status: 'cleaned', })), ) { - return createAppLogLiveHandle({ + return createTestAppLogLiveHandle({ inspect: () => ({ backend: 'android', state: 'recovering', startedAt: 1 }), finish: async () => ({ status: 'completed', diff --git a/packages/daemon/src/resources/__tests__/app-log-session-resource.test.ts b/packages/daemon/src/resources/__tests__/app-log-session-resource.test.ts index 92414e0256..2db2ebda52 100644 --- a/packages/daemon/src/resources/__tests__/app-log-session-resource.test.ts +++ b/packages/daemon/src/resources/__tests__/app-log-session-resource.test.ts @@ -1,5 +1,4 @@ import { appLogResourceStore } from '@agent-device/capture-kit/app-log-durable-resource'; -import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import fs from 'node:fs'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; @@ -11,7 +10,7 @@ import { createDurableResourceEnvelope, } from '@agent-device/capture-kit'; import { PendingTransferGuard } from '@agent-device/contracts/async-lifecycle'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { createTestAppLogLiveHandle, mkdtempForTestSync } from '@agent-device/test-kit'; import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; @@ -434,7 +433,7 @@ test.each(['rebuild', 'retire', 'replace-resource'] as const)( result: { backend: 'android' as const, outputPath: '/tmp/app.log', completedAt: 2 }, }; }); - const handle = createAppLogLiveHandle({ + const handle = createTestAppLogLiveHandle({ inspect: () => ({ backend: 'android', state: 'active', startedAt: 1 }), finish, forceCleanup: async () => ({ status: 'cleaned' }), @@ -511,7 +510,7 @@ function makeStartResult( envelopeDevice?: Parameters[0]['device'], ) { const forceCleanup = vi.fn(async () => cleanup); - const handle = createAppLogLiveHandle({ + const handle = createTestAppLogLiveHandle({ inspect: () => ({ backend: 'android', state: 'active', startedAt: 1 }), finish: async () => ({ status: 'completed', diff --git a/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts b/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts index 89e8e94e81..532ae85cde 100644 --- a/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts +++ b/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts @@ -1,5 +1,4 @@ import { appLogResourceStore } from '@agent-device/capture-kit/app-log-durable-resource'; -import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import { recoverAppLogResourcesAfterDaemonLock } from '@agent-device/capture-kit/app-log-resource-recovery'; import { platformResourceCleanupFixture } from '../../__tests__/support/platform-resource-cleanup-fixture.ts'; import fs from 'node:fs'; @@ -8,7 +7,7 @@ import { expect, test, vi } from 'vitest'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import type { ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { createTestAppLogLiveHandle, mkdtempForTestSync } from '@agent-device/test-kit'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { flushDaemonStartupDiagnostics, @@ -114,7 +113,7 @@ test('daemon shutdown settles fenced app-log cleanup before finalization can rel await cleanupReleased; return { status: 'cleaned' as const }; }); - const handle = createAppLogLiveHandle({ + const handle = createTestAppLogLiveHandle({ inspect: () => ({ backend: 'android', state: 'active', startedAt: 1 }), finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), forceCleanup, diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts index c0610f82b6..ae717ea6b0 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts @@ -1,5 +1,4 @@ import { appLogResourceStore } from '@agent-device/capture-kit/app-log-durable-resource'; -import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import { daemonPlatformServicesFixture } from '../../../../__tests__/support/platform-services-fixture.ts'; import fs from 'node:fs'; import { beforeEach, expect, test, vi } from 'vitest'; @@ -13,6 +12,7 @@ import { } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { + createTestAppLogLiveHandle, unavailableDeploymentSnapshotAndShutdownOperationFacts, createTestHostEnvironment, } from '@agent-device/test-kit'; @@ -345,7 +345,7 @@ function createRuntimeHarness(options: { inspectAvailable?: boolean } = {}) { status: 'cleaned', })); const start = vi.fn(async (input) => { - const handle = createAppLogLiveHandle({ + const handle = createTestAppLogLiveHandle({ inspect: () => ({ backend: 'ios-simulator', state: 'active', diff --git a/packages/test-kit/src/app-log-live-handle.ts b/packages/test-kit/src/app-log-live-handle.ts new file mode 100644 index 0000000000..ac614502a5 --- /dev/null +++ b/packages/test-kit/src/app-log-live-handle.ts @@ -0,0 +1,47 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { + AppLogCompletion, + AppLogLiveHandle, + AppLogLiveSnapshot, +} from '@agent-device/contracts/app-log-runtime'; +import { + isConfirmedCleanup, + type CleanupOutcome, + type FinishOutcome, +} from '@agent-device/contracts/durable-resource'; + +type AppLogLiveHandleImplementation = Readonly<{ + inspect(): AppLogLiveSnapshot; + finish(): Promise>; + forceCleanup(): Promise; +}>; + +/** + * Test-only constructor for owners that need independently controlled finish and cleanup outcomes. + * It follows the handle contract (idempotent finish and cleanup, disposal confirms cleanup) + * without importing capture-kit, whose own tests depend on this package. + */ +export function createTestAppLogLiveHandle( + implementation: AppLogLiveHandleImplementation, +): AppLogLiveHandle { + let finish: Promise> | undefined; + let cleanup: Promise | undefined; + let disposal: Promise | undefined; + const forceCleanup = (): Promise => (cleanup ??= implementation.forceCleanup()); + return Object.freeze({ + inspect: () => implementation.inspect(), + finish: () => (finish ??= implementation.finish()), + forceCleanup, + [Symbol.asyncDispose]: async () => { + disposal ??= forceCleanup().then((outcome) => { + if (isConfirmedCleanup(outcome)) return; + throw new AppError( + 'COMMAND_FAILED', + outcome.message ?? 'Durable resource cleanup could not be confirmed', + { reason: outcome.reason }, + ); + }); + await disposal; + }, + }); +} diff --git a/packages/test-kit/src/index.ts b/packages/test-kit/src/index.ts index 1c648d6da2..78dabdb0d2 100644 --- a/packages/test-kit/src/index.ts +++ b/packages/test-kit/src/index.ts @@ -3,6 +3,7 @@ // '@agent-device/test-kit', never from a file path. export { assertRejectsAppError, assertThrowsAppError } from './app-error.ts'; +export { createTestAppLogLiveHandle } from './app-log-live-handle.ts'; export { BOUNDARY_COMMAND_CLASSES, BOUNDARY_FAULT_MATRIX, diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index ea86c5158c..b56efec39e 100644 --- a/test/integration/provider-scenarios/ios-alert-settings.test.ts +++ b/test/integration/provider-scenarios/ios-alert-settings.test.ts @@ -1,5 +1,4 @@ import assert from 'node:assert/strict'; -import { createAppLogLiveHandle } from '@agent-device/capture-kit/app-log-live-handle'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; @@ -10,7 +9,10 @@ import { type RuntimeFacts, } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; -import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '@agent-device/test-kit'; +import { + createTestAppLogLiveHandle, + unavailableDeploymentSnapshotAndShutdownOperationFacts, +} from '@agent-device/test-kit'; import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { applePlugin } from '@agent-device/platform-apple'; @@ -257,7 +259,7 @@ function createRecordingPlatformRuntimeGateway(params: { }, }; })()); - const handle = createAppLogLiveHandle({ + const handle = createTestAppLogLiveHandle({ inspect: () => ({ backend: 'ios-simulator', state: 'active', From e71ad8c6741506589f536db04d73f6ae86542c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 14:08:50 +0200 Subject: [PATCH 18/27] refactor(capture-kit): take the host explicitly for Swift cache and recording tooling capture-kit no longer reads the command host, so its suites need no bound host and its local test-host copy is gone; the root recording seams supply the host. test-kit keeps depending on capture-kit again, and capture-kit does not depend on test-kit. Co-Authored-By: Claude Sonnet 5.5 --- packages/capture-kit/package.json | 1 - .../src/host-environment.fixtures.ts | 11 ++++ packages/capture-kit/src/index.ts | 2 +- .../src/recording/__tests__/overlay.test.ts | 50 +++++++++++++++---- .../recording/contact-sheet-frames.test.ts | 9 ++-- .../src/recording/contact-sheet-frames.ts | 16 ++++-- .../src/recording/contact-sheet.test.ts | 5 +- .../src/recording/contact-sheet.ts | 3 ++ packages/capture-kit/src/recording/overlay.ts | 26 +++++++--- .../src/recording/swift-cache.test.ts | 49 ++++++++++-------- .../capture-kit/src/recording/swift-cache.ts | 29 +++++++---- .../capture-kit/src/recording/video.test.ts | 23 +++++++-- packages/capture-kit/src/recording/video.ts | 14 +++--- packages/capture-kit/tsconfig.json | 7 +-- packages/test-kit/package.json | 1 + packages/test-kit/src/app-log-live-handle.ts | 35 ++----------- .../src/keyboard-occlusion.fixtures.ts | 14 ++---- packages/test-kit/tsconfig.json | 7 ++- pnpm-lock.yaml | 6 +-- .../recording/runtime/contact-sheet.test.ts | 3 ++ .../recording/runtime/contact-sheet.ts | 2 + ...m-runtime-screen-recording-android-host.ts | 4 +- ...me-screen-recording-finalizer-host.test.ts | 7 ++- ...runtime-screen-recording-finalizer-host.ts | 10 ++-- .../integration/live-device-e2e/assertions.ts | 3 +- 25 files changed, 204 insertions(+), 133 deletions(-) create mode 100644 packages/capture-kit/src/host-environment.fixtures.ts diff --git a/packages/capture-kit/package.json b/packages/capture-kit/package.json index ea7f7749ef..93b2b54e02 100644 --- a/packages/capture-kit/package.json +++ b/packages/capture-kit/package.json @@ -356,7 +356,6 @@ } }, "devDependencies": { - "@agent-device/test-kit": "workspace:*", "@types/pngjs": "^6.0.5", "fast-check": "^4.9.0", "jpeg-js": "^0.4.4", diff --git a/packages/capture-kit/src/host-environment.fixtures.ts b/packages/capture-kit/src/host-environment.fixtures.ts new file mode 100644 index 0000000000..e5240152ba --- /dev/null +++ b/packages/capture-kit/src/host-environment.fixtures.ts @@ -0,0 +1,11 @@ +import { captureHostEnvironment } from '@agent-device/host-kit/process'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; + +/** + * The host a capture-kit test hands to code that takes one explicitly: a snapshot of the process + * as it is when called, so `vi.stubEnv` made earlier in the test reaches it. Test-kit depends on + * capture-kit, so capture-kit keeps this local. + */ +export function createTestHost(): HostEnvironment { + return captureHostEnvironment(process); +} diff --git a/packages/capture-kit/src/index.ts b/packages/capture-kit/src/index.ts index 9006c680e4..5c83dd4f4e 100644 --- a/packages/capture-kit/src/index.ts +++ b/packages/capture-kit/src/index.ts @@ -7,7 +7,7 @@ export { createAppLogStartResult, decodeAppLogProcessMarker, } from './app-log-runtime.ts'; -export { createAppLogLiveHandleFromFinish } from './app-log-live-handle.ts'; +export { createAppLogLiveHandle, createAppLogLiveHandleFromFinish } from './app-log-live-handle.ts'; export { createHostAudioProbeCaptureOperations } from './audio-probe-runtime.ts'; export { decodeDurableDescriptor } from './durable-descriptor-codec.ts'; export { createScreenRecordingLiveHandle } from './screen-recording-live-handle.ts'; diff --git a/packages/capture-kit/src/recording/__tests__/overlay.test.ts b/packages/capture-kit/src/recording/__tests__/overlay.test.ts index bf94c4beda..e42b12d359 100644 --- a/packages/capture-kit/src/recording/__tests__/overlay.test.ts +++ b/packages/capture-kit/src/recording/__tests__/overlay.test.ts @@ -1,10 +1,8 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { createTestHost } from '../../host-environment.fixtures.ts'; import fs from 'node:fs'; import path from 'node:path'; import { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts'; -import { bindTestCommandHost } from '@agent-device/test-kit'; - -bindTestCommandHost(); vi.mock('@agent-device/host-kit/command', async (importOriginal) => { const fs = await import('node:fs'); @@ -66,12 +64,14 @@ test('overlay burns touches through a cached helper and same-directory temp outp fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), }); fs.writeFileSync(videoPath, 'second original'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -112,7 +112,12 @@ test('overlay preserves Swift helper compile hints', async () => { }); await expect( - overlayRecordingTouches({ videoPath, telemetryPath, deadline: startRecordingOverlayBudget() }), + overlayRecordingTouches({ + host: createTestHost(), + videoPath, + telemetryPath, + deadline: startRecordingOverlayBudget(), + }), ).rejects.toMatchObject({ code: 'COMMAND_FAILED', message: 'Failed to add touch overlays to the recording', @@ -129,6 +134,7 @@ test('overlay defaults to the fast medium export preset', async () => { fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -146,6 +152,7 @@ test('overlay forwards the requested high export preset', async () => { fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -164,6 +171,7 @@ test('overlay caps the helper frame rate at the caller fps only when one was ask fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -172,6 +180,7 @@ test('overlay caps the helper frame rate at the caller fps only when one was ask mockRunCmd.mockClear(); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -188,6 +197,7 @@ test('overlay hands the helper what is left of its budget, inside the record req fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -215,7 +225,7 @@ test('overlay hands the helper only what the finalization left of the budget', a const deadline = startRecordingOverlayBudget(); vi.setSystemTime(Date.now() + spentMs); - await overlayRecordingTouches({ videoPath, telemetryPath, deadline }); + await overlayRecordingTouches({ host: createTestHost(), videoPath, telemetryPath, deadline }); const helperCall = mockRunCmd.mock.calls.find(([cmd]) => cmd !== 'xcrun'); expect(helperCall?.[2]?.timeoutMs).toBe(OVERLAY_BUDGET_MS - spentMs); @@ -237,7 +247,12 @@ test('overlay leaves the video as recorded when compiling the helper spent its b }); await expect( - overlayRecordingTouches({ videoPath, telemetryPath, deadline: startRecordingOverlayBudget() }), + overlayRecordingTouches({ + host: createTestHost(), + videoPath, + telemetryPath, + deadline: startRecordingOverlayBudget(), + }), ).rejects.toMatchObject({ code: 'COMMAND_FAILED', message: 'Failed to add touch overlays to the recording', @@ -254,6 +269,7 @@ test('overlay names its budget when the export ran out of it', async () => { fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -265,7 +281,12 @@ test('overlay names its budget when the export ran out of it', async () => { }); await expect( - overlayRecordingTouches({ videoPath, telemetryPath, deadline: startRecordingOverlayBudget() }), + overlayRecordingTouches({ + host: createTestHost(), + videoPath, + telemetryPath, + deadline: startRecordingOverlayBudget(), + }), ).rejects.toMatchObject({ code: 'COMMAND_FAILED', message: `Failed to add touch overlays to the recording: the export did not finish within its ${OVERLAY_BUDGET_MS}ms budget; a lower --fps renders faster`, @@ -280,6 +301,7 @@ test('overlay keeps the plain failure when the export failed with budget to spar fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -289,7 +311,12 @@ test('overlay keeps the plain failure when the export failed with budget to spar }); await expect( - overlayRecordingTouches({ videoPath, telemetryPath, deadline: startRecordingOverlayBudget() }), + overlayRecordingTouches({ + host: createTestHost(), + videoPath, + telemetryPath, + deadline: startRecordingOverlayBudget(), + }), ).rejects.toMatchObject({ message: 'Failed to add touch overlays to the recording', }); @@ -307,12 +334,15 @@ test('overlay keeps the video as recorded when verifying the export spent its bu }); await expect( - overlayRecordingTouches({ videoPath, telemetryPath, deadline }), + overlayRecordingTouches({ host: createTestHost(), videoPath, telemetryPath, deadline }), ).rejects.toMatchObject({ code: 'COMMAND_FAILED', message: `Failed to add touch overlays to the recording: the export did not finish within its ${OVERLAY_BUDGET_MS}ms budget; a lower --fps renders faster`, }); - expect(vi.mocked(waitForPlayableVideo)).toHaveBeenCalledWith(expect.any(String), { deadline }); + expect(vi.mocked(waitForPlayableVideo)).toHaveBeenCalledWith(expect.any(String), { + host: expect.objectContaining({ cwd: process.cwd() }), + deadline, + }); expect(fs.readFileSync(videoPath, 'utf8')).toBe('original'); expect(fs.readdirSync(tmpDir).filter((name) => name.includes('agent-device-'))).toEqual([]); }); diff --git a/packages/capture-kit/src/recording/contact-sheet-frames.test.ts b/packages/capture-kit/src/recording/contact-sheet-frames.test.ts index c4ba002bab..113a00f345 100644 --- a/packages/capture-kit/src/recording/contact-sheet-frames.test.ts +++ b/packages/capture-kit/src/recording/contact-sheet-frames.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createTestHost } from '../host-environment.fixtures.ts'; import path from 'node:path'; import { CONTACT_SHEET_EXTRACTION_REASON, @@ -7,13 +8,10 @@ import { } from './contact-sheet-report.ts'; import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors'; import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; -import { bindTestCommandHost } from '@agent-device/test-kit'; import { solidPng } from '../png-pixels.fixtures.ts'; import { writeDecodedFrames, type StubFrame } from './contact-sheet.fixtures.ts'; import { extractRecordingFrames } from './contact-sheet-frames.ts'; -bindTestCommandHost(); - vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ ...(await importOriginal()), runCmd: vi.fn(), @@ -40,9 +38,9 @@ function reasonOf(action: () => Promise): Promise | unknown { /** Decoding is Apple tooling, so every case here states the host it is pretending to be. */ function extract( - input: Omit[0], 'hostPlatform'>, + input: Omit[0], 'hostPlatform' | 'host'>, ): Promise>> { - return extractRecordingFrames({ ...input, hostPlatform: 'darwin' }); + return extractRecordingFrames({ host: createTestHost(), ...input, hostPlatform: 'darwin' }); } function withTimes(args: readonly string[], times: string): string[] { @@ -66,6 +64,7 @@ describe('extractRecordingFrames', () => { expect( await reasonOf(() => extractRecordingFrames({ + host: createTestHost(), videoPath: VIDEO, scratchDir, maxWidth: CELL_WIDTH, diff --git a/packages/capture-kit/src/recording/contact-sheet-frames.ts b/packages/capture-kit/src/recording/contact-sheet-frames.ts index fd6f92de77..b9e9e61c0c 100644 --- a/packages/capture-kit/src/recording/contact-sheet-frames.ts +++ b/packages/capture-kit/src/recording/contact-sheet-frames.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import path from 'node:path'; import { CONTACT_SHEET_EXTRACTION_REASON, @@ -67,6 +68,7 @@ export type ExtractedRecordingFrames = Readonly<{ */ export async function extractRecordingFrames( input: Readonly<{ + host: HostEnvironment; videoPath: string; scratchDir: string; timesMs: readonly number[]; @@ -107,7 +109,7 @@ export async function extractRecordingFrames( ], { timeoutMs: EXTRACTION_TIMEOUT_MS, - env: buildSwiftToolEnv(), + env: buildSwiftToolEnv(input.host.env), allowFailure: true, ...(input.signal === undefined ? {} : { signal: input.signal }), }, @@ -260,11 +262,17 @@ function lastNonEmptyLine(text: string): string { * Compiles the decoder helper, or fails as an extraction failure: a cold Swift cache that cannot * build the helper is exactly as unable to produce a sheet as a helper that exits nonzero. */ -async function compileDecoder(input: { videoPath: string; signal?: AbortSignal }): Promise { +async function compileDecoder(input: { + host: HostEnvironment; + videoPath: string; + signal?: AbortSignal; +}): Promise { + const { cwd } = input.host; try { return await compileSwiftSourceFile({ - sourcePath: resolveRecordingScriptPath(FRAMES_SCRIPT, findProjectRoot()), - extraSourcePaths: [resolveRecordingScriptPath(SHARED_SUPPORT_SCRIPT, findProjectRoot())], + host: input.host, + sourcePath: resolveRecordingScriptPath(FRAMES_SCRIPT, findProjectRoot(), cwd), + extraSourcePaths: [resolveRecordingScriptPath(SHARED_SUPPORT_SCRIPT, findProjectRoot(), cwd)], cacheName: 'recording-frames', timeoutMs: COMPILATION_TIMEOUT_MS, }); diff --git a/packages/capture-kit/src/recording/contact-sheet.test.ts b/packages/capture-kit/src/recording/contact-sheet.test.ts index 6cdad63eba..49d3e4d7da 100644 --- a/packages/capture-kit/src/recording/contact-sheet.test.ts +++ b/packages/capture-kit/src/recording/contact-sheet.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createTestHost } from '../host-environment.fixtures.ts'; import fs from 'node:fs'; import path from 'node:path'; import { @@ -12,14 +13,11 @@ import { import { AppError } from '@agent-device/kernel/errors'; import { BLACK, RED, WHITE, paintPng, solidPng } from '../png-pixels.fixtures.ts'; import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; -import { bindTestCommandHost } from '@agent-device/test-kit'; import { decodePng } from '../png.ts'; import { mp4Atom, mp4MovieHeader } from './mp4.fixtures.ts'; import { writeDecodedFrames, type StubFrame } from './contact-sheet.fixtures.ts'; import { buildRecordingContactSheet, recordingContactSheetPath } from './contact-sheet.ts'; -bindTestCommandHost(); - vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ ...(await importOriginal()), runCmd: vi.fn(), @@ -101,6 +99,7 @@ type BuildInput = Partial< function build(videoPath: string, input: BuildInput = {}) { return buildRecordingContactSheet({ + host: createTestHost(), ...input, videoPath, maxPixels: input.maxPixels ?? 20_000_000, diff --git a/packages/capture-kit/src/recording/contact-sheet.ts b/packages/capture-kit/src/recording/contact-sheet.ts index 131f9859f2..b3f3a0fb88 100644 --- a/packages/capture-kit/src/recording/contact-sheet.ts +++ b/packages/capture-kit/src/recording/contact-sheet.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import os from 'node:os'; import path from 'node:path'; import { @@ -50,6 +51,7 @@ export type { RecordingContactSheetResult } from './contact-sheet-report.ts'; */ export async function buildRecordingContactSheet( input: Readonly<{ + host: HostEnvironment; videoPath: string; outputPath?: string; maxPixels: number; @@ -83,6 +85,7 @@ export async function buildRecordingContactSheet( const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-contact-sheet-')); try { const extracted = await extractRecordingFrames({ + host: input.host, videoPath, scratchDir, timesMs: grid.timesMs, diff --git a/packages/capture-kit/src/recording/overlay.ts b/packages/capture-kit/src/recording/overlay.ts index 7dad0d5f51..ec8cc0177e 100644 --- a/packages/capture-kit/src/recording/overlay.ts +++ b/packages/capture-kit/src/recording/overlay.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import path from 'node:path'; import { runCmd } from '@agent-device/host-kit/command'; import { Deadline } from '@agent-device/host-kit/retry'; @@ -48,33 +49,40 @@ const HELPER_EXIT_GRACE_MS = 5_000; let overlayScriptPath: string | undefined; let exportSupportScriptPath: string | undefined; -function getOverlayScriptPath(): string { - overlayScriptPath ??= resolveRecordingScriptPath('recording-overlay.swift', findProjectRoot()); +function getOverlayScriptPath(host: HostEnvironment): string { + overlayScriptPath ??= resolveRecordingScriptPath( + 'recording-overlay.swift', + findProjectRoot(), + host.cwd, + ); return overlayScriptPath; } -function getExportSupportScriptPath(): string { +function getExportSupportScriptPath(host: HostEnvironment): string { exportSupportScriptPath ??= resolveRecordingScriptPath( 'RecordingExportSupport.swift', findProjectRoot(), + host.cwd, ); return exportSupportScriptPath; } async function exportProcessedVideo(params: { + host: HostEnvironment; videoPath: string; scriptPath: string; scriptArgs: string[]; commandDescription: string; deadline: Deadline; }): Promise { - const { videoPath, scriptPath, scriptArgs, commandDescription, deadline } = params; + const { host, videoPath, scriptPath, scriptArgs, commandDescription, deadline } = params; const outputPath = temporarySiblingVideoPath(videoPath); let exportOverran = false; try { const executablePath = await compileSwiftSourceFile({ + host, sourcePath: scriptPath, - extraSourcePaths: [getExportSupportScriptPath()], + extraSourcePaths: [getExportSupportScriptPath(host)], // `runCmd` reads a timeout of 0 as none. timeoutMs: Math.max(1, deadline.remainingMs()), }); @@ -98,13 +106,13 @@ async function exportProcessedVideo(params: { '--timeout-ms', String(Math.round(exportMs)), ], - { timeoutMs: helperMs, env: buildSwiftToolEnv() }, + { timeoutMs: helperMs, env: buildSwiftToolEnv(host.env) }, ); } catch (error) { exportOverran = deadline.remainingMs() <= HELPER_EXIT_GRACE_MS; throw error; } - await waitForPlayableVideo(outputPath, { deadline }); + await waitForPlayableVideo(outputPath, { host, deadline }); if (deadline.isExpired()) { exportOverran = true; throw new AppError( @@ -148,6 +156,7 @@ function temporarySiblingVideoPath(videoPath: string): string { } export async function overlayRecordingTouches(params: { + host: HostEnvironment; videoPath: string; telemetryPath: string; exportQuality?: RecordingExportQuality; @@ -164,8 +173,9 @@ export async function overlayRecordingTouches(params: { targetLabel = 'recording', } = params; await exportProcessedVideo({ + host: params.host, videoPath, - scriptPath: getOverlayScriptPath(), + scriptPath: getOverlayScriptPath(params.host), scriptArgs: [ '--events', telemetryPath, diff --git a/packages/capture-kit/src/recording/swift-cache.test.ts b/packages/capture-kit/src/recording/swift-cache.test.ts index 9c6438df5c..5087846756 100644 --- a/packages/capture-kit/src/recording/swift-cache.test.ts +++ b/packages/capture-kit/src/recording/swift-cache.test.ts @@ -1,10 +1,8 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { createTestHost } from '../host-environment.fixtures.ts'; import fs from 'node:fs'; import path from 'node:path'; import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; -import { bindTestCommandHost } from '@agent-device/test-kit'; - -bindTestCommandHost(); vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ ...(await importOriginal()), @@ -16,7 +14,7 @@ vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ }), })); -import { runCmd, withCommandHost } from '@agent-device/host-kit/command'; +import { runCmd } from '@agent-device/host-kit/command'; import { buildRecordingScriptPathCandidates, compileSwiftSourceFile, @@ -41,6 +39,7 @@ test('compileSwiftSourceFile compiles to a unique temp executable before publish const sourcePath = writeSourceFile('print("hello")'); const executablePath = await compileSwiftSourceFile({ + host: createTestHost(), cacheName: 'recording-helper', sourcePath, }); @@ -59,10 +58,7 @@ test('concurrent file-backed helper compiles for the same cache key reuse the wi const sourcePath = writeSourceFile(); await expectConcurrentCacheReuse(() => - compileSwiftSourceFile({ - sourcePath, - cacheName: 'recording-overlay', - }), + compileSwiftSourceFile({ host: createTestHost(), sourcePath, cacheName: 'recording-overlay' }), ); }); @@ -75,16 +71,19 @@ test('extra source paths participate in the cache key and reach the compiler', a const [withSupport, withSupportAgain, withChangedSupport] = await Promise.all([ compileSwiftSourceFile({ + host: createTestHost(), sourcePath, extraSourcePaths: [supportPath], cacheName: 'recording-overlay', }), compileSwiftSourceFile({ + host: createTestHost(), sourcePath, extraSourcePaths: [supportPath], cacheName: 'recording-overlay', }), compileSwiftSourceFile({ + host: createTestHost(), sourcePath, extraSourcePaths: [changedSupportPath], cacheName: 'recording-overlay', @@ -113,6 +112,7 @@ test('cache locks with a proven dead owner are recovered before compiling', asyn await expect( compileSwiftSourceFile({ + host: createTestHost(), sourcePath, cacheName: 'recording-overlay', timeoutMs: 100, @@ -130,6 +130,7 @@ test('an ownerless cache lock is retained with verified-recovery guidance regard await expect( compileSwiftSourceFile({ + host: createTestHost(), sourcePath, cacheName: 'recording-overlay', timeoutMs: 1, @@ -159,7 +160,11 @@ test('compileSwiftSourceText resolves a cache name with a long interior dash run // call is expected to reject once it reaches disk I/O; that happens only *after* the // (now fast) sanitize step this test pins, so timing the settle either way still proves // no catastrophic backtracking occurred. - await compileSwiftSourceText({ source: 'print(1)', cacheName: value }).catch(() => {}); + await compileSwiftSourceText({ + host: createTestHost(), + source: 'print(1)', + cacheName: value, + }).catch(() => {}); const elapsedMs = Date.now() - start; expect(elapsedMs).toBeLessThan(1_000); @@ -167,6 +172,7 @@ test('compileSwiftSourceText resolves a cache name with a long interior dash run test('compileSwiftSourceText falls back to swift-helper when the cache name sanitizes to nothing', async () => { const executablePath = await compileSwiftSourceText({ + host: createTestHost(), source: 'print(1)', cacheName: '---', }); @@ -194,9 +200,9 @@ test('a compile that failed is reported over a cache lock that could not be give throw buildFailure; }); - await expect(compileSwiftSourceFile({ sourcePath, cacheName: 'recording-overlay' })).rejects.toBe( - buildFailure, - ); + await expect( + compileSwiftSourceFile({ host: createTestHost(), sourcePath, cacheName: 'recording-overlay' }), + ).rejects.toBe(buildFailure); // The release really could not verify itself: the lock is still standing. expect(fs.existsSync(lockDir)).toBe(true); }); @@ -210,6 +216,7 @@ function writeSourceFile(source = 'print("recording")'): string { async function createBlockedCacheEntry() { const sourcePath = writeSourceFile(); const executablePath = await compileSwiftSourceFile({ + host: createTestHost(), sourcePath, cacheName: 'recording-overlay', }); @@ -283,19 +290,17 @@ test('recording script candidates include packaged dist apple-runner source', () expect(firstExisting).toBe(scriptPath); }); -test('the cache root and tool environment come from the command host, not the process', async () => { +test('the cache root and tool environment come from the host it is given, not the process', async () => { const hostCacheDir = path.join(tmpDir, 'host-cache'); - const executablePath = await withCommandHost( - { - host: { - env: { AGENT_DEVICE_SWIFT_CACHE_DIR: hostCacheDir, FROM_HOST: 'yes' }, - cwd: tmpDir, - }, - requestEnv: undefined, + const executablePath = await compileSwiftSourceText({ + host: { + env: { AGENT_DEVICE_SWIFT_CACHE_DIR: hostCacheDir, FROM_HOST: 'yes' }, + cwd: tmpDir, }, - async () => await compileSwiftSourceText({ source: 'print("host")', cacheName: 'host-helper' }), - ); + source: 'print("host")', + cacheName: 'host-helper', + }); expect(path.dirname(executablePath)).toBe(path.join(hostCacheDir, 'bin')); expect(mockRunCmd.mock.calls[0]?.[2]?.env).toMatchObject({ diff --git a/packages/capture-kit/src/recording/swift-cache.ts b/packages/capture-kit/src/recording/swift-cache.ts index 8885beb46e..aeb3e43dff 100644 --- a/packages/capture-kit/src/recording/swift-cache.ts +++ b/packages/capture-kit/src/recording/swift-cache.ts @@ -5,7 +5,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { trimEdgeDashes } from '@agent-device/kernel/collections'; import { AppError } from '@agent-device/kernel/errors'; -import { commandHostEnvironment, runCmd } from '@agent-device/host-kit/command'; +import { runCmd } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { readProcessStartTime } from '@agent-device/host-kit/process'; import { acquireProcessLock, @@ -48,8 +49,8 @@ export function buildRecordingScriptPathCandidates( export function resolveRecordingScriptPath( scriptName: string, projectRoot: string, + cwd: string, moduleDir = path.dirname(fileURLToPath(import.meta.url)), - cwd = commandHostEnvironment().cwd, ): string { const scriptCandidates = buildRecordingScriptPathCandidates( scriptName, @@ -70,9 +71,7 @@ export function resolveRecordingScriptPath( }); } -export function buildSwiftToolEnv( - env: NodeJS.ProcessEnv = commandHostEnvironment().env, -): NodeJS.ProcessEnv { +export function buildSwiftToolEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const root = getSwiftCacheRoot(env); const homePath = path.join(root, 'home'); const moduleCachePath = path.join(root, 'module-cache'); @@ -86,6 +85,7 @@ export function buildSwiftToolEnv( } export async function compileSwiftSourceFile(params: { + host: HostEnvironment; sourcePath: string; /** Additional compilation units (shared helpers) compiled into the same executable. */ extraSourcePaths?: string[]; @@ -111,8 +111,13 @@ export async function compileSwiftSourceFile(params: { source, ]), ]); - const executablePath = path.join(getSwiftCacheRoot(), 'bin', `${cacheName}-${key}`); + const executablePath = path.join( + getSwiftCacheRoot(params.host.env), + 'bin', + `${cacheName}-${key}`, + ); await ensureSwiftExecutable({ + host: params.host, sourcePaths, executablePath, timeoutMs: params.timeoutMs, @@ -121,16 +126,19 @@ export async function compileSwiftSourceFile(params: { } export async function compileSwiftSourceText(params: { + host: HostEnvironment; source: string; cacheName: string; timeoutMs?: number; }): Promise { const cacheName = sanitizeCacheName(params.cacheName); const key = hashParts([SWIFT_CACHE_VERSION, process.platform, process.arch, params.source]); - const sourcePath = path.join(getSwiftCacheRoot(), 'sources', `${cacheName}-${key}.swift`); - const executablePath = path.join(getSwiftCacheRoot(), 'bin', `${cacheName}-${key}`); + const cacheRoot = getSwiftCacheRoot(params.host.env); + const sourcePath = path.join(cacheRoot, 'sources', `${cacheName}-${key}.swift`); + const executablePath = path.join(cacheRoot, 'bin', `${cacheName}-${key}`); await ensureSwiftExecutable({ + host: params.host, sourcePaths: [sourcePath], executablePath, sourceText: params.source, @@ -139,7 +147,7 @@ export async function compileSwiftSourceText(params: { return executablePath; } -function getSwiftCacheRoot(env: NodeJS.ProcessEnv = commandHostEnvironment().env): string { +function getSwiftCacheRoot(env: NodeJS.ProcessEnv): string { const configured = env.AGENT_DEVICE_SWIFT_CACHE_DIR?.trim(); if (configured) { return path.resolve(configured); @@ -148,6 +156,7 @@ function getSwiftCacheRoot(env: NodeJS.ProcessEnv = commandHostEnvironment().env } async function ensureSwiftExecutable(params: { + host: HostEnvironment; sourcePaths: string[]; executablePath: string; sourceText?: string; @@ -183,7 +192,7 @@ async function ensureSwiftExecutable(params: { } await runCmd('xcrun', ['swiftc', ...params.sourcePaths, '-o', tempExecutablePath], { timeoutMs, - env: buildSwiftToolEnv(), + env: buildSwiftToolEnv(params.host.env), }); fs.renameSync(tempExecutablePath, params.executablePath); } finally { diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index c91904f010..653b0a2223 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { createTestHost } from '../host-environment.fixtures.ts'; import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import { runCmd } from '@agent-device/host-kit/command'; @@ -24,11 +25,15 @@ const directory = mkdtempForTestSync('agent-device-video-webm-'); const playableWebm = likelyPlayableWebmContainer(); test('accepts a real WebM video track and complete media block without AVFoundation', async () => { - await expect(isPlayableVideo(writeFixture('capture.webm', playableWebm))).resolves.toBe(true); + await expect( + isPlayableVideo(writeFixture('capture.webm', playableWebm), createTestHost()), + ).resolves.toBe(true); }); test('does not infer WebM from bytes when the requested container is different', async () => { - await expect(isPlayableVideo(writeFixture('capture.bin', playableWebm))).resolves.toBe(false); + await expect( + isPlayableVideo(writeFixture('capture.bin', playableWebm), createTestHost()), + ).resolves.toBe(false); }); function writeFixture(name: string, bytes: Buffer): string { @@ -45,13 +50,17 @@ const moov = mp4Atom( test('reaches the semantic validator with an MP4 container whose last box runs past the file', async () => { const truncated = Buffer.concat([ftyp, moov]).subarray(0, ftyp.length + moov.length - 4); - await expect(isPlayableVideo(writeFixture('truncated.mp4', truncated))).resolves.toBe(true); + await expect( + isPlayableVideo(writeFixture('truncated.mp4', truncated), createTestHost()), + ).resolves.toBe(true); expect(runCmd).toHaveBeenCalledTimes(1); }); test('refuses an MP4 container that never declares a movie header', async () => { const container = Buffer.concat([ftyp, mp4Atom('mdat', Buffer.alloc(8))]); - await expect(isPlayableVideo(writeFixture('no-moov.mp4', container))).resolves.toBe(false); + await expect( + isPlayableVideo(writeFixture('no-moov.mp4', container), createTestHost()), + ).resolves.toBe(false); expect(runCmd).not.toHaveBeenCalled(); }); @@ -59,6 +68,10 @@ test('waiting for a playable video stops polling once its deadline passes', asyn const filePath = path.join(mkdtempForTestSync('agent-device-video-wait-'), 'never-playable.mp4'); fs.writeFileSync(filePath, ''); const started = Date.now(); - await waitForPlayableVideo(filePath, { pollMs: 60_000, deadline: Deadline.fromTimeoutMs(0) }); + await waitForPlayableVideo(filePath, { + host: createTestHost(), + pollMs: 60_000, + deadline: Deadline.fromTimeoutMs(0), + }); expect(Date.now() - started).toBeLessThan(1_000); }); diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index c6284eb1f6..b83d4e5a1f 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -3,6 +3,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { runCmd } from '@agent-device/host-kit/command'; import { type Deadline, sleep } from '@agent-device/host-kit/retry'; import { buildSwiftToolEnv, compileSwiftSourceText } from './swift-cache.ts'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { hasPlayableWebmStructure } from './video-webm.ts'; @@ -67,18 +68,18 @@ export async function waitForStableFile( } } -export async function isPlayableVideo(filePath: string): Promise { +export async function isPlayableVideo(filePath: string, host: HostEnvironment): Promise { const container = await readVideoContainerKind(filePath); if (!container) return false; // AVFoundation is the MP4 semantic validator. It does not reliably load WebM on supported // macOS hosts, so WebM completion is established by its EBML document type + Segment marker. if (container === 'webm') return true; try { - const validatorPath = await getVideoValidatorExecutablePath(); + const validatorPath = await getVideoValidatorExecutablePath(host); const result = await runCmd(validatorPath, [filePath], { allowFailure: true, timeoutMs: 10_000, - env: buildSwiftToolEnv(), + env: buildSwiftToolEnv(host.env), }); if (result.exitCode === 0) { return true; @@ -92,8 +93,9 @@ export async function isPlayableVideo(filePath: string): Promise { } } -async function getVideoValidatorExecutablePath(): Promise { +async function getVideoValidatorExecutablePath(host: HostEnvironment): Promise { videoValidatorExecutablePathPromise ??= compileSwiftSourceText({ + host, source: VIDEO_VALIDATION_SCRIPT, cacheName: 'video-validator', timeoutMs: 30_000, @@ -122,13 +124,13 @@ function isSwiftVideoValidatorError(error: unknown): boolean { /** Polls until the file plays, for `attempts` polls or until `deadline` passes, whichever is first. */ export async function waitForPlayableVideo( filePath: string, - options: { pollMs?: number; attempts?: number; deadline?: Deadline } = {}, + options: { host: HostEnvironment; pollMs?: number; attempts?: number; deadline?: Deadline }, ): Promise { const pollMs = options.pollMs ?? 150; const attempts = options.attempts ?? 12; for (let attempt = 0; attempt < attempts; attempt += 1) { - if (await isPlayableVideo(filePath)) { + if (await isPlayableVideo(filePath, options.host)) { return; } if (options.deadline?.isExpired()) { diff --git a/packages/capture-kit/tsconfig.json b/packages/capture-kit/tsconfig.json index 380be0b4f5..247a915b90 100644 --- a/packages/capture-kit/tsconfig.json +++ b/packages/capture-kit/tsconfig.json @@ -10,10 +10,5 @@ "tsBuildInfoFile": "./dist-types/index.tsbuildinfo" }, "include": ["src"], - "references": [ - { "path": "../contracts" }, - { "path": "../host-kit" }, - { "path": "../kernel" }, - { "path": "../test-kit" } - ] + "references": [{ "path": "../contracts" }, { "path": "../host-kit" }, { "path": "../kernel" }] } diff --git a/packages/test-kit/package.json b/packages/test-kit/package.json index 6c709f4a8d..3d65015612 100644 --- a/packages/test-kit/package.json +++ b/packages/test-kit/package.json @@ -5,6 +5,7 @@ "type": "module", "description": "Private shared test helpers (temp dirs, device/session/snapshot fixtures, mocked processes, loopback servers) for the root and workspace-package test trees. One entry, named exports; test-only, never bundled, never importing root `src/`, `platform-*`, `provider-*` or the daemon.", "dependencies": { + "@agent-device/capture-kit": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/host-kit": "workspace:*", "@agent-device/kernel": "workspace:*" diff --git a/packages/test-kit/src/app-log-live-handle.ts b/packages/test-kit/src/app-log-live-handle.ts index ac614502a5..68bc7da4bb 100644 --- a/packages/test-kit/src/app-log-live-handle.ts +++ b/packages/test-kit/src/app-log-live-handle.ts @@ -1,14 +1,10 @@ -import { AppError } from '@agent-device/kernel/errors'; import type { AppLogCompletion, AppLogLiveHandle, AppLogLiveSnapshot, } from '@agent-device/contracts/app-log-runtime'; -import { - isConfirmedCleanup, - type CleanupOutcome, - type FinishOutcome, -} from '@agent-device/contracts/durable-resource'; +import type { CleanupOutcome, FinishOutcome } from '@agent-device/contracts/durable-resource'; +import { createAppLogLiveHandle } from '@agent-device/capture-kit'; type AppLogLiveHandleImplementation = Readonly<{ inspect(): AppLogLiveSnapshot; @@ -16,32 +12,9 @@ type AppLogLiveHandleImplementation = Readonly<{ forceCleanup(): Promise; }>; -/** - * Test-only constructor for owners that need independently controlled finish and cleanup outcomes. - * It follows the handle contract (idempotent finish and cleanup, disposal confirms cleanup) - * without importing capture-kit, whose own tests depend on this package. - */ +/** Test-only constructor for owners that need independently controlled finish and cleanup outcomes. */ export function createTestAppLogLiveHandle( implementation: AppLogLiveHandleImplementation, ): AppLogLiveHandle { - let finish: Promise> | undefined; - let cleanup: Promise | undefined; - let disposal: Promise | undefined; - const forceCleanup = (): Promise => (cleanup ??= implementation.forceCleanup()); - return Object.freeze({ - inspect: () => implementation.inspect(), - finish: () => (finish ??= implementation.finish()), - forceCleanup, - [Symbol.asyncDispose]: async () => { - disposal ??= forceCleanup().then((outcome) => { - if (isConfirmedCleanup(outcome)) return; - throw new AppError( - 'COMMAND_FAILED', - outcome.message ?? 'Durable resource cleanup could not be confirmed', - { reason: outcome.reason }, - ); - }); - await disposal; - }, - }); + return createAppLogLiveHandle(implementation); } diff --git a/packages/test-kit/src/keyboard-occlusion.fixtures.ts b/packages/test-kit/src/keyboard-occlusion.fixtures.ts index c7a62e8d55..9b2cf6e741 100644 --- a/packages/test-kit/src/keyboard-occlusion.fixtures.ts +++ b/packages/test-kit/src/keyboard-occlusion.fixtures.ts @@ -1,9 +1,5 @@ -import { - attachRefs, - type RawSnapshotNode, - type Rect, - type SnapshotState, -} from '@agent-device/kernel/snapshot'; +import { makeSnapshotState } from '@agent-device/capture-kit/snapshot-state-fixtures'; +import type { Rect, SnapshotState } from '@agent-device/kernel/snapshot'; /** The #2589 capture's app-owned tab bar, and the band the captured keyboard docks to. */ export const KEYBOARD_COVERED_TAB_BAR_RECT: Rect = { x: 148, y: 791, width: 104, height: 83 }; @@ -37,7 +33,7 @@ export function keyboardCoveredTabBarSnapshot( } function keyboardCoveredTabBarTree(): SnapshotState { - return keyboardSnapshotState([ + return makeSnapshotState([ { index: 0, depth: 0, @@ -100,7 +96,3 @@ function keyboardCoveredTabBarTree(): SnapshotState { }, ]); } - -function keyboardSnapshotState(raw: RawSnapshotNode[]): SnapshotState { - return { nodes: attachRefs(raw), createdAt: Date.now() }; -} diff --git a/packages/test-kit/tsconfig.json b/packages/test-kit/tsconfig.json index 247a915b90..3431c632a3 100644 --- a/packages/test-kit/tsconfig.json +++ b/packages/test-kit/tsconfig.json @@ -10,5 +10,10 @@ "tsBuildInfoFile": "./dist-types/index.tsbuildinfo" }, "include": ["src"], - "references": [{ "path": "../contracts" }, { "path": "../host-kit" }, { "path": "../kernel" }] + "references": [ + { "path": "../capture-kit" }, + { "path": "../contracts" }, + { "path": "../host-kit" }, + { "path": "../kernel" } + ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9780173f06..0cf558c119 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -257,9 +257,6 @@ importers: specifier: workspace:* version: link:../kernel devDependencies: - '@agent-device/test-kit': - specifier: workspace:* - version: link:../test-kit '@types/pngjs': specifier: ^6.0.5 version: 6.0.5 @@ -840,6 +837,9 @@ importers: packages/test-kit: dependencies: + '@agent-device/capture-kit': + specifier: workspace:* + version: link:../capture-kit '@agent-device/contracts': specifier: workspace:* version: link:../contracts diff --git a/src/commands/recording/runtime/contact-sheet.test.ts b/src/commands/recording/runtime/contact-sheet.test.ts index 67bfc2b48b..c3bb39dbc7 100644 --- a/src/commands/recording/runtime/contact-sheet.test.ts +++ b/src/commands/recording/runtime/contact-sheet.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import { beforeEach, test, vi } from 'vitest'; import { buildRecordingContactSheet, @@ -15,6 +16,8 @@ import { restrictedCommandPolicy, } from '@agent-device/contracts/command-policy'; +bindTestCommandHost(); + vi.mock('@agent-device/capture-kit/recording-contact-sheet', async (importOriginal) => ({ ...(await importOriginal()), buildRecordingContactSheet: vi.fn(), diff --git a/src/commands/recording/runtime/contact-sheet.ts b/src/commands/recording/runtime/contact-sheet.ts index d11a28433d..826036fc72 100644 --- a/src/commands/recording/runtime/contact-sheet.ts +++ b/src/commands/recording/runtime/contact-sheet.ts @@ -5,6 +5,7 @@ import { recordingContactSheetPath, type RecordingContactSheetResult, } from '@agent-device/capture-kit/recording-contact-sheet'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { AppError } from '@agent-device/kernel/errors'; import type { ArtifactDescriptor, @@ -59,6 +60,7 @@ export const contactSheetCommand: RuntimeCommand< artifactType: 'screen-recording-contact-sheet', }); const sheet = await buildRecordingContactSheet({ + host: commandHostEnvironment(), videoPath: video.path, outputPath: output.path, maxPixels: runtime.policy.maxImagePixels, diff --git a/src/platform-runtime-screen-recording-android-host.ts b/src/platform-runtime-screen-recording-android-host.ts index 7da8f53699..e6ae77c57f 100644 --- a/src/platform-runtime-screen-recording-android-host.ts +++ b/src/platform-runtime-screen-recording-android-host.ts @@ -6,6 +6,7 @@ import type { } from '@agent-device/contracts/screen-recording-runtime-host'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { type ShellWord, shellFragment, shellQuote } from '@agent-device/kernel/device-shell'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { isPlayableVideo } from '@agent-device/capture-kit/recording-video'; import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; @@ -107,7 +108,8 @@ export async function createAndroidScreenRecordingTransport( }); return { ...result, - playable: result.exitCode === 0 && (await isPlayableVideo(outputPath)), + playable: + result.exitCode === 0 && (await isPlayableVideo(outputPath, commandHostEnvironment())), }; }, remove: async (remotePath, signal) => diff --git a/src/platform-runtime-screen-recording-finalizer-host.test.ts b/src/platform-runtime-screen-recording-finalizer-host.test.ts index 040749d6a6..47f2656123 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.test.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.test.ts @@ -1,4 +1,5 @@ import { beforeEach, expect, test, vi } from 'vitest'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import { OVERLAY_BUDGET_MS, type overlayRecordingTouches, @@ -13,6 +14,8 @@ const video = vi.hoisted(() => ({ })); const telemetry = vi.hoisted(() => vi.fn(() => '/tmp/capture.telemetry.json')); const overlay = vi.hoisted(() => vi.fn(async () => {})); +bindTestCommandHost(); + vi.mock('@agent-device/capture-kit/recording-video', () => ({ waitForStableFile: video.stable, waitForPlayableVideo: video.playable, @@ -48,7 +51,9 @@ test('requires stable playable media before publishing finalization telemetry', }); expect(video.stable).toHaveBeenCalledWith('/tmp/capture.mp4'); - expect(video.playable).toHaveBeenCalledWith('/tmp/capture.mp4'); + expect(video.playable).toHaveBeenCalledWith('/tmp/capture.mp4', { + host: expect.objectContaining({ cwd: process.cwd() }), + }); expect(result).toEqual({ telemetryPath: '/tmp/capture.telemetry.json' }); }); diff --git a/src/platform-runtime-screen-recording-finalizer-host.ts b/src/platform-runtime-screen-recording-finalizer-host.ts index d183e13a99..287fe6e7a6 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.ts @@ -1,6 +1,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { RECORDING_OUTPUT_UNPLAYABLE_REASON } from '@agent-device/contracts/screen-recording-runtime'; import type { ScreenRecordingRuntimeHost } from '@agent-device/contracts/screen-recording-runtime-host'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { Deadline } from '@agent-device/host-kit/retry'; import { getRecordingOverlaySupportWarning, @@ -28,8 +29,9 @@ async function finalizeScreenRecording( ) { const overlayDeadline = startRecordingOverlayBudget(); await waitForStableFile(input.outputPath); - await waitForPlayableVideo(input.outputPath); - if (!(await isPlayableVideo(input.outputPath))) throw unplayableRecording(input.outputPath); + const host = commandHostEnvironment(); + await waitForPlayableVideo(input.outputPath, { host }); + if (!(await isPlayableVideo(input.outputPath, host))) throw unplayableRecording(input.outputPath); const telemetryPath = persistRecordingTelemetry({ recording: { outPath: input.outputPath, gestureEvents: [...input.gestureEvents] }, }); @@ -45,7 +47,9 @@ async function overlayTouches( const warning = getRecordingOverlaySupportWarning(); if (warning) return { telemetryPath, overlayWarning: warning }; try { + const host = commandHostEnvironment(); await overlayRecordingTouches({ + host, videoPath: input.outputPath, telemetryPath, exportQuality: input.exportQuality, @@ -53,7 +57,7 @@ async function overlayTouches( targetLabel: input.targetLabel, deadline, }); - if (!(await isPlayableVideo(input.outputPath))) { + if (!(await isPlayableVideo(input.outputPath, host))) { throw new Error('recording post-processing produced an unplayable video'); } return { telemetryPath }; diff --git a/test/integration/live-device-e2e/assertions.ts b/test/integration/live-device-e2e/assertions.ts index 86cc006967..fcba5ddba5 100644 --- a/test/integration/live-device-e2e/assertions.ts +++ b/test/integration/live-device-e2e/assertions.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import { assertPngFile } from '../provider-scenarios/assertions.ts'; import { isPlayableVideo } from '@agent-device/capture-kit/recording-video'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import type { CliJsonResult } from '../cli-json.ts'; import type { LiveDeviceContext } from './runtime.ts'; @@ -88,7 +89,7 @@ export function assertNonEmptyFile(filePath: string, name: string): void { export async function assertMp4File(filePath: string): Promise { assertNonEmptyFile(filePath, 'recording'); assert.equal( - await isPlayableVideo(filePath), + await isPlayableVideo(filePath, captureHostEnvironment(process)), true, `recording is not a finalized playable video: ${filePath}`, ); From 8752e8cf07057973820415a4f1d7f030f3d67350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 14:42:10 +0200 Subject: [PATCH 19/27] fix(capture-kit): report a worker that dies under a request in that request Co-Authored-By: Claude Sonnet 5.5 --- .../src/png-worker-client-unavailable.test.ts | 42 +++++++++++++++++++ packages/capture-kit/src/png-worker-client.ts | 6 ++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/capture-kit/src/png-worker-client-unavailable.test.ts b/packages/capture-kit/src/png-worker-client-unavailable.test.ts index bb73fdb2fd..d401d65025 100644 --- a/packages/capture-kit/src/png-worker-client-unavailable.test.ts +++ b/packages/capture-kit/src/png-worker-client-unavailable.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events'; import { expect, test, vi } from 'vitest'; import { PNG } from './png.ts'; @@ -28,3 +29,44 @@ test('an unavailable worker is reported by the first diagnostics scope that reac expect(laterScope).toBe(0); vi.doUnmock('./internal-entry.ts'); }); + +test('a worker that dies under a request is reported in that request, not only the next one', async () => { + vi.resetModules(); + class FakeWorker extends EventEmitter { + static last: FakeWorker | undefined; + constructor() { + super(); + FakeWorker.last = this; + } + postMessage(): void {} + ref(): void {} + unref(): void {} + terminate(): Promise { + return Promise.resolve(0); + } + } + vi.doMock('node:worker_threads', async (importOriginal) => ({ + ...(await importOriginal()), + Worker: FakeWorker, + })); + const { countDiagnosticEventsByPhase, withDiagnosticsScope } = + await import('@agent-device/host-kit/diagnostics'); + const { decodePngAsync, prewarmPngWorker } = await import('./png-worker-client.ts'); + const png = PNG.sync.write(new PNG({ width: 1, height: 1 })); + prewarmPngWorker(); + + let jobStarted!: () => void; + const started = new Promise((resolve) => (jobStarted = resolve)); + const scoped = withDiagnosticsScope({ command: 'screenshot' }, async () => { + const decoded = decodePngAsync(png, 'under-request'); + jobStarted(); + await decoded; + return countDiagnosticEventsByPhase(['png_worker_unavailable']); + }); + await started; + // The worker's own error event carries no diagnostics scope. + FakeWorker.last?.emit('error', new Error('worker crashed')); + + expect(await scoped).toBe(1); + vi.doUnmock('node:worker_threads'); +}); diff --git a/packages/capture-kit/src/png-worker-client.ts b/packages/capture-kit/src/png-worker-client.ts index f3949aa7d8..07745995a3 100644 --- a/packages/capture-kit/src/png-worker-client.ts +++ b/packages/capture-kit/src/png-worker-client.ts @@ -191,7 +191,11 @@ async function runPngJob( try { return await runWorkerJob(job); } catch (error) { - if (error instanceof PngWorkerUnavailableError) return await runSync(); + if (error instanceof PngWorkerUnavailableError) { + // A worker can fail while this request waits on it, from an event callback with no scope. + reportWorkerUnavailable(); + return await runSync(); + } throw error; } } From ca4958595072dddbfa265e3cdec60936c048007d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 14:49:43 +0200 Subject: [PATCH 20/27] refactor(platform-android): take the install environment from the caller The Android adb host port's environment is bound at module load, so a host whose environment differs from the process's got the process's proxy settings on a network install. Co-Authored-By: Claude Sonnet 5.5 --- .../install-artifact-cancellation.test.ts | 17 ++-- .../src/install-artifact.test.ts | 11 ++- .../platform-android/src/install-artifact.ts | 16 ++-- src/__tests__/install-source.test.ts | 77 ++++++++++++------- ...orm-runtime-android-deployment-executor.ts | 6 +- 5 files changed, 83 insertions(+), 44 deletions(-) diff --git a/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts b/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts index 81cbc87570..508c8aa881 100644 --- a/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts +++ b/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts @@ -31,7 +31,7 @@ test('aborts an in-flight Android artifact identity probe', async () => { try { const pending = prepareAndroidInstallArtifact( { kind: 'path', path: archivePath }, - { signal: controller.signal }, + { environment: process.env, signal: controller.signal }, ); await vi.waitFor(() => assert.equal(probe.mock.calls.length, 1)); controller.abort(new DOMException('cancelled', 'AbortError')); @@ -65,12 +65,19 @@ test('cleans URL materialization when identity inspection fails', async () => { try { await assert.rejects( () => - prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://example.com/app.apk', - }), + prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'https://example.com/app.apk', + }, + { environment: { https_proxy: 'http://host-proxy' } }, + ), /identity failed/, ); + // The download follows the environment the caller passed, not the adb host's. + assert.deepEqual(requestSpy.mock.calls[0]?.[0].environment, { + https_proxy: 'http://host-proxy', + }); assert.deepEqual(await fs.readdir(tempRoot), []); } finally { manifestSpy.mockRestore(); diff --git a/packages/platform-android/src/install-artifact.test.ts b/packages/platform-android/src/install-artifact.test.ts index 13a1bc283a..11c8a0d197 100644 --- a/packages/platform-android/src/install-artifact.test.ts +++ b/packages/platform-android/src/install-artifact.test.ts @@ -16,10 +16,13 @@ test('installing an already-installed APK resolves its package with no aapt reac // after — the diff resolves nothing — and no adb call may run. The closest negative — the same // install without the artifact identity — lives in deployment/native.test.ts. bindAndroidAdbHostStub(); - const artifact = await prepareAndroidInstallArtifact({ - kind: 'path', - path: ANDROID_MANIFEST_APK_FIXTURE_PATH, - }); + const artifact = await prepareAndroidInstallArtifact( + { + kind: 'path', + path: ANDROID_MANIFEST_APK_FIXTURE_PATH, + }, + { environment: process.env }, + ); try { assert.equal(artifact.packageName, ANDROID_MANIFEST_FIXTURE_PACKAGE); const adbCalls: string[][] = []; diff --git a/packages/platform-android/src/install-artifact.ts b/packages/platform-android/src/install-artifact.ts index 9f2d6198b7..ac1baf7ff7 100644 --- a/packages/platform-android/src/install-artifact.ts +++ b/packages/platform-android/src/install-artifact.ts @@ -1,7 +1,6 @@ import path from 'node:path'; import type { LocalInstallSource } from '@agent-device/kernel/contracts'; import { materializeInstallablePath } from '@agent-device/provision-kit/install-source'; -import { requireAndroidAdbHost } from './adb-host.ts'; import * as manifest from './manifest.ts'; export type PreparedAndroidInstallArtifact = { @@ -13,21 +12,26 @@ export type PreparedAndroidInstallArtifact = { export async function prepareAndroidInstallArtifact( source: LocalInstallSource, - options?: { signal?: AbortSignal; resolveIdentity?: boolean }, + options: { + signal?: AbortSignal; + resolveIdentity?: boolean; + /** The host environment whose proxy settings apply to a network download. */ + environment: NodeJS.ProcessEnv; + }, ): Promise { const materialized = await materializeInstallablePath({ source, isInstallablePath: (candidatePath, stat) => stat.isFile() && isAndroidInstallablePath(candidatePath), installableLabel: 'Android installable (.apk or .aab)', - signal: options?.signal, - environment: requireAndroidAdbHost().environment, + signal: options.signal, + environment: options.environment, }); try { const identity = - options?.resolveIdentity === false + options.resolveIdentity === false ? {} - : await inspectAndroidArtifactIdentity(materialized.installablePath, options?.signal); + : await inspectAndroidArtifactIdentity(materialized.installablePath, options.signal); return { archivePath: materialized.archivePath, installablePath: materialized.installablePath, diff --git a/src/__tests__/install-source.test.ts b/src/__tests__/install-source.test.ts index b6c38e4815..0dae54f920 100644 --- a/src/__tests__/install-source.test.ts +++ b/src/__tests__/install-source.test.ts @@ -237,10 +237,13 @@ test(ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE.testName, async () => { await withMockedInstallSourceFetch( apkBytes, async () => { - const result = await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://example.com/app.apk', - }); + const result = await prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'https://example.com/app.apk', + }, + { environment: process.env }, + ); try { assert.equal(result.packageName, 'io.example.directurl'); @@ -258,10 +261,13 @@ test(ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE.testName, async () => { test('prepareAndroidInstallArtifact rejects private direct APK URL sources', async () => { await assert.rejects( async () => - await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'http://127.0.0.1/app.apk', - }), + await prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'http://127.0.0.1/app.apk', + }, + { environment: process.env }, + ), /not allowed|private or loopback/i, ); }); @@ -284,10 +290,13 @@ test('prepareAndroidInstallArtifact accepts direct AAB URL sources', async () => await withMockedInstallSourceFetch( aabBytes, async () => { - const result = await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://example.com/app.aab', - }); + const result = await prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'https://example.com/app.aab', + }, + { environment: process.env }, + ); try { assert.equal(result.packageName, 'io.example.directaab'); @@ -320,10 +329,13 @@ test('prepareAndroidInstallArtifact extracts GitHub artifact ZIP containing one }, async () => { await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { - const result = await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/123/zip', - }); + const result = await prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/123/zip', + }, + { environment: process.env }, + ); try { assert.equal(path.basename(result.installablePath), 'app.apk'); @@ -354,10 +366,13 @@ test('prepareAndroidInstallArtifact extracts GitHub artifact ZIP containing one }, async () => { await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { - const result = await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/456/zip', - }); + const result = await prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/456/zip', + }, + { environment: process.env }, + ); try { assert.equal(path.basename(result.installablePath), 'app.aab'); @@ -545,10 +560,13 @@ test('prepareAndroidInstallArtifact rejects URL archives with multiple installab await withMockedInstallSourceFetch(await fs.readFile(archivePath), async () => { await assert.rejects( async () => - await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/654/zip', - }), + await prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/654/zip', + }, + { environment: process.env }, + ), /multiple Android installable/i, ); }); @@ -573,10 +591,13 @@ test('prepareAndroidInstallArtifact extracts a URL archive from any public host' }, async () => { await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { - const result = await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://artifacts.example.com/signed/build.zip?sig=abc', - }); + const result = await prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'https://artifacts.example.com/signed/build.zip?sig=abc', + }, + { environment: process.env }, + ); try { assert.equal(path.basename(result.installablePath), 'app.apk'); diff --git a/src/platform-runtime-android-deployment-executor.ts b/src/platform-runtime-android-deployment-executor.ts index 87720ebe90..6e9eb128f8 100644 --- a/src/platform-runtime-android-deployment-executor.ts +++ b/src/platform-runtime-android-deployment-executor.ts @@ -3,6 +3,7 @@ import type { AndroidAppDeploymentExecutor, MaterializeAppSourceInput, } from '@agent-device/contracts/app-deployment-runtime'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; /** Android deployment adapter; native command construction remains package-owned. */ @@ -17,7 +18,10 @@ export function createAndroidAppDeploymentExecutor( }, prepareArtifact: async (input: MaterializeAppSourceInput, options) => { const { prepareAndroidInstallArtifact } = await loadAndroidMechanics(); - return await prepareAndroidInstallArtifact(input.source, options); + return await prepareAndroidInstallArtifact(input.source, { + ...options, + environment: commandHostEnvironment().env, + }); }, resolveAppPackage: async (device, app, options) => { const { resolveAndroidApp } = await loadAndroidMechanics(); From b647f9c84df9879c486d99e88c5d019e152bd7e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 14:57:39 +0200 Subject: [PATCH 21/27] test(platform-apple): pin the detached snapshot bridge's starting directory Co-Authored-By: Claude Sonnet 5.5 --- .../src/snapshot-source/host.test.ts | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/platform-apple/src/snapshot-source/host.test.ts b/packages/platform-apple/src/snapshot-source/host.test.ts index 1302037d87..8461b0e4c2 100644 --- a/packages/platform-apple/src/snapshot-source/host.test.ts +++ b/packages/platform-apple/src/snapshot-source/host.test.ts @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import path from 'node:path'; import { test, vi } from 'vitest'; -import { runCmdBackground } from '@agent-device/host-kit/command'; -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { runCmdBackground, withCommandHost } from '@agent-device/host-kit/command'; +import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; import { simulatorAddressFor } from '../core/simctl.ts'; import { createSnapshotSourceHost, snapshotSourceSocketPath } from './host.ts'; @@ -74,3 +76,39 @@ test.each([ ], ]); }); + +test('the detached bridge starts in the host directory, not the process directory', async () => { + const root = mkdtempForTestSync('snapshot-bridge-cwd-'); + const bin = path.join(root, 'bin'); + const hostCwd = path.join(root, 'host-cwd'); + const evidence = path.join(root, 'evidence.txt'); + fs.mkdirSync(bin); + fs.mkdirSync(hostCwd); + fs.writeFileSync(path.join(bin, 'xcrun'), '#!/bin/sh\nprintf "%s\\n" "$PWD" > "$EVIDENCE"\n', { + mode: 0o755, + }); + const actual = await vi.importActual( + '@agent-device/host-kit/command', + ); + vi.mocked(runCmdBackground).mockReset().mockImplementation(actual.runCmdBackground); + + await withCommandHost( + { host: { env: { PATH: bin, EVIDENCE: evidence }, cwd: hostCwd }, requestEnv: undefined }, + async () => { + createSnapshotSourceHost().start( + simulatorAddressFor({ + platform: 'apple', + id: 'simulator-1', + name: 'iPhone 17', + kind: 'simulator', + target: 'mobile', + }), + '/tmp/bridge', + '/tmp/bridge.sock', + ); + }, + ); + + await vi.waitFor(() => assert.equal(fs.existsSync(evidence), true)); + assert.equal(fs.readFileSync(evidence, 'utf8'), `${fs.realpathSync(hostCwd)}\n`); +}); From 1c61b40313b8cfe7c58b18d8494588ef03655344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 15:10:32 +0200 Subject: [PATCH 22/27] test(capture-kit): fail when the pre-warm starts no worker to kill Co-Authored-By: Claude Sonnet 5.5 --- .../capture-kit/src/png-worker-client-unavailable.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/capture-kit/src/png-worker-client-unavailable.test.ts b/packages/capture-kit/src/png-worker-client-unavailable.test.ts index d401d65025..565f892f04 100644 --- a/packages/capture-kit/src/png-worker-client-unavailable.test.ts +++ b/packages/capture-kit/src/png-worker-client-unavailable.test.ts @@ -65,7 +65,9 @@ test('a worker that dies under a request is reported in that request, not only t }); await started; // The worker's own error event carries no diagnostics scope. - FakeWorker.last?.emit('error', new Error('worker crashed')); + const worker = FakeWorker.last; + if (!worker) throw new Error('the pre-warm did not start a worker'); + worker.emit('error', new Error('worker crashed')); expect(await scoped).toBe(1); vi.doUnmock('node:worker_threads'); From 3f3570717156879ca6a1ec0c00ea78b37f5c0091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 16:15:37 +0200 Subject: [PATCH 23/27] test(host): keep the provider scenario host's Swift cache directory for scenarios that set it on the process Co-Authored-By: Claude Sonnet 5.5 --- test/integration/provider-scenarios/harness.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index e9650ca16a..b33436de99 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -112,6 +112,8 @@ export async function createProviderScenarioHarness( PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, + // A scenario that isolates the Swift helper cache names it through the process. + AGENT_DEVICE_SWIFT_CACHE_DIR: process.env.AGENT_DEVICE_SWIFT_CACHE_DIR, // A scenario that seeds claims for its daemon names the directory through the process. AGENT_DEVICE_CLAIMS_DIR: process.env.AGENT_DEVICE_CLAIMS_DIR ?? path.join(testRoot, 'claims'), AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '3600000', From 298babc664276c3276b8664eb797df592ac5eca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 17:53:39 +0200 Subject: [PATCH 24/27] fix(capture-kit): key the cached Swift helper paths on the host they were built for The video validator is cached per Swift cache root and the overlay scripts per working directory, so a second host does not reuse the first host's paths. Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/overlay-script-paths.test.ts | 53 +++++++++++++++++++ packages/capture-kit/src/recording/overlay.ts | 33 +++++++----- .../capture-kit/src/recording/swift-cache.ts | 3 +- .../capture-kit/src/recording/video.test.ts | 23 +++++++- packages/capture-kit/src/recording/video.ts | 26 +++++---- 5 files changed, 112 insertions(+), 26 deletions(-) create mode 100644 packages/capture-kit/src/recording/__tests__/overlay-script-paths.test.ts diff --git a/packages/capture-kit/src/recording/__tests__/overlay-script-paths.test.ts b/packages/capture-kit/src/recording/__tests__/overlay-script-paths.test.ts new file mode 100644 index 0000000000..5051eff479 --- /dev/null +++ b/packages/capture-kit/src/recording/__tests__/overlay-script-paths.test.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts'; + +vi.mock('@agent-device/host-kit/command', async () => { + const fs = await import('node:fs'); + return { + runCmd: vi.fn(async (_cmd: string, args: string[]) => { + fs.writeFileSync(args[args.indexOf('--output') + 1]!, 'processed'); + return { stdout: '', stderr: '', exitCode: 0 }; + }), + }; +}); +vi.mock('../video.ts', () => ({ waitForPlayableVideo: vi.fn(async () => {}) })); +vi.mock('../swift-cache.ts', () => ({ + buildSwiftToolEnv: () => ({}), + compileSwiftSourceFile: vi.fn(async () => '/bin/true'), + resolveRecordingScriptPath: vi.fn( + (name: string, _projectRoot: string, cwd: string) => `${cwd}/${name}`, + ), +})); + +import { overlayRecordingTouches, startRecordingOverlayBudget } from '../overlay.ts'; +import { compileSwiftSourceFile } from '../swift-cache.ts'; + +test('a helper script path is resolved per host working directory, not once per process', async () => { + const root = mkdtempForTestSync('agent-device-overlay-script-paths-'); + const videoPath = path.join(root, 'recording.mp4'); + const telemetryPath = path.join(root, 'recording.gesture-telemetry.json'); + fs.writeFileSync(telemetryPath, '{"events":[]}'); + const overlay = async (cwd: string) => { + fs.writeFileSync(videoPath, 'original'); + await overlayRecordingTouches({ + host: { env: {}, cwd }, + videoPath, + telemetryPath, + deadline: startRecordingOverlayBudget(), + }); + }; + + await overlay('/cwd/one'); + await overlay('/cwd/two'); + await overlay('/cwd/one'); + + expect(vi.mocked(compileSwiftSourceFile).mock.calls.map(([params]) => params.sourcePath)).toEqual( + [ + '/cwd/one/recording-overlay.swift', + '/cwd/two/recording-overlay.swift', + '/cwd/one/recording-overlay.swift', + ], + ); +}); diff --git a/packages/capture-kit/src/recording/overlay.ts b/packages/capture-kit/src/recording/overlay.ts index ec8cc0177e..6f1d9f46b4 100644 --- a/packages/capture-kit/src/recording/overlay.ts +++ b/packages/capture-kit/src/recording/overlay.ts @@ -46,25 +46,30 @@ export function startRecordingOverlayBudget(): Deadline { */ const HELPER_EXIT_GRACE_MS = 5_000; -let overlayScriptPath: string | undefined; -let exportSupportScriptPath: string | undefined; +// The working-directory fallback in the search makes a resolved script path specific to the host's +// directory, so each directory keeps its own. +const overlayScriptPaths = new Map(); +const exportSupportScriptPaths = new Map(); + +function resolveCachedScriptPath( + cache: Map, + scriptName: string, + host: HostEnvironment, +): string { + let resolved = cache.get(host.cwd); + if (resolved === undefined) { + resolved = resolveRecordingScriptPath(scriptName, findProjectRoot(), host.cwd); + cache.set(host.cwd, resolved); + } + return resolved; +} function getOverlayScriptPath(host: HostEnvironment): string { - overlayScriptPath ??= resolveRecordingScriptPath( - 'recording-overlay.swift', - findProjectRoot(), - host.cwd, - ); - return overlayScriptPath; + return resolveCachedScriptPath(overlayScriptPaths, 'recording-overlay.swift', host); } function getExportSupportScriptPath(host: HostEnvironment): string { - exportSupportScriptPath ??= resolveRecordingScriptPath( - 'RecordingExportSupport.swift', - findProjectRoot(), - host.cwd, - ); - return exportSupportScriptPath; + return resolveCachedScriptPath(exportSupportScriptPaths, 'RecordingExportSupport.swift', host); } async function exportProcessedVideo(params: { diff --git a/packages/capture-kit/src/recording/swift-cache.ts b/packages/capture-kit/src/recording/swift-cache.ts index aeb3e43dff..9c4ed0aad8 100644 --- a/packages/capture-kit/src/recording/swift-cache.ts +++ b/packages/capture-kit/src/recording/swift-cache.ts @@ -147,7 +147,8 @@ export async function compileSwiftSourceText(params: { return executablePath; } -function getSwiftCacheRoot(env: NodeJS.ProcessEnv): string { +/** The directory compiled helpers land in for `env`: what a cached helper path is valid for. */ +export function getSwiftCacheRoot(env: NodeJS.ProcessEnv): string { const configured = env.AGENT_DEVICE_SWIFT_CACHE_DIR?.trim(); if (configured) { return path.resolve(configured); diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index 653b0a2223..00039aae51 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -7,6 +7,7 @@ import { likelyPlayableWebmContainer } from '../__tests__/test-utils/video-fixtu import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; import { mp4Atom, mp4MovieHeader } from './mp4.fixtures.ts'; import { Deadline } from '@agent-device/host-kit/retry'; +import { compileSwiftSourceText } from './swift-cache.ts'; import { isPlayableVideo, waitForPlayableVideo } from './video.ts'; vi.mock('@agent-device/host-kit/command', () => ({ @@ -14,7 +15,8 @@ vi.mock('@agent-device/host-kit/command', () => ({ })); vi.mock('./swift-cache.ts', () => ({ buildSwiftToolEnv: () => ({}), - compileSwiftSourceText: async () => '/bin/true', + getSwiftCacheRoot: (env: NodeJS.ProcessEnv) => env.AGENT_DEVICE_SWIFT_CACHE_DIR ?? '', + compileSwiftSourceText: vi.fn(async () => '/bin/true'), })); beforeEach(() => { @@ -48,6 +50,25 @@ const moov = mp4Atom( mp4Atom('mvhd', mp4MovieHeader({ version: 0, timescale: 1_000, duration: 2_000 })), ); +test('each Swift cache root compiles its own validator, and a root reuses the one it has', async () => { + const mp4 = writeFixture('validated.mp4', Buffer.concat([ftyp, moov])); + const hostIn = (cacheDir: string) => ({ + env: { AGENT_DEVICE_SWIFT_CACHE_DIR: cacheDir }, + cwd: directory, + }); + vi.mocked(compileSwiftSourceText).mockClear(); + + await isPlayableVideo(mp4, hostIn('/cache/one')); + await isPlayableVideo(mp4, hostIn('/cache/one')); + await isPlayableVideo(mp4, hostIn('/cache/two')); + + expect( + vi + .mocked(compileSwiftSourceText) + .mock.calls.map(([params]) => params.host.env.AGENT_DEVICE_SWIFT_CACHE_DIR), + ).toEqual(['/cache/one', '/cache/two']); +}); + test('reaches the semantic validator with an MP4 container whose last box runs past the file', async () => { const truncated = Buffer.concat([ftyp, moov]).subarray(0, ftyp.length + moov.length - 4); await expect( diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index b83d4e5a1f..ece772e724 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import { AppError } from '@agent-device/kernel/errors'; import { runCmd } from '@agent-device/host-kit/command'; import { type Deadline, sleep } from '@agent-device/host-kit/retry'; -import { buildSwiftToolEnv, compileSwiftSourceText } from './swift-cache.ts'; +import { buildSwiftToolEnv, compileSwiftSourceText, getSwiftCacheRoot } from './swift-cache.ts'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { hasPlayableWebmStructure } from './video-webm.ts'; @@ -35,7 +35,8 @@ semaphore.wait() exit(exitCode) `.trim(); -let videoValidatorExecutablePathPromise: Promise | undefined; +// A compiled helper is valid for the Swift cache it was built into, so each cache root keeps its own. +const videoValidatorExecutablePaths = new Map>(); export async function waitForStableFile( filePath: string, @@ -94,16 +95,21 @@ export async function isPlayableVideo(filePath: string, host: HostEnvironment): } async function getVideoValidatorExecutablePath(host: HostEnvironment): Promise { - videoValidatorExecutablePathPromise ??= compileSwiftSourceText({ - host, - source: VIDEO_VALIDATION_SCRIPT, - cacheName: 'video-validator', - timeoutMs: 30_000, - }); + const cacheRoot = getSwiftCacheRoot(host.env); + let compiled = videoValidatorExecutablePaths.get(cacheRoot); + if (!compiled) { + compiled = compileSwiftSourceText({ + host, + source: VIDEO_VALIDATION_SCRIPT, + cacheName: 'video-validator', + timeoutMs: 30_000, + }); + videoValidatorExecutablePaths.set(cacheRoot, compiled); + } try { - return await videoValidatorExecutablePathPromise; + return await compiled; } catch (error) { - videoValidatorExecutablePathPromise = undefined; + videoValidatorExecutablePaths.delete(cacheRoot); throw error; } } From d53f78b69bec85bd4478ab4584f9ec30e87a50d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 17:56:18 +0200 Subject: [PATCH 25/27] test(host): give the Android recording scenarios their host env through the harness Co-Authored-By: Claude Sonnet 5.5 --- .../android-recording-fixtures.ts | 33 +++-- .../android-recording.test.ts | 127 +++++++++++------- .../record-trace-android-copy.test.ts | 27 ++-- .../record-trace-android-liveness.test.ts | 23 ++-- .../session-close-leak-oracle.test.ts | 11 +- 5 files changed, 128 insertions(+), 93 deletions(-) diff --git a/test/integration/provider-scenarios/android-recording-fixtures.ts b/test/integration/provider-scenarios/android-recording-fixtures.ts index 0b251b894f..f0cd806247 100644 --- a/test/integration/provider-scenarios/android-recording-fixtures.ts +++ b/test/integration/provider-scenarios/android-recording-fixtures.ts @@ -6,11 +6,7 @@ import { deviceIdentity } from '@agent-device/kernel/device'; import { screenRecordingResourceStore } from '@agent-device/capture-kit/screen-recording-resource-store'; import type { AndroidRecordingManifestFixture } from './android-recording-manifest-fixtures.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; -import { - createProviderScenarioHarness, - restoreEnv, - withProviderScenarioTempDir, -} from './harness.ts'; +import { createProviderScenarioHarness, withProviderScenarioTempDir } from './harness.ts'; export type ProviderScenarioDaemon = Awaited>; export type ProviderScenarioRpcResult = Awaited>; @@ -25,28 +21,29 @@ export async function stopAndroidRecording( }); } +/** + * The daemon's host for an Android recording scenario: no real tool on its PATH, so the Swift + * helpers cannot compile, and a Swift cache of its own. + */ +export function androidRecordingHostEnv(tmpDir: string): Readonly> { + return { PATH: tmpDir, AGENT_DEVICE_SWIFT_CACHE_DIR: path.join(tmpDir, 'swift-cache') }; +} + export async function createAndroidRecordingScenarioHarness( deps: Parameters[0], + tmpDir: string, ): Promise { - return await createProviderScenarioHarness({ ...deps }); + return await createProviderScenarioHarness({ + ...deps, + env: { ...androidRecordingHostEnv(tmpDir), ...deps.env }, + }); } export async function withAndroidRecordingScenario( prefix: string, run: (tmpDir: string) => Promise, ): Promise { - return await withProviderScenarioTempDir(prefix, async (tmpDir) => { - const previousPath = process.env.PATH; - const previousSwiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; - process.env.PATH = tmpDir; - process.env.AGENT_DEVICE_SWIFT_CACHE_DIR = path.join(tmpDir, 'swift-cache'); - try { - return await run(tmpDir); - } finally { - restoreEnv('PATH', previousPath); - restoreEnv('AGENT_DEVICE_SWIFT_CACHE_DIR', previousSwiftCacheDir); - } - }); + return await withProviderScenarioTempDir(prefix, run); } export function seedAndroidRecordingResource( diff --git a/test/integration/provider-scenarios/android-recording.test.ts b/test/integration/provider-scenarios/android-recording.test.ts index 9060ca21d6..26c7139669 100644 --- a/test/integration/provider-scenarios/android-recording.test.ts +++ b/test/integration/provider-scenarios/android-recording.test.ts @@ -25,10 +25,13 @@ test(ANDROID_RECORDING_CONTRACT_EVIDENCE.testName, async () => { const calls: string[][] = []; const pulls: PullCall[] = []; const outputPath = path.join(tmpDir, 'sessionless.mp4'); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => createAndroidRecordingProvider({ calls, pulls }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => createAndroidRecordingProvider({ calls, pulls }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); try { const started = await daemon.callCommand('record', ['start', outputPath], { platform: 'android', @@ -71,11 +74,14 @@ test('Provider-backed integration Android record stop reattaches a matching dura remotePath, sessionName: 'default', }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => - createAndroidRecordingProvider({ calls, pulls, manifests: [manifest] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ calls, pulls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); seedAndroidRecordingResource(daemon, manifest); try { const stopped = await stopAndroidRecording(daemon, outputPath); @@ -115,11 +121,14 @@ test('Provider-backed integration Android record stop returns fenced completed n recordOnlySession: false, }, }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => - createAndroidRecordingProvider({ calls, manifests: [manifest], deadPids: ['4321'] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ calls, manifests: [manifest], deadPids: ['4321'] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); seedAndroidRecordingResource(daemon, manifest); try { const stopped = await stopAndroidRecording(daemon, outputPath); @@ -165,15 +174,18 @@ test('Provider-backed integration Android record start retires completed evidenc }, }); fs.writeFileSync(previousPath, 'saved recording'); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => - createAndroidRecordingProvider({ - calls, - manifests: [manifest], - reusedPids: [{ pid: '4004', startTime: '5432' }], - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ + calls, + manifests: [manifest], + reusedPids: [{ pid: '4004', startTime: '5432' }], + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); try { const started = await daemon.callCommand('record', ['start', outputPath], { platform: 'android', @@ -217,11 +229,14 @@ test('Provider-backed integration Android record start retires evidence stranded deviceId: 'emulator-5588', chunks: [{ index: 1, remotePath, remotePid: '4004', remoteStartTime: '3766' }], }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => - createAndroidRecordingProvider({ calls, manifests: [manifest], deadPids: ['4004'] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ calls, manifests: [manifest], deadPids: ['4004'] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); try { const started = await daemon.callCommand('record', ['start', outputPath], { platform: 'android', @@ -271,15 +286,18 @@ test('Provider-backed integration Android record start retains completed evidenc recordOnlySession: false, }, }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => - createAndroidRecordingProvider({ - calls, - manifests: [manifest], - reusedPids: [{ pid: '4004', startTime: '9911', role: 'replacement-recorder' }], - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ + calls, + manifests: [manifest], + reusedPids: [{ pid: '4004', startTime: '9911', role: 'replacement-recorder' }], + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); try { assertRpcError( await daemon.callCommand('record', ['start', outputPath], { @@ -338,10 +356,14 @@ test('Provider-backed integration Android record start refuses completed evidenc recordOnlySession: false, }, }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => createAndroidRecordingProvider({ calls, manifests: [manifest] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ calls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); try { assertRpcError( await daemon.callCommand('record', ['start', outputPath], { @@ -386,10 +408,14 @@ test('Provider-backed integration Android corrupt descriptor is retained without remotePath: '/sdcard/agent-device-recording-323456789.mp4', sessionName: 'default', }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => createAndroidRecordingProvider({ calls, manifests: [manifest] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ calls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); seedAndroidRecordingResource(daemon, manifest, { descriptor: { malformed: true } }); try { const stopped = await stopAndroidRecording(daemon); @@ -423,10 +449,13 @@ test('Provider-backed integration Android cleanup-only recovery terminalizes bef pendingRemotePath: '/sdcard/agent-device-recording-423456789.mp4', }); const provider = createAndroidRecordingProvider({ calls, manifests: [manifest] }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => provider, - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); seedAndroidRecordingResource(daemon, manifest); try { assertRpcError( diff --git a/test/integration/provider-scenarios/record-trace-android-copy.test.ts b/test/integration/provider-scenarios/record-trace-android-copy.test.ts index 1c15529b09..766e09eb86 100644 --- a/test/integration/provider-scenarios/record-trace-android-copy.test.ts +++ b/test/integration/provider-scenarios/record-trace-android-copy.test.ts @@ -27,18 +27,21 @@ test('Provider-backed integration Android record stop retries the pull until in- const streaming = mp4WithMoovSlot('free'); const finalized = mp4WithMoovSlot('moov'); let pulls = 0; - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => - createAndroidRecordingProvider({ - calls: [], - manifests: [manifest], - onPull: (_remotePath, localPath, count) => { - pulls = count; - fs.writeFileSync(localPath, count < 3 ? streaming : finalized); - }, - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ + calls: [], + manifests: [manifest], + onPull: (_remotePath, localPath, count) => { + pulls = count; + fs.writeFileSync(localPath, count < 3 ? streaming : finalized); + }, + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); seedAndroidRecordingResource(daemon, manifest); try { const stopped = await stopAndroidRecording(daemon, outputPath); diff --git a/test/integration/provider-scenarios/record-trace-android-liveness.test.ts b/test/integration/provider-scenarios/record-trace-android-liveness.test.ts index aff50c3b50..91c969a3a1 100644 --- a/test/integration/provider-scenarios/record-trace-android-liveness.test.ts +++ b/test/integration/provider-scenarios/record-trace-android-liveness.test.ts @@ -24,17 +24,20 @@ test('Provider-backed integration Android record stop finishes an artifact after remotePath, sessionName: 'default', }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => { - const provider = createAndroidRecordingProvider({ - calls, - manifests: [manifest], - deadPids: ['4321'], - }); - return provider; + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => { + const provider = createAndroidRecordingProvider({ + calls, + manifests: [manifest], + deadPids: ['4321'], + }); + return provider; + }, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], }, - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + tmpDir, + ); seedAndroidRecordingResource(daemon, manifest); try { const stopped = await stopAndroidRecording(daemon, outputPath); diff --git a/test/integration/provider-scenarios/session-close-leak-oracle.test.ts b/test/integration/provider-scenarios/session-close-leak-oracle.test.ts index ea10091401..54f1c6def2 100644 --- a/test/integration/provider-scenarios/session-close-leak-oracle.test.ts +++ b/test/integration/provider-scenarios/session-close-leak-oracle.test.ts @@ -26,10 +26,13 @@ test( async (tmpDir) => { const calls: string[][] = []; const outputPath = path.join(tmpDir, 'close-leak.mp4'); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => createAndroidRecordingProvider({ calls }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => createAndroidRecordingProvider({ calls }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); try { const started = await daemon.callCommand('record', ['start', outputPath], { platform: 'android', From 7cb529c5fa83d7f80ac87ef089a3d16ebcd41923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 18:04:05 +0200 Subject: [PATCH 26/27] test(host): scenarios name their own host env; the harness no longer forwards the process's Swift cache Co-Authored-By: Claude Sonnet 5.5 --- .../provider-scenarios/android-recording-fixtures.ts | 2 +- test/integration/provider-scenarios/harness.ts | 2 -- test/integration/provider-scenarios/macos-world.ts | 3 +++ 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test/integration/provider-scenarios/android-recording-fixtures.ts b/test/integration/provider-scenarios/android-recording-fixtures.ts index f0cd806247..dd598be6bc 100644 --- a/test/integration/provider-scenarios/android-recording-fixtures.ts +++ b/test/integration/provider-scenarios/android-recording-fixtures.ts @@ -25,7 +25,7 @@ export async function stopAndroidRecording( * The daemon's host for an Android recording scenario: no real tool on its PATH, so the Swift * helpers cannot compile, and a Swift cache of its own. */ -export function androidRecordingHostEnv(tmpDir: string): Readonly> { +function androidRecordingHostEnv(tmpDir: string): Readonly> { return { PATH: tmpDir, AGENT_DEVICE_SWIFT_CACHE_DIR: path.join(tmpDir, 'swift-cache') }; } diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index b33436de99..e9650ca16a 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -112,8 +112,6 @@ export async function createProviderScenarioHarness( PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, - // A scenario that isolates the Swift helper cache names it through the process. - AGENT_DEVICE_SWIFT_CACHE_DIR: process.env.AGENT_DEVICE_SWIFT_CACHE_DIR, // A scenario that seeds claims for its daemon names the directory through the process. AGENT_DEVICE_CLAIMS_DIR: process.env.AGENT_DEVICE_CLAIMS_DIR ?? path.join(testRoot, 'claims'), AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '3600000', diff --git a/test/integration/provider-scenarios/macos-world.ts b/test/integration/provider-scenarios/macos-world.ts index 32d4fd9603..00c0bbde40 100644 --- a/test/integration/provider-scenarios/macos-world.ts +++ b/test/integration/provider-scenarios/macos-world.ts @@ -48,7 +48,10 @@ export async function createMacOsDesktopWorld( }, }, }); + // Recording finalization runs the Swift validator; the process's warm cache keeps it from compiling. + const swiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; const daemon = await createProviderScenarioHarness({ + ...(swiftCacheDir ? { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: swiftCacheDir } } : {}), appleRunnerProvider: options.appleRunnerProvider ? () => options.appleRunnerProvider : undefined, From f0b20fae1b727aa48051aa4967f6431509e63535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 18:59:06 +0200 Subject: [PATCH 27/27] test(host): drop the iOS recording scenario's process-env block now that capture-kit takes the host Co-Authored-By: Claude Sonnet 5.5 --- .../provider-scenarios/ios-record-trace.test.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/test/integration/provider-scenarios/ios-record-trace.test.ts b/test/integration/provider-scenarios/ios-record-trace.test.ts index 9622d71ec5..60f2f2f2ac 100644 --- a/test/integration/provider-scenarios/ios-record-trace.test.ts +++ b/test/integration/provider-scenarios/ios-record-trace.test.ts @@ -17,7 +17,6 @@ import { PROVIDER_SCENARIO_IOS_DEVICE, PROVIDER_SCENARIO_IOS_SIMULATOR } from '. import { createProviderIosSimulatorRecordingProcess, createProviderScenarioHarness, - restoreEnv, withProviderScenarioTempDir, } from './harness.ts'; import { @@ -112,13 +111,6 @@ test('iOS simulator recording reports host contention and recovers through the f appleSimulatorScreenRecordingTransport: () => recordingTransport, deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], }); - // capture-kit's recording finalizer still reads PATH and the Swift cache from the process - // (#3777 moves capture-kit to the host), so the scenario sets both there as well. - const previousPath = process.env.PATH; - const previousSwiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; - process.env.PATH = tmpDir; - process.env.AGENT_DEVICE_SWIFT_CACHE_DIR = path.join(tmpDir, 'swift-cache'); - try { const open = await daemon.callCommand('open', ['com.apple.Preferences'], { platform: 'ios', @@ -199,8 +191,6 @@ test('iOS simulator recording reports host contention and recovers through the f ); } finally { await daemon.close(); - restoreEnv('PATH', previousPath); - restoreEnv('AGENT_DEVICE_SWIFT_CACHE_DIR', previousSwiftCacheDir); } }, );