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..d3be442797 100644 --- a/packages/capture-kit/src/capture-admission/app-log-files.ts +++ b/packages/capture-kit/src/capture-admission/app-log-files.ts @@ -12,7 +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 = process.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/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/png-worker-client-unavailable.test.ts b/packages/capture-kit/src/png-worker-client-unavailable.test.ts new file mode 100644 index 0000000000..565f892f04 --- /dev/null +++ b/packages/capture-kit/src/png-worker-client-unavailable.test.ts @@ -0,0 +1,74 @@ +import { EventEmitter } from 'node:events'; +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'); +}); + +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. + 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'); +}); diff --git a/packages/capture-kit/src/png-worker-client.ts b/packages/capture-kit/src/png-worker-client.ts index 994e1cedf0..07745995a3 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,21 +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; - // 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}`, - ); + 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 { @@ -102,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) { @@ -110,7 +123,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); @@ -176,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; } } 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/__tests__/overlay.test.ts b/packages/capture-kit/src/recording/__tests__/overlay.test.ts index 9a54eec10d..e42b12d359 100644 --- a/packages/capture-kit/src/recording/__tests__/overlay.test.ts +++ b/packages/capture-kit/src/recording/__tests__/overlay.test.ts @@ -1,4 +1,5 @@ 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'; @@ -63,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(), @@ -109,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', @@ -126,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(), @@ -143,6 +152,7 @@ test('overlay forwards the requested high export preset', async () => { fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -161,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(), @@ -169,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(), @@ -185,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(), @@ -212,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); @@ -234,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', @@ -251,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(), @@ -262,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`, @@ -277,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(), @@ -286,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', }); @@ -304,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 b8266c16ab..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, @@ -37,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[] { @@ -63,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 464f65dd3b..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 { @@ -98,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..6f1d9f46b4 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'; @@ -45,36 +46,48 @@ 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 getOverlayScriptPath(): string { - overlayScriptPath ??= resolveRecordingScriptPath('recording-overlay.swift', findProjectRoot()); - return overlayScriptPath; +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 { + return resolveCachedScriptPath(overlayScriptPaths, 'recording-overlay.swift', host); } -function getExportSupportScriptPath(): string { - exportSupportScriptPath ??= resolveRecordingScriptPath( - 'RecordingExportSupport.swift', - findProjectRoot(), - ); - return exportSupportScriptPath; +function getExportSupportScriptPath(host: HostEnvironment): string { + return resolveCachedScriptPath(exportSupportScriptPaths, 'RecordingExportSupport.swift', host); } 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 +111,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 +161,7 @@ function temporarySiblingVideoPath(videoPath: string): string { } export async function overlayRecordingTouches(params: { + host: HostEnvironment; videoPath: string; telemetryPath: string; exportQuality?: RecordingExportQuality; @@ -164,8 +178,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 8949f5583e..5087846756 100644 --- a/packages/capture-kit/src/recording/swift-cache.test.ts +++ b/packages/capture-kit/src/recording/swift-cache.test.ts @@ -1,4 +1,5 @@ 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'; @@ -38,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, }); @@ -56,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' }), ); }); @@ -72,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', @@ -110,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, @@ -127,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, @@ -156,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); @@ -164,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: '---', }); @@ -191,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); }); @@ -207,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', }); @@ -279,3 +289,22 @@ test('recording script candidates include packaged dist apple-runner source', () expect(firstExisting).toBe(scriptPath); }); + +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 compileSwiftSourceText({ + host: { + env: { AGENT_DEVICE_SWIFT_CACHE_DIR: hostCacheDir, FROM_HOST: 'yes' }, + cwd: tmpDir, + }, + 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..9c4ed0aad8 100644 --- a/packages/capture-kit/src/recording/swift-cache.ts +++ b/packages/capture-kit/src/recording/swift-cache.ts @@ -6,6 +6,7 @@ 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 type { HostEnvironment } from '@agent-device/kernel/source-value'; import { readProcessStartTime } from '@agent-device/host-kit/process'; import { acquireProcessLock, @@ -42,13 +43,14 @@ 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, projectRoot: string, + cwd: string, moduleDir = path.dirname(fileURLToPath(import.meta.url)), - cwd = process.cwd(), ): string { const scriptCandidates = buildRecordingScriptPathCandidates( scriptName, @@ -69,8 +71,8 @@ export function resolveRecordingScriptPath( }); } -export function buildSwiftToolEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - const root = getSwiftCacheRoot(); +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'); fs.mkdirSync(homePath, { recursive: true }); @@ -83,6 +85,7 @@ export function buildSwiftToolEnv(env: NodeJS.ProcessEnv = process.env): NodeJS. } export async function compileSwiftSourceFile(params: { + host: HostEnvironment; sourcePath: string; /** Additional compilation units (shared helpers) compiled into the same executable. */ extraSourcePaths?: string[]; @@ -108,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, @@ -118,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, @@ -136,8 +147,9 @@ export async function compileSwiftSourceText(params: { return executablePath; } -function getSwiftCacheRoot(): string { - const configured = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR?.trim(); +/** 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); } @@ -145,6 +157,7 @@ function getSwiftCacheRoot(): string { } async function ensureSwiftExecutable(params: { + host: HostEnvironment; sourcePaths: string[]; executablePath: string; sourceText?: string; @@ -180,7 +193,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..00039aae51 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'; @@ -6,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', () => ({ @@ -13,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(() => { @@ -24,11 +27,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 { @@ -43,15 +50,38 @@ 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(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 +89,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..ece772e724 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -2,7 +2,8 @@ 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'; @@ -34,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, @@ -67,18 +69,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,16 +94,22 @@ export async function isPlayableVideo(filePath: string): Promise { } } -async function getVideoValidatorExecutablePath(): Promise { - videoValidatorExecutablePathPromise ??= compileSwiftSourceText({ - source: VIDEO_VALIDATION_SCRIPT, - cacheName: 'video-validator', - timeoutMs: 30_000, - }); +async function getVideoValidatorExecutablePath(host: HostEnvironment): Promise { + 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; } } @@ -122,13 +130,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/daemon/src/resources/__tests__/app-log.test.ts b/packages/daemon/src/resources/__tests__/app-log.test.ts index c96feea65a..40630c37c6 100644 --- a/packages/daemon/src/resources/__tests__/app-log.test.ts +++ b/packages/daemon/src/resources/__tests__/app-log.test.ts @@ -12,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/); @@ -44,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 31ef5a3fbf..3887a794b9 100644 --- a/packages/daemon/src/resources/app-log.ts +++ b/packages/daemon/src/resources/app-log.ts @@ -26,8 +26,8 @@ export function getAppLogPathMetadata(outPath: string): { } } -export function appendAppLogMarker(outPath: string, marker: string): void { - ensureAppLogPath(outPath); +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/server/http/http-server-proxy.test.ts b/packages/daemon/src/server/http/http-server-proxy.test.ts index b670de57a9..ca93e9c3a4 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, + }, + env, + ), }; }, }); 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 } }; } 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..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 @@ -40,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, @@ -73,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 { @@ -109,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); @@ -146,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); @@ -164,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); @@ -203,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}`); @@ -220,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, @@ -242,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..8c20de3831 100644 --- a/packages/maestro/src/daemon-port/run-script-http-child.ts +++ b/packages/maestro/src/daemon-port/run-script-http-child.ts @@ -6,6 +6,7 @@ import { executeRunScriptHttpRequest } from './run-script-http.ts'; export async function runScriptHttpChild(): Promise { const response = await executeRunScriptHttpRequest( JSON.parse(readFileSync(0, 'utf8')) as RunScriptHttpRequest, + 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/platform-android/src/__tests__/install-artifact-cancellation.test.ts b/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts index 4241f7e867..508c8aa881 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 { bindAndroidAdbHostStub } from '../adb-host.fixtures.ts'; + +bindAndroidAdbHostStub(); test('aborts an in-flight Android artifact identity probe', async () => { const tempRoot = await mkdtempForTest('agent-device-android-artifact-abort-'); @@ -28,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')); @@ -62,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 5ec4b3cc19..ac1baf7ff7 100644 --- a/packages/platform-android/src/install-artifact.ts +++ b/packages/platform-android/src/install-artifact.ts @@ -12,20 +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, + 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/packages/platform-apple/src/core/__tests__/install-artifact.test.ts b/packages/platform-apple/src/core/__tests__/install-artifact.test.ts index cda3ca4d84..10711d088e 100644 --- a/packages/platform-apple/src/core/__tests__/install-artifact.test.ts +++ b/packages/platform-apple/src/core/__tests__/install-artifact.test.ts @@ -20,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'); @@ -36,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'); @@ -57,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 && @@ -72,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\)/, ); }); @@ -89,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/__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/install-artifact.ts b/packages/platform-apple/src/core/install-artifact.ts index 5fe729906d..1936b857cd 100644 --- a/packages/platform-apple/src/core/install-artifact.ts +++ b/packages/platform-apple/src/core/install-artifact.ts @@ -24,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 = { @@ -44,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), @@ -53,7 +55,7 @@ export async function prepareIosInstallArtifact( async function prepareIosInstallArtifactInScope( source: LocalInstallSource, - options?: InstallIosArtifactOptions, + options: InstallIosArtifactOptions, ): Promise { const materialized = await materializeInstallablePath({ source, @@ -61,7 +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, + signal: options.signal, + environment: options.environment, }); let resolved: Awaited> | undefined; 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..c6a5534137 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, + hostEnvironment: 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-process-launch.test.ts b/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts index 703610720f..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,6 +49,34 @@ 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 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 }), + }); + + const launched = launchRunnerProcess({ + device: IOS_SIMULATOR, + xctestrunPath: '/tmp/runner.xctestrun', + derivedPath: '/tmp/runner-derived', + logPath: runnerLogPath(), + }); + await launched.wait; + + 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', () => { mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); 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..ce6af7a57f 100644 --- a/packages/platform-apple/src/runner/host.ts +++ b/packages/platform-apple/src/runner/host.ts @@ -87,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. @@ -158,6 +163,7 @@ export const runCmdBackground = delegate('runCmdBackground'); export const requireExecSuccess = delegate('requireExecSuccess'); export const isCommandTimeoutError = delegate('isCommandTimeoutError'); export const commandDeveloperDir = delegate('commandDeveloperDir'); +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/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..8d7288957e 100644 --- a/packages/platform-apple/src/runner/runner-adoption.ts +++ b/packages/platform-apple/src/runner/runner-adoption.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { 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'; @@ -61,7 +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 = process.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 b15275d240..b3301914b5 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, + hostEnvironment, } 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 } = hostEnvironment(); + 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..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 { isEnvTruthy, findProjectRoot, readVersion } from './host.ts'; +import { hostEnvironment, isEnvTruthy, findProjectRoot, readVersion } from './host.ts'; import { resolveRunnerBuildDestinationFamily, resolveRunnerDerivedBaseName, @@ -88,14 +88,14 @@ function normalizeBundleId(value: string | undefined): string { return value?.trim() ?? ''; } -export function resolveRunnerAppBundleId(env: NodeJS.ProcessEnv = process.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 = process.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; @@ -103,7 +103,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 +117,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 } = hostEnvironment(); return { schemaVersion: RUNNER_CACHE_SCHEMA_VERSION, packageVersion: readVersion(projectRoot), @@ -136,15 +133,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 +149,7 @@ export function resolveRunnerDerivedPath( device: DeviceInfo, metadata: RunnerXctestrunCacheMetadata, ): string { - const override = process.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); } @@ -256,7 +253,7 @@ export function resolveRunnerMaxConcurrentDestinationsFlag(device: DeviceInfo): } export function resolveRunnerSigningBuildSettings( - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = hostEnvironment().env, forDevice = false, device: Pick = { platform: 'apple' }, ): string[] { @@ -285,7 +282,9 @@ export function resolveRunnerSigningBuildSettings( return args; } -export function resolveRunnerBundleBuildSettings(env: NodeJS.ProcessEnv = process.env): string[] { +export function resolveRunnerBundleBuildSettings( + env: NodeJS.ProcessEnv = hostEnvironment().env, +): string[] { const appBundleId = resolveRunnerAppBundleId(env); const testBundleId = resolveRunnerTestBundleId(env); return [ @@ -310,7 +309,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 = hostEnvironment().env, +): string[] { const archs = env.AGENT_DEVICE_XCUITEST_ARCHS?.trim(); return archs ? [`ARCHS=${archs}`] : []; } @@ -329,11 +330,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 = hostEnvironment().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..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 { emitDiagnostic } from './host.ts'; +import { hostEnvironment, 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 = hostEnvironment().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 = 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 fbb60aba68..9a427ab1b3 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, + hostEnvironment, } 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(hostEnvironment().env.AGENT_DEVICE_IOS_CLEAN_DERIVED); } export function assertSafeDerivedCleanup( derivedPath: string, - env: NodeJS.ProcessEnv = process.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 297bc4735f..0e426883bb 100644 --- a/packages/platform-apple/src/runner/runner-disposal.ts +++ b/packages/platform-apple/src/runner/runner-disposal.ts @@ -1,4 +1,5 @@ import { + hostEnvironment, 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(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 18f3d77742..f04f6d959f 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, + hostEnvironment, } 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 hostEnvironment().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 = 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 80ce456b4e..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 { 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'; @@ -60,6 +60,8 @@ 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 } = hostEnvironment(); 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/runner/runner-retention.ts b/packages/platform-apple/src/runner/runner-retention.ts index 1e818afef6..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 { 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 = process.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 16b00c4043..22f3fb257e 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, + hostEnvironment, } 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(hostEnvironment().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/packages/platform-apple/src/snapshot-source/host.test.ts b/packages/platform-apple/src/snapshot-source/host.test.ts index 989e0feaef..8461b0e4c2 100644 --- a/packages/platform-apple/src/snapshot-source/host.test.ts +++ b/packages/platform-apple/src/snapshot-source/host.test.ts @@ -1,10 +1,15 @@ 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 { 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'; +bindTestCommandHost(); + vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ ...(await importOriginal()), runCmdBackground: vi.fn(), @@ -71,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`); +}); 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; 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 e3616b7e11..aadd959deb 100644 --- a/packages/provision-kit/src/install-source-network-transport.test.ts +++ b/packages/provision-kit/src/install-source-network-transport.test.ts @@ -57,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 39491de4b6..b3efb6f7df 100644 --- a/packages/provision-kit/src/install-source-network-transport.ts +++ b/packages/provision-kit/src/install-source-network-transport.ts @@ -14,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); @@ -43,10 +45,7 @@ export async function requestApprovedUrl(params: { } } -export function resolveProxyForUrl( - url: URL, - environment: NodeJS.ProcessEnv = process.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..136e68e94a 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, @@ -79,17 +84,17 @@ 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( source: LocalInstallSource, - options?: { signal?: AbortSignal }, + options: { signal?: AbortSignal; environment: NodeJS.ProcessEnv }, ): Promise { if (source.kind === 'path') { return { - localPath: expandSourcePath(source.path), + localPath: expandSourcePath(source.path, options.environment), cleanup: async () => {}, }; } @@ -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/react-native/src/companion/client-companion-tunnel.ts b/packages/react-native/src/companion/client-companion-tunnel.ts index 8506d79cb5..8486cb010e 100644 --- a/packages/react-native/src/companion/client-companion-tunnel.ts +++ b/packages/react-native/src/companion/client-companion-tunnel.ts @@ -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 = { @@ -401,7 +403,8 @@ function spawnCompanionProcess( let pid = 0; try { pid = runCmdDetached(process.execPath, [...execArgs, modulePath, options.definition.runArg], { - env: buildCompanionEnv(options, options.env ?? process.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 2e2a17b988..e7cd226c88 100644 --- a/packages/react-native/src/companion/client-metro-companion.test.ts +++ b/packages/react-native/src/companion/client-metro-companion.test.ts @@ -96,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', @@ -106,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', @@ -116,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', @@ -194,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', @@ -204,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', @@ -239,6 +249,7 @@ test('spawned companion uses neutral env names', async () => { ); await ensureMetroCompanion({ + cwd: process.cwd(), projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -280,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', @@ -436,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', @@ -479,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', @@ -496,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', @@ -539,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/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..707ad64a52 100644 --- a/packages/react-native/src/index-metro.test.ts +++ b/packages/react-native/src/index-metro.test.ts @@ -13,6 +13,7 @@ import { writeFakeMetroNpx, findFreeMetroPort, stopTestMetroProcess, + createTestHostEnvironment, } from '@agent-device/test-kit'; const TEST_TOKEN = 'agent-device-proxy-test-token'; @@ -48,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'); @@ -103,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'); @@ -153,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( @@ -204,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'); @@ -246,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; @@ -288,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'); @@ -331,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'); @@ -353,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' && @@ -366,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' && @@ -378,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' && @@ -394,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' && @@ -409,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 59b1a101aa..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,7 +2,7 @@ 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 { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { AppError } from '@agent-device/kernel/errors'; vi.mock('../companion/client-metro-companion.ts', () => ({ @@ -98,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); @@ -131,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'); @@ -208,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); @@ -268,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/); @@ -331,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); @@ -394,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'; @@ -504,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 fe1e921666..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,7 +2,7 @@ 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 { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import type { AppError } from '@agent-device/kernel/errors'; vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ @@ -80,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 ae3c13c047..58e4cb1095 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 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'; @@ -14,12 +15,19 @@ 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 } = options; 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, }; @@ -99,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/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)) { 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/__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/__tests__/install-source.test.ts b/src/__tests__/install-source.test.ts index 182f160ad5..0dae54f920 100644 --- a/src/__tests__/install-source.test.ts +++ b/src/__tests__/install-source.test.ts @@ -25,6 +25,7 @@ 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 { mkdtempForTest } from '@agent-device/test-kit'; import * as networkTransport from '@agent-device/provision-kit/install-source-network-transport'; @@ -140,6 +141,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 +172,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 { @@ -198,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'); @@ -231,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'); @@ -252,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, ); }); @@ -278,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'); @@ -314,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'); @@ -348,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'); @@ -385,10 +406,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'); @@ -426,10 +450,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'); @@ -462,10 +489,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'); @@ -504,10 +534,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), []); @@ -527,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, ); }); @@ -555,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/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/agent-device-client.ts b/src/agent-device-client.ts index 07f0635f74..dfdbaddbcc 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, @@ -90,6 +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 take the client's host explicitly. + const prepareMetroRuntime = (input: Parameters[0]) => + 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 = ( + options: { session: string; stateDir?: string }, + result: Parameters[1], + ) => devServer.recordPreparedMetroSession(withHost(options), result); + const recordOpenedMetroSession = ( + 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/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-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(); diff --git a/src/platform-runtime-app-log-output.test.ts b/src/platform-runtime-app-log-output.test.ts index 6be7e1f424..d700a0a432 100644 --- a/src/platform-runtime-app-log-output.test.ts +++ b/src/platform-runtime-app-log-output.test.ts @@ -1,9 +1,12 @@ 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 { mkdtempForTestSync } from '@agent-device/test-kit'; +import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; + +bindTestCommandHost(); const roots: string[] = []; @@ -92,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 e0e1d3346b..70c4b3fe59 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 { @@ -22,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; @@ -89,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) 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'); 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-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); } 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/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..24806147b0 100644 --- a/src/sdk/metro.ts +++ b/src/sdk/metro.ts @@ -1,11 +1,20 @@ +import * as devServer from '@agent-device/react-native'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; + export { normalizeBaseUrl, buildBundleUrl, resolveRuntimeTransport, - stopMetroTunnel, - prepareMetroRuntime, reloadMetro, + stopMetroTunnel, type MetroBridgeDescriptor, type MetroTunnelRequestMessage, type MetroTunnelResponseMessage, } from '@agent-device/react-native'; + +/** 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)); +} 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}`, ); diff --git a/test/integration/provider-scenarios/android-recording-fixtures.ts b/test/integration/provider-scenarios/android-recording-fixtures.ts index 0b251b894f..dd598be6bc 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. + */ +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/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); } }, ); 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, 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',