diff --git a/packages/contracts/src/platform-plugin.ts b/packages/contracts/src/platform-plugin.ts index 099dcd940d..8417b565b5 100644 --- a/packages/contracts/src/platform-plugin.ts +++ b/packages/contracts/src/platform-plugin.ts @@ -1,4 +1,5 @@ import type { DeviceInfo, Platform, PlatformSelector } from '@agent-device/kernel/device'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { PlatformGatedProviderResolverKey } from './platform-providers.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; @@ -31,8 +32,18 @@ export type PlatformPlugin = { readonly platforms: readonly Platform[]; /** The multi-platform family selector, when the plugin owns more than one leaf (`apple`). */ readonly familySelector?: PlatformSelector; - /** Lazily builds the {@link Interactor} for `device` — wraps today's `getInteractor` switch arm. */ - createInteractor(device: DeviceInfo, runner: RunnerContext): Promise; + /** + * Lazily builds the {@link Interactor} for `device` — wraps today's `getInteractor` switch arm. + * `host` is the environment the calling runtime captured at its entry point. Plugins are + * module-level and shared by every runtime in a process, so the host arrives per call — never + * bound at plugin construction — and is the same object the runtime's deps and request-bound + * facets carry. + */ + createInteractor( + device: DeviceInfo, + runner: RunnerContext, + host: HostEnvironment, + ): Promise; /** * The request-scope provider facet (issue #974). `platformGatedResolvers` * declares which PLATFORM-GATED request provider resolvers apply to this family's diff --git a/packages/contracts/src/platform-runtime-host.ts b/packages/contracts/src/platform-runtime-host.ts index 4ab991b5c7..bf59d681cd 100644 --- a/packages/contracts/src/platform-runtime-host.ts +++ b/packages/contracts/src/platform-runtime-host.ts @@ -2,6 +2,10 @@ import type { DeviceInfo, Platform } from '@agent-device/kernel/device'; import type { JsonObject, JsonValue } from './json.ts'; import type { AndroidClipboardShellSupport } from './android-clipboard-support.ts'; +// The composition root names the runtime host through this contract rather than the kernel +// substrate directly (R13): one host object is the whole configuration surface a runtime reads. +export type { HostEnvironment } from '@agent-device/kernel/source-value'; + export type HostCommandRequest = Readonly<{ executable: string; args: readonly string[]; diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index a3c04d59c5..06b378487c 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -1,5 +1,6 @@ import type { AppLogRuntimeHost, AppLogRuntimeOperations } from './app-log-runtime.ts'; import type { AppInventoryRuntimeOperations } from './app-inventory-runtime.ts'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { AndroidAppDeploymentExecutor, AppDeploymentRuntimeOperations, @@ -830,6 +831,11 @@ export const keyboardRuntimePlanUses = Object.freeze([ export type PlatformRuntimeHost = AppLogRuntimeHost & NetworkRuntimeHost & Readonly<{ + /** + * The host this runtime's entry point captured. Owners read configuration from it; it is the + * same object the runtime's deps carry, never a re-capture. + */ + hostEnvironment: HostEnvironment; /** Focused native ports; deployment semantics remain in the owning family packages. */ appleDeployment: AppleAppDeploymentExecutor; androidDeployment: AndroidAppDeploymentExecutor; diff --git a/packages/daemon/src/__tests__/support/platform-services-fixture.ts b/packages/daemon/src/__tests__/support/platform-services-fixture.ts index 21e80b88e3..04e43512df 100644 --- a/packages/daemon/src/__tests__/support/platform-services-fixture.ts +++ b/packages/daemon/src/__tests__/support/platform-services-fixture.ts @@ -12,8 +12,8 @@ import { unavailableDeviceRuntimeGateway } from './unavailable-device-runtime-ga * that cares about one ask overrides just that member — per case through the `overrides` * argument, or by mocking this module's exported spy — and asserts the daemon's branching on * the answer. What the composed members actually do (surface, open-target, readiness, and boot - * rules over the platform packages) is owned by the composition lane, which builds the real - * port through `src/__tests__/daemon-composition/platform-services-fixture.ts`. Members are + * rules over the platform packages) is owned by the composition lane, which builds the real port + * in `src/daemon-composition.ts`. Members are * module-level stable references so two fixture instances deep-compare equal. */ export function daemonPlatformServicesFixture( diff --git a/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts b/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts index 1b47864824..e6a705633c 100644 --- a/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts +++ b/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts @@ -113,7 +113,10 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ // runner underneath is the suite's own mock. const runnerOptions = { signal: requestSignal }; const appleInteractor = isApplePlatform(device.platform) - ? await applePlugin.createInteractor(device, runnerOptions) + ? await applePlugin.createInteractor(device, runnerOptions, { + env: process.env, + cwd: process.cwd(), + }) : undefined; const alertOptions = (input: AlertRuntimeInput) => ({ ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), diff --git a/packages/host-kit/src/host-file.ts b/packages/host-kit/src/host-file.ts index 2e69d1f65d..7d1ded3f22 100644 --- a/packages/host-kit/src/host-file.ts +++ b/packages/host-kit/src/host-file.ts @@ -11,6 +11,7 @@ export { hostFileStat, hostFileStatSync, hostHomeDirectory, + hostHomeDirectoryFromEnv, hostTemporaryDirectory, makeHostTemporaryDirectory, readHostBinaryFile, diff --git a/packages/host-kit/src/internal/host-file.ts b/packages/host-kit/src/internal/host-file.ts index 297df12a0a..dc278bc8d7 100644 --- a/packages/host-kit/src/internal/host-file.ts +++ b/packages/host-kit/src/internal/host-file.ts @@ -7,6 +7,18 @@ export function hostHomeDirectory(): string { return os.homedir(); } +/** + * The home directory a named host names for itself: its own HOME (or USERPROFILE on Windows). + * Returns undefined when the host env names no home; callers must fail closed or skip the + * home-bound lookup, never fall back to `os.homedir()`, because the process home is an ambient + * read that #3753 forbids. Derive per-runtime home config through this (#3794). + */ +export function hostHomeDirectoryFromEnv( + env: Record, +): string | undefined { + return env['HOME'] ?? env['USERPROFILE']; +} + export function hostTemporaryDirectory(): string { return os.tmpdir(); } diff --git a/packages/platform-apple/src/__tests__/alert.test.ts b/packages/platform-apple/src/__tests__/alert.test.ts index edfe127ca7..5bdf5180b2 100644 --- a/packages/platform-apple/src/__tests__/alert.test.ts +++ b/packages/platform-apple/src/__tests__/alert.test.ts @@ -43,7 +43,7 @@ afterEach(() => { test('a read spends the family default, not the caller window', async () => { mockRunner.mockResolvedValue({ title: 'Camera Access' }); - await readAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 37 }); + await readAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 37 }, process.env); assert.equal(mockRunner.mock.calls.length, 1); assert.deepEqual(mockRunner.mock.calls[0]?.[1], { @@ -62,7 +62,12 @@ test('a wait polls until one attempt answers, and the first attempt gets the who return { title: 'Camera Access' }; }); - const result = await awaitAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 5_000 }); + const result = await awaitAppleAlert( + IOS_SIMULATOR, + runnerOptions, + { timeoutMs: 5_000 }, + process.env, + ); assert.deepEqual(result, { title: 'Camera Access' }); assert.equal(calls, 2); @@ -76,7 +81,12 @@ test('a wait that never sees an alert reports the timeout rather than the last a vi.useFakeTimers(); mockRunner.mockRejectedValue(runnerAbsence()); - const outcome = awaitAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 900 }).then( + const outcome = awaitAppleAlert( + IOS_SIMULATOR, + runnerOptions, + { timeoutMs: 900 }, + process.env, + ).then( () => undefined, (error: unknown) => error, ); @@ -93,7 +103,13 @@ test('an accept retries only while the backend says the alert is not there yet', throw new AppError('COMMAND_FAILED', 'runner crashed'); }); - const outcome = actOnAppleAlert(IOS_SIMULATOR, runnerOptions, 'accept').then( + const outcome = actOnAppleAlert( + IOS_SIMULATOR, + runnerOptions, + 'accept', + undefined, + process.env, + ).then( () => undefined, (error: unknown) => error, ); @@ -107,7 +123,13 @@ test('an exhausted accept carries the scoped-snapshot fallback the agent needs n vi.useFakeTimers(); mockRunner.mockRejectedValue(runnerAbsence()); - const outcome = actOnAppleAlert(IOS_SIMULATOR, runnerOptions, 'accept').then( + const outcome = actOnAppleAlert( + IOS_SIMULATOR, + runnerOptions, + 'accept', + undefined, + process.env, + ).then( () => undefined, (error: unknown) => error, ); @@ -130,7 +152,12 @@ test('a wait propagates a non-absence failure instead of spending it as poll bud throw new AppError('COMMAND_FAILED', 'runner transport closed: alert not found'); }); - const outcome = awaitAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 5_000 }).then( + const outcome = awaitAppleAlert( + IOS_SIMULATOR, + runnerOptions, + { timeoutMs: 5_000 }, + process.env, + ).then( () => undefined, (error: unknown) => error, ); @@ -148,7 +175,13 @@ test('an action does not retry a failure that only reads like an absence', async throw new AppError('COMMAND_FAILED', 'no alert service on this runner'); }); - const outcome = actOnAppleAlert(IOS_SIMULATOR, runnerOptions, 'dismiss').then( + const outcome = actOnAppleAlert( + IOS_SIMULATOR, + runnerOptions, + 'dismiss', + undefined, + process.env, + ).then( () => undefined, (error: unknown) => error, ); @@ -168,7 +201,13 @@ test('the macOS helper states absence as a typed reason, and the family retries throw helperAbsence(); }); - const outcome = actOnAppleAlert(MACOS_DEVICE, runnerOptions, 'accept').then( + const outcome = actOnAppleAlert( + MACOS_DEVICE, + runnerOptions, + 'accept', + undefined, + process.env, + ).then( () => undefined, (error: unknown) => error, ); @@ -183,13 +222,21 @@ test('the macOS helper states absence as a typed reason, and the family retries test('the macOS host reads through its helper, and a frontmost-app session names no bundle', async () => { mockHelper.mockResolvedValue({ title: 'Allow access' }); - await readAppleAlert(MACOS_DEVICE, runnerOptions, { - surface: 'frontmost-app', - appBundleId: 'com.example.app', - }); + await readAppleAlert( + MACOS_DEVICE, + runnerOptions, + { + surface: 'frontmost-app', + appBundleId: 'com.example.app', + }, + process.env, + ); assert.equal(mockRunner.mock.calls.length, 0); - assert.deepEqual(mockHelper.mock.calls[0], ['get', { surface: 'frontmost-app' }]); + assert.deepEqual(mockHelper.mock.calls[0], [ + 'get', + { surface: 'frontmost-app', env: process.env }, + ]); }); // The narrowing is the macOS helper's alone. A non-macOS Apple leaf keeps the session bundle @@ -197,10 +244,15 @@ test('the macOS host reads through its helper, and a frontmost-app session names test('the XCTest runner keeps the session bundle even on a frontmost-app surface', async () => { mockRunner.mockResolvedValue({ title: 'Camera Access' }); - await readAppleAlert(IOS_SIMULATOR, runnerOptions, { - surface: 'frontmost-app', - appBundleId: 'com.example.app', - }); + await readAppleAlert( + IOS_SIMULATOR, + runnerOptions, + { + surface: 'frontmost-app', + appBundleId: 'com.example.app', + }, + process.env, + ); assert.deepEqual(mockRunner.mock.calls[0]?.[1], { command: 'alert', @@ -213,13 +265,19 @@ test('the XCTest runner keeps the session bundle even on a frontmost-app surface test('a macOS app session forwards the bundle its surface is scoped to', async () => { mockHelper.mockResolvedValue({ action: 'dismiss' }); - await actOnAppleAlert(MACOS_DEVICE, runnerOptions, 'dismiss', { - surface: 'app', - appBundleId: 'com.example.app', - }); + await actOnAppleAlert( + MACOS_DEVICE, + runnerOptions, + 'dismiss', + { + surface: 'app', + appBundleId: 'com.example.app', + }, + process.env, + ); assert.deepEqual(mockHelper.mock.calls[0], [ 'dismiss', - { bundleId: 'com.example.app', surface: 'app' }, + { bundleId: 'com.example.app', surface: 'app', env: process.env }, ]); }); diff --git a/packages/platform-apple/src/__tests__/index-runner-requests.test.ts b/packages/platform-apple/src/__tests__/index-runner-requests.test.ts index b1c960a62d..447190b4fe 100644 --- a/packages/platform-apple/src/__tests__/index-runner-requests.test.ts +++ b/packages/platform-apple/src/__tests__/index-runner-requests.test.ts @@ -192,15 +192,17 @@ const SCOPED_SITES: Record = { ], 'macos.screenshot-runner.fullscreen': [ MACOS_DEVICE, - (outPath) => captureScreenshotViaRunner(MACOS_DEVICE, outPath, APP, true), + (outPath) => + captureScreenshotViaRunner(MACOS_DEVICE, outPath, APP, true, undefined, process.env), ], 'ios-device.physical-device-screenshot.coredevice-file': [ IOS_DEVICE, - (outPath) => captureScreenshotViaRunner(IOS_DEVICE, outPath, APP, true), + (outPath) => captureScreenshotViaRunner(IOS_DEVICE, outPath, APP, true, undefined, process.env), ], 'ios-device.physical-device-screenshot.xctest-inline': [ XCTEST_DEVICE, - (outPath) => captureScreenshotViaRunner(XCTEST_DEVICE, outPath, APP), + (outPath) => + captureScreenshotViaRunner(XCTEST_DEVICE, outPath, APP, undefined, undefined, process.env), ], 'ios-device.physical-device-control.activate': [ XCTEST_DEVICE, @@ -223,7 +225,12 @@ test('every Apple request site builds exactly its golden runner request', async for (const [name, [device, drive]] of Object.entries(INTERACTOR_SITES)) { const calls: RecordedRunnerCall[] = []; await drive( - createAppleInteractor(device, { appBundleId: APP }, recordingRunnerProvider(calls)), + createAppleInteractor( + device, + { appBundleId: APP }, + { env: process.env, cwd: process.cwd() }, + recordingRunnerProvider(calls), + ), ); captured.push([name, onlyRequest(name, calls)]); } @@ -231,6 +238,7 @@ test('every Apple request site builds exactly its golden runner request', async await createAppleInteractor( IOS_SIMULATOR, { appBundleId: APP }, + { env: process.env, cwd: process.cwd() }, recordingRunnerProvider(observationCalls, { snapshot: { nodes: [{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }], diff --git a/packages/platform-apple/src/__tests__/interactor-macos-surface.test.ts b/packages/platform-apple/src/__tests__/interactor-macos-surface.test.ts index 51cfc9bf92..0aef39c1ae 100644 --- a/packages/platform-apple/src/__tests__/interactor-macos-surface.test.ts +++ b/packages/platform-apple/src/__tests__/interactor-macos-surface.test.ts @@ -113,7 +113,11 @@ function reachedBackend(): MacOsSurfaceBackend { test.each(SURFACE_ROWS)( 'every macOS operation on the %s surface reaches the %s backend', async (surface, backend) => { - const interactor = createAppleInteractor(macOsDevice, {}); + const interactor = createAppleInteractor( + macOsDevice, + {}, + { env: process.env, cwd: process.cwd() }, + ); const operations: Record Promise> = { screenshot: async () => await interactor.screenshot('/tmp/out.png', { surface }), snapshot: async () => await interactor.snapshot({ surface }), @@ -139,7 +143,11 @@ test.each(SURFACE_ROWS)( test.each(HELPER_ROWS)( 'refuses an explicit --fullscreen on the macOS %s surface before any capture', async (surface) => { - const interactor = createAppleInteractor(macOsDevice, {}); + const interactor = createAppleInteractor( + macOsDevice, + {}, + { env: process.env, cwd: process.cwd() }, + ); await expect( interactor.screenshot('/tmp/out.png', { surface, fullscreen: true }), @@ -158,19 +166,27 @@ test.each(HELPER_ROWS)( test.each(HELPER_ROWS)( 'captures the %s surface through the helper when --fullscreen is not requested', async (surface) => { - const interactor = createAppleInteractor(macOsDevice, {}); + const interactor = createAppleInteractor( + macOsDevice, + {}, + { env: process.env, cwd: process.cwd() }, + ); await interactor.screenshot('/tmp/out.png', { surface }); expect(runMacOsScreenshotAction).toHaveBeenCalledOnce(); const [, options] = vi.mocked(runMacOsScreenshotAction).mock.calls[0]!; - expect(options).toEqual({ surface }); + expect(options).toEqual({ surface, env: process.env }); expect(Object.hasOwn(options ?? {}, 'fullscreen')).toBe(false); }, ); test('keeps a macOS app session on the runner path with --fullscreen unchanged', async () => { - const interactor = createAppleInteractor(macOsDevice, {}); + const interactor = createAppleInteractor( + macOsDevice, + {}, + { env: process.env, cwd: process.cwd() }, + ); await interactor.screenshot('/tmp/out.png', { surface: 'app', fullscreen: true }); diff --git a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts index 852b759597..bdf35eb670 100644 --- a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts +++ b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createLiveTestHostEnvironment } from '@agent-device/test-kit'; import type { Interactor, RunnerContext, @@ -104,7 +104,12 @@ const LOCAL_TOOL_METHODS: Record Promise { - const interactor = createAppleInteractor(IOS_SIMULATOR, {}, recordingRunnerProvider([])); + const interactor = createAppleInteractor( + IOS_SIMULATOR, + {}, + createLiveTestHostEnvironment(), + recordingRunnerProvider([]), + ); const classified = [ ...Object.keys(RUNNER_TRANSPORT_METHODS), ...Object.keys(LOCAL_TOOL_METHODS), @@ -117,6 +122,7 @@ test('provider-backed interactor routes runner-command methods through the injec const interactor = createAppleInteractor( IOS_SIMULATOR, { appBundleId: 'com.example.app' }, + createLiveTestHostEnvironment(), recordingRunnerProvider(calls), ); for (const [method, { invoke, runnerCommand }] of Object.entries(RUNNER_TRANSPORT_METHODS)) { @@ -128,7 +134,12 @@ test('provider-backed interactor routes runner-command methods through the injec }); test('provider-backed interactor rejects local Apple tooling methods with a clear error', async () => { - const interactor = createAppleInteractor(IOS_SIMULATOR, {}, recordingRunnerProvider([])); + const interactor = createAppleInteractor( + IOS_SIMULATOR, + {}, + createLiveTestHostEnvironment(), + recordingRunnerProvider([]), + ); for (const [method, invoke] of Object.entries(LOCAL_TOOL_METHODS)) { await assert.rejects( invoke(interactor), @@ -147,6 +158,7 @@ test('injected transport still resolves when the runner context carries a reques const interactor = createAppleInteractor( IOS_SIMULATOR, runnerContext, + createLiveTestHostEnvironment(), recordingRunnerProvider(calls), ); await interactor.tap(10, 20); @@ -160,6 +172,7 @@ test('injected transport receives the active interaction cancellation signal', a const interactor = createAppleInteractor( IOS_SIMULATOR, { signal: controller.signal }, + createLiveTestHostEnvironment(), recordingRunnerProvider(calls), ); @@ -175,6 +188,7 @@ test('snapshot merges its per-call cancellation signal with the interaction cont const interactor = createAppleInteractor( IOS_SIMULATOR, { signal: contextController.signal }, + createLiveTestHostEnvironment(), recordingRunnerProvider(calls), ); @@ -187,78 +201,79 @@ test('snapshot merges its per-call cancellation signal with the interaction cont }); test('snapshot over the injected transport keeps the shared xctest result shape', async () => { - const interactor = createAppleInteractor(IOS_SIMULATOR, {}, recordingRunnerProvider([])); + const interactor = createAppleInteractor( + IOS_SIMULATOR, + {}, + createLiveTestHostEnvironment(), + recordingRunnerProvider([]), + ); const result = presentedSnapshot(await interactor.snapshot()); assert.equal(result.backend, 'xctest'); assert.equal(result.nodes?.length, 2); }); test('snapshot publishes runner presentation through the engine and drops its quality view', async () => { - const interactor = createAppleInteractor( - IOS_SIMULATOR, - {}, - { - hasLiveSession: () => true, - runCommand: async () => ({ + const interactor = createAppleInteractor(IOS_SIMULATOR, {}, createLiveTestHostEnvironment(), { + hasLiveSession: () => true, + runCommand: async () => ({ + nodes: [ + { + index: 0, + depth: 0, + type: 'Application', + label: 'App', + rect: { x: 0, y: 0, width: 390, height: 844 }, + }, + { + index: 1, + depth: 1, + parentIndex: 0, + type: 'Table', + label: 'Settings', + rect: { x: 0, y: 40, width: 390, height: 804 }, + }, + { + index: 2, + depth: 2, + parentIndex: 1, + type: 'Cell', + label: 'General', + rect: { x: 16, y: 80, width: 358, height: 52 }, + }, + { + index: 3, + depth: 3, + parentIndex: 2, + type: 'Button', + label: 'General', + rect: { x: 16, y: 80, width: 358, height: 52 }, + hittable: true, + }, + { + index: 4, + depth: 4, + parentIndex: 3, + type: 'StaticText', + label: 'General', + rect: { x: 16, y: 80, width: 358, height: 52 }, + }, + ], + truncated: false, + snapshotQuality: { state: 'healthy', backend: 'tree' }, + qualityPayload: { nodes: [ { index: 0, - depth: 0, type: 'Application', label: 'App', rect: { x: 0, y: 0, width: 390, height: 844 }, }, - { - index: 1, - depth: 1, - parentIndex: 0, - type: 'Table', - label: 'Settings', - rect: { x: 0, y: 40, width: 390, height: 804 }, - }, - { - index: 2, - depth: 2, - parentIndex: 1, - type: 'Cell', - label: 'General', - rect: { x: 16, y: 80, width: 358, height: 52 }, - }, - { - index: 3, - depth: 3, - parentIndex: 2, - type: 'Button', - label: 'General', - rect: { x: 16, y: 80, width: 358, height: 52 }, - hittable: true, - }, - { - index: 4, - depth: 4, - parentIndex: 3, - type: 'StaticText', - label: 'General', - rect: { x: 16, y: 80, width: 358, height: 52 }, - }, ], truncated: false, - snapshotQuality: { state: 'healthy', backend: 'tree' }, - qualityPayload: { - nodes: [ - { - index: 0, - type: 'Application', - label: 'App', - rect: { x: 0, y: 0, width: 390, height: 844 }, - }, - ], - truncated: false, - scope: null, - }, - }), - }, - ); + scope: null, + }, + }), + }); const result = presentedSnapshot(await interactor.snapshot({ interactiveOnly: true })); @@ -271,22 +286,18 @@ test('snapshot publishes runner presentation through the engine and drops its qu test('a message-less runner capture leaves its disclosures to the verdict', async () => { const coverage = { read: 12, candidates: 19, truncated: 0, blocked: false }; - const interactor = createAppleInteractor( - IOS_SIMULATOR, - {}, - { - hasLiveSession: () => true, - runCommand: async () => ({ - ...runnerResultFor({ command: 'snapshot' }), - snapshotQuality: { - state: 'healthy', - backend: 'tree', - customActions: coverage, - collapsedLeafIndexes: [1], - }, - }), - }, - ); + const interactor = createAppleInteractor(IOS_SIMULATOR, {}, createLiveTestHostEnvironment(), { + hasLiveSession: () => true, + runCommand: async () => ({ + ...runnerResultFor({ command: 'snapshot' }), + snapshotQuality: { + state: 'healthy', + backend: 'tree', + customActions: coverage, + collapsedLeafIndexes: [1], + }, + }), + }); const result = presentedSnapshot(await interactor.snapshot()); @@ -297,11 +308,10 @@ test('a message-less runner capture leaves its disclosures to the verdict', asyn test('macOS app snapshots preserve runner nodes outside the iOS presentation engine', async () => { const nodes = [{ index: 0, type: 'Other', value: 'checkbox, unchecked' }]; - const interactor = createAppleInteractor( - MACOS_DEVICE, - {}, - { hasLiveSession: () => true, runCommand: async () => ({ nodes }) }, - ); + const interactor = createAppleInteractor(MACOS_DEVICE, {}, createLiveTestHostEnvironment(), { + hasLiveSession: () => true, + runCommand: async () => ({ nodes }), + }); const result = presentedSnapshot(await interactor.snapshot({ interactiveOnly: true })); @@ -309,14 +319,10 @@ test('macOS app snapshots preserve runner nodes outside the iOS presentation eng }); test('snapshot reports typed runner presentation failures', async () => { - const interactor = createAppleInteractor( - IOS_SIMULATOR, - {}, - { - hasLiveSession: () => true, - runCommand: async () => ({ nodes: [{ index: 0, type: 'Application' }] }), - }, - ); + const interactor = createAppleInteractor(IOS_SIMULATOR, {}, createLiveTestHostEnvironment(), { + hasLiveSession: () => true, + runCommand: async () => ({ nodes: [{ index: 0, type: 'Application' }] }), + }); await assert.rejects(interactor.snapshot(), (error: unknown) => { assert.ok(error instanceof AppError); @@ -327,34 +333,30 @@ test('snapshot reports typed runner presentation failures', async () => { }); test('sparse runner payloads with no viewport fail before publishing actionable nodes', async () => { - const interactor = createAppleInteractor( - IOS_SIMULATOR, - {}, - { - hasLiveSession: () => true, - runCommand: async () => ({ - nodes: [ - { index: 0, type: 'Application', label: 'App' }, - { - index: 1, - parentIndex: 0, - type: 'Button', - label: 'Escaped action', - rect: { x: 10, y: 10, width: 80, height: 40 }, - hittable: true, - }, - ], - truncated: true, - snapshotQuality: { - state: 'sparse', - backend: 'tree', - reason: 'no usable snapshot backend', - reasonCode: 'sparse-tree', + const interactor = createAppleInteractor(IOS_SIMULATOR, {}, createLiveTestHostEnvironment(), { + hasLiveSession: () => true, + runCommand: async () => ({ + nodes: [ + { index: 0, type: 'Application', label: 'App' }, + { + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Escaped action', + rect: { x: 10, y: 10, width: 80, height: 40 }, + hittable: true, }, - systemSurface: { bundleId: 'com.apple.SafariViewService', kind: 'web-auth' }, - }), - }, - ); + ], + truncated: true, + snapshotQuality: { + state: 'sparse', + backend: 'tree', + reason: 'no usable snapshot backend', + reasonCode: 'sparse-tree', + }, + systemSurface: { bundleId: 'com.apple.SafariViewService', kind: 'web-auth' }, + }), + }); await assert.rejects(interactor.snapshot(), (error: unknown) => { assert.ok(error instanceof AppError); @@ -377,17 +379,13 @@ test('sparse runner payloads with no viewport fail before publishing actionable }); test('snapshot rejects a scoped quality payload at the runner boundary', async () => { - const interactor = createAppleInteractor( - IOS_SIMULATOR, - {}, - { - hasLiveSession: () => true, - runCommand: async () => ({ - nodes: [{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }], - qualityPayload: { nodes: [], truncated: false, scope: 'Settings' }, - }), - }, - ); + const interactor = createAppleInteractor(IOS_SIMULATOR, {}, createLiveTestHostEnvironment(), { + hasLiveSession: () => true, + runCommand: async () => ({ + nodes: [{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }], + qualityPayload: { nodes: [], truncated: false, scope: 'Settings' }, + }), + }); await assert.rejects( interactor.snapshot(), @@ -406,7 +404,12 @@ test('snapshot accepts only structured healthy empty scope results', async () => snapshotQuality: { state: 'healthy', backend: 'tree' }, }), }; - const interactor = createAppleInteractor(IOS_SIMULATOR, {}, healthyEmptyProvider); + const interactor = createAppleInteractor( + IOS_SIMULATOR, + {}, + createLiveTestHostEnvironment(), + healthyEmptyProvider, + ); const scoped = presentedSnapshot(await interactor.snapshot({ scope: 'missing' })); assert.deepEqual(scoped.nodes, []); @@ -420,14 +423,10 @@ test('snapshot accepts only structured healthy empty scope results', async () => error.message === 'XCTest snapshot returned 0 nodes on iOS simulator.', ); - const legacyEmpty = createAppleInteractor( - IOS_SIMULATOR, - {}, - { - hasLiveSession: () => true, - runCommand: async () => ({ nodes: [] }), - }, - ); + const legacyEmpty = createAppleInteractor(IOS_SIMULATOR, {}, createLiveTestHostEnvironment(), { + hasLiveSession: () => true, + runCommand: async () => ({ nodes: [] }), + }); await assert.rejects(legacyEmpty.snapshot({ scope: 'missing' }), { code: 'COMMAND_FAILED', }); @@ -439,7 +438,12 @@ test('snapshot accepts only structured healthy empty scope results', async () => // forwarding preferredBackend into the emitted RunnerCommand. test('snapshot forwards either forceable preferredBackend into the emitted runner command', async () => { const calls: RecordedRunnerCall[] = []; - const interactor = createAppleInteractor(IOS_SIMULATOR, {}, recordingRunnerProvider(calls)); + const interactor = createAppleInteractor( + IOS_SIMULATOR, + {}, + createLiveTestHostEnvironment(), + recordingRunnerProvider(calls), + ); await interactor.snapshot({ preferredBackend: 'tree' }); await interactor.snapshot(); diff --git a/packages/platform-apple/src/__tests__/interactor-screenshot-backend.test.ts b/packages/platform-apple/src/__tests__/interactor-screenshot-backend.test.ts index 6cbc9569cb..9f7d7c6939 100644 --- a/packages/platform-apple/src/__tests__/interactor-screenshot-backend.test.ts +++ b/packages/platform-apple/src/__tests__/interactor-screenshot-backend.test.ts @@ -28,7 +28,11 @@ beforeEach(() => { }); test('routes an internal runner screenshot without changing the default backend', async () => { - const interactor = createAppleInteractor(device, { appBundleId: 'com.example.app' }); + const interactor = createAppleInteractor( + device, + { appBundleId: 'com.example.app' }, + { env: process.env, cwd: process.cwd() }, + ); await interactor.screenshot('/tmp/default.png'); await interactor.screenshot('/tmp/runner.png', { @@ -49,5 +53,6 @@ test('routes an internal runner screenshot without changing the default backend' 'com.example.app', undefined, expect.objectContaining({}), + process.env, ); }); diff --git a/packages/platform-apple/src/__tests__/interactor-snapshot-viewport.test.ts b/packages/platform-apple/src/__tests__/interactor-snapshot-viewport.test.ts index e55ebe643b..dd0dec7733 100644 --- a/packages/platform-apple/src/__tests__/interactor-snapshot-viewport.test.ts +++ b/packages/platform-apple/src/__tests__/interactor-snapshot-viewport.test.ts @@ -14,6 +14,7 @@ test('an Apple runner capture publishes the app-window box its rects are measure const interactor = createAppleInteractor( IOS_SIMULATOR, {}, + { env: process.env, cwd: process.cwd() }, { hasLiveSession: () => true, runCommand: async () => runnerResultFor({ command: 'snapshot' }), @@ -40,6 +41,7 @@ test('a macOS app capture publishes no viewport (#3182)', async () => { const interactor = createAppleInteractor( MACOS_DEVICE, {}, + { env: process.env, cwd: process.cwd() }, { hasLiveSession: () => true, runCommand: async () => ({ nodes }) }, ); diff --git a/packages/platform-apple/src/__tests__/interactor-target-activation.test.ts b/packages/platform-apple/src/__tests__/interactor-target-activation.test.ts index f6cd4ba331..7119b7fd17 100644 --- a/packages/platform-apple/src/__tests__/interactor-target-activation.test.ts +++ b/packages/platform-apple/src/__tests__/interactor-target-activation.test.ts @@ -31,7 +31,12 @@ function interactorServing(payload: Record) { hasLiveSession: () => true, runCommand: async () => payload, }; - return createAppleInteractor(IOS_SIMULATOR, { appBundleId: 'com.example.app' }, runnerProvider); + return createAppleInteractor( + IOS_SIMULATOR, + { appBundleId: 'com.example.app' }, + { env: process.env, cwd: process.cwd() }, + runnerProvider, + ); } test('a capture whose own command repaired foreground discloses it and keeps the fact', async () => { @@ -71,7 +76,12 @@ test.each([ return { ...HEALTHY_TREE, observation }; }, }; - const snapshot = (await createAppleInteractor(device, {}, runnerProvider).snapshot({ + const snapshot = (await createAppleInteractor( + device, + {}, + { env: process.env, cwd: process.cwd() }, + runnerProvider, + ).snapshot({ appBundleId: 'com.example.app', observeOnly: true, })) as SnapshotResult; diff --git a/packages/platform-apple/src/__tests__/interactor-watchos-sentinel.test.ts b/packages/platform-apple/src/__tests__/interactor-watchos-sentinel.test.ts index 4d94f82dfc..a438f45faf 100644 --- a/packages/platform-apple/src/__tests__/interactor-watchos-sentinel.test.ts +++ b/packages/platform-apple/src/__tests__/interactor-watchos-sentinel.test.ts @@ -17,7 +17,11 @@ const watchOsDevice: DeviceInfo = { test('createAppleInteractor rejects a watchOS device as UNSUPPORTED_PLATFORM', () => { // The guard throws before touching runnerContext, so an empty context is fine. - const create = () => createAppleInteractor(watchOsDevice, {} as RunnerContext); + const create = () => + createAppleInteractor(watchOsDevice, {} as RunnerContext, { + env: process.env, + cwd: process.cwd(), + }); expect(create).toThrow(AppError); try { create(); @@ -43,5 +47,10 @@ test('a non-watchOS appleOs does not trigger the watchOS sentinel', () => { appleOs: 'tvos', booted: true, }; - expect(createAppleInteractor(tvOsDevice, {} as RunnerContext)).toBeTruthy(); + expect( + createAppleInteractor(tvOsDevice, {} as RunnerContext, { + env: process.env, + cwd: process.cwd(), + }), + ).toBeTruthy(); }); diff --git a/packages/platform-apple/src/alert.ts b/packages/platform-apple/src/alert.ts index dd9adfe3db..baa33228ce 100644 --- a/packages/platform-apple/src/alert.ts +++ b/packages/platform-apple/src/alert.ts @@ -10,6 +10,7 @@ import type { RunnerCallOptions, } from '@agent-device/contracts/interactor-types'; import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { AppError } from '@agent-device/kernel/errors'; import { sleep } from '@agent-device/host-kit/retry'; import { runAppleRunnerCommand } from './core/runner-client.ts'; @@ -40,13 +41,15 @@ function runAppleAlert( device: DeviceInfo, runnerOptions: RunnerCallOptions, options: AlertInteractorOptions | undefined, + env: Readonly, ): (action: NativeAlertAction, timeoutMs: number) => Promise> { if (isMacOs(device)) { const target = options?.surface === 'frontmost-app' ? { surface: options.surface } : { bundleId: options?.appBundleId, surface: options?.surface }; - return async (action) => (await runMacOsAlertAction(action, target)) as Record; + return async (action) => + (await runMacOsAlertAction(action, { ...target, env })) as Record; } return async (action, timeoutMs) => (await runAppleRunnerCommand( @@ -69,9 +72,10 @@ export async function alertIfPresent(read: Promise): Promise, ): Promise> { - return await runAppleAlert(device, runnerOptions, options)('get', DEFAULT_ALERT_TIMEOUT_MS); + return await runAppleAlert(device, runnerOptions, options, env)('get', DEFAULT_ALERT_TIMEOUT_MS); } /** @@ -81,9 +85,10 @@ export async function readAppleAlert( export async function awaitAppleAlert( device: DeviceInfo, runnerOptions: RunnerCallOptions, - options?: AlertInteractorOptions, + options: AlertInteractorOptions | undefined, + env: Readonly, ): Promise> { - const runAlert = runAppleAlert(device, runnerOptions, options); + const runAlert = runAppleAlert(device, runnerOptions, options, env); const timeout = options?.timeoutMs ?? DEFAULT_ALERT_TIMEOUT_MS; const start = Date.now(); let firstAttempt = true; @@ -114,9 +119,10 @@ export async function actOnAppleAlert( device: DeviceInfo, runnerOptions: RunnerCallOptions, action: 'accept' | 'dismiss', - options?: AlertInteractorOptions, + options: AlertInteractorOptions | undefined, + env: Readonly, ): Promise> { - const runAlert = runAppleAlert(device, runnerOptions, options); + const runAlert = runAppleAlert(device, runnerOptions, options, env); const runnerTimeoutMs = isIosFamily(device) ? DEFAULT_ALERT_TIMEOUT_MS : ALERT_ACTION_RETRY_MS; const start = Date.now(); let lastError: unknown; diff --git a/packages/platform-apple/src/core/__tests__/app-launch-close-quit.test.ts b/packages/platform-apple/src/core/__tests__/app-launch-close-quit.test.ts index 02465cef9a..92a0d330e4 100644 --- a/packages/platform-apple/src/core/__tests__/app-launch-close-quit.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-launch-close-quit.test.ts @@ -52,7 +52,7 @@ test('closeIosApp on macOS uses helper quit for bundle identifiers', async () => process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; try { - await closeIosApp(MACOS_TEST_DEVICE, 'com.example.foobar'); + await closeIosApp(MACOS_TEST_DEVICE, 'com.example.foobar', undefined, process.env); const logged = await fs.readFile(argsLogPath, 'utf8'); assert.equal(logged, 'app\nquit\n--bundle-id\ncom.example.foobar\n'); } finally { @@ -68,7 +68,7 @@ test('closeIosApp on iOS simulator bounds simctl terminate', async () => { mockEnsureBootedSimulator.mockResolvedValue(undefined); mockRunCmd.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); - await closeIosApp(IOS_TEST_SIMULATOR, 'com.example.foobar'); + await closeIosApp(IOS_TEST_SIMULATOR, 'com.example.foobar', undefined, process.env); assert.equal(mockRunCmd.mock.calls.length, 1); assert.equal(mockRunCmd.mock.calls[0]?.[0], 'xcrun'); diff --git a/packages/platform-apple/src/core/__tests__/app-launch-close.test.ts b/packages/platform-apple/src/core/__tests__/app-launch-close.test.ts index 9471a4eda8..af8ae36a21 100644 --- a/packages/platform-apple/src/core/__tests__/app-launch-close.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-launch-close.test.ts @@ -47,7 +47,7 @@ test('closeIosApp terminates a physical iOS app by its resolved process ID', asy }); await withAppleToolProvider(provider, async () => { - await closeIosApp(IOS_DEVICE, APP_BUNDLE_ID); + await closeIosApp(IOS_DEVICE, APP_BUNDLE_ID, undefined, process.env); }); const terminateCall = calls.at(-1); @@ -67,7 +67,7 @@ test('closeIosApp terminates a physical iOS app by its resolved process ID', asy test('closeIosApp tolerates the process exiting after PID resolution', async () => { await withAppleToolProvider(createTerminateFailureProvider(3), async () => { - await closeIosApp(IOS_DEVICE, APP_BUNDLE_ID); + await closeIosApp(IOS_DEVICE, APP_BUNDLE_ID, undefined, process.env); }); }); @@ -75,7 +75,7 @@ test('closeIosApp preserves termination failures other than an exited process', await assert.rejects( () => withAppleToolProvider(createTerminateFailureProvider(1), async () => { - await closeIosApp(IOS_DEVICE, APP_BUNDLE_ID); + await closeIosApp(IOS_DEVICE, APP_BUNDLE_ID, undefined, process.env); }), (error: unknown) => { assert.ok(error instanceof AppError); @@ -109,7 +109,7 @@ test('closeIosApp reports the required devicectl process-list API', async () => await assert.rejects( () => withAppleToolProvider(provider, async () => { - await closeIosApp(IOS_DEVICE, APP_BUNDLE_ID); + await closeIosApp(IOS_DEVICE, APP_BUNDLE_ID, undefined, process.env); }), (error: unknown) => { assert.ok(error instanceof AppError); @@ -144,7 +144,7 @@ test('closeIosApp fails closed when devicectl changes the process-list response await assert.rejects( () => withAppleToolProvider(provider, async () => { - await closeIosApp(IOS_DEVICE, APP_BUNDLE_ID); + await closeIosApp(IOS_DEVICE, APP_BUNDLE_ID, undefined, process.env); }), (error: unknown) => { assert.ok(error instanceof AppError); diff --git a/packages/platform-apple/src/core/__tests__/app-launch-system-surface.test.ts b/packages/platform-apple/src/core/__tests__/app-launch-system-surface.test.ts index 6f10cfcc0b..f75e52bea8 100644 --- a/packages/platform-apple/src/core/__tests__/app-launch-system-surface.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-launch-system-surface.test.ts @@ -74,7 +74,7 @@ test.for<[string, OpenOptions]>([ // Terminating the host cancels the presented session just as launching it does. test('close refuses to terminate a system-surface host', async () => { await assert.rejects( - closeIosApp(IOS_TEST_SIMULATOR, 'com.apple.SafariViewService'), + closeIosApp(IOS_TEST_SIMULATOR, 'com.apple.SafariViewService', undefined, process.env), (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.details?.reason, 'system-surface-host-not-openable'); diff --git a/packages/platform-apple/src/core/__tests__/app-settings.test.ts b/packages/platform-apple/src/core/__tests__/app-settings.test.ts index 31d4a82120..ad5271acec 100644 --- a/packages/platform-apple/src/core/__tests__/app-settings.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-settings.test.ts @@ -110,7 +110,7 @@ async function collectBiometricCalls( return unexpectedArgs(args); }, async () => { - await setIosSetting(IOS_TEST_SIMULATOR, setting, state); + await setIosSetting(IOS_TEST_SIMULATOR, setting, state, undefined, undefined, process.env); }, ); return calls; @@ -152,7 +152,8 @@ test('setIosSetting biometric enroll fails when the read-back state did not chan }, async () => { await assert.rejects( - () => setIosSetting(IOS_TEST_SIMULATOR, 'faceid', 'enroll'), + () => + setIosSetting(IOS_TEST_SIMULATOR, 'faceid', 'enroll', undefined, undefined, process.env), (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.code, 'COMMAND_FAILED'); @@ -172,10 +173,14 @@ test('setIosSetting biometric rejects an unknown state before spawning anything' return unexpectedArgs(args); }, async () => { - await assertRejectsAppError(() => setIosSetting(IOS_TEST_SIMULATOR, 'faceid', 'toggle'), { - code: 'INVALID_ARGS', - message: /Use match\|nonmatch\|enroll\|unenroll/, - }); + await assertRejectsAppError( + () => + setIosSetting(IOS_TEST_SIMULATOR, 'faceid', 'toggle', undefined, undefined, process.env), + { + code: 'INVALID_ARGS', + message: /Use match\|nonmatch\|enroll\|unenroll/, + }, + ); }, ); }); @@ -188,7 +193,8 @@ test('setIosSetting touchid reports COMMAND_FAILED with the notifyutil attempt w }, async () => { await assert.rejects( - () => setIosSetting(IOS_TEST_SIMULATOR, 'touchid', 'match'), + () => + setIosSetting(IOS_TEST_SIMULATOR, 'touchid', 'match', undefined, undefined, process.env), (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.code, 'COMMAND_FAILED'); @@ -213,7 +219,14 @@ test('setIosSetting appearance toggle queries current osascript appearance on ma return unexpectedArgs(args); }, async ({ calls }) => { - await setIosSetting(MACOS_TEST_DEVICE, 'appearance', 'toggle'); + await setIosSetting( + MACOS_TEST_DEVICE, + 'appearance', + 'toggle', + undefined, + undefined, + process.env, + ); const flat = calls.map((args) => args.join(' ')); assert.equal( flat.some((line) => line.includes('get dark mode')), @@ -245,9 +258,16 @@ test('setIosSetting permission grant accessibility uses macOS helper', async () process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; try { - const result = await setIosSetting(MACOS_TEST_DEVICE, 'permission', 'grant', undefined, { - permissionTarget: 'accessibility', - }); + const result = await setIosSetting( + MACOS_TEST_DEVICE, + 'permission', + 'grant', + undefined, + { + permissionTarget: 'accessibility', + }, + process.env, + ); const logged = await fs.readFile(argsLogPath, 'utf8'); assert.equal(logged, 'permission\ngrant\naccessibility\n'); assert.deepEqual(result, { @@ -269,9 +289,16 @@ test('setIosSetting permission grant accessibility uses macOS helper', async () test('setIosSetting rejects unsupported macOS permission deny action', async () => { await assertRejectsAppError( () => - setIosSetting(MACOS_TEST_DEVICE, 'permission', 'deny', undefined, { - permissionTarget: 'accessibility', - }), + setIosSetting( + MACOS_TEST_DEVICE, + 'permission', + 'deny', + undefined, + { + permissionTarget: 'accessibility', + }, + process.env, + ), { code: 'INVALID_ARGS', message: /grant\|reset, not deny/ }, ); }); @@ -282,10 +309,17 @@ test('setIosSetting location runs the simctl plan on the simulator udid and retu await withFakeAppleTool( () => '', async ({ calls }) => { - const result = await setIosSetting(IOS_TEST_SIMULATOR, 'location', 'set', undefined, { - latitude: 37.3349, - longitude: -122.009, - }); + const result = await setIosSetting( + IOS_TEST_SIMULATOR, + 'location', + 'set', + undefined, + { + latitude: 37.3349, + longitude: -122.009, + }, + process.env, + ); assert.deepEqual(calls, [['simctl', 'location', 'sim-1', 'set', '37.3349,-122.009']]); assert.deepEqual(result, { latitude: 37.3349, longitude: -122.009 }); }, @@ -299,7 +333,15 @@ test('setIosSetting clear-app-state refuses an appless session with the publishe (args) => unexpectedArgs(args), async () => { await assertRejectsAppError( - () => setIosSetting(IOS_TEST_SIMULATOR, 'clear-app-state', 'clear'), + () => + setIosSetting( + IOS_TEST_SIMULATOR, + 'clear-app-state', + 'clear', + undefined, + undefined, + process.env, + ), { code: 'INVALID_ARGS', reason: PRE_DISPATCH_REFUSAL_REASONS.sessionAppRequired, @@ -320,7 +362,14 @@ test('setIosSetting appearance runs the simctl plan on the simulator udid and re return unexpectedArgs(args); }, async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'appearance', 'toggle'); + await setIosSetting( + IOS_TEST_SIMULATOR, + 'appearance', + 'toggle', + undefined, + undefined, + process.env, + ); assert.deepEqual(calls, [ ['simctl', 'ui', 'sim-1', 'appearance'], ['simctl', 'ui', 'sim-1', 'appearance', 'light'], @@ -352,6 +401,8 @@ test('setIosSetting clear-app-state refuses a system app whose simctl answer is 'clear-app-state', 'clear', 'com.apple.Preferences', + undefined, + process.env, ).catch((error: unknown) => error); assert.ok(thrown instanceof AppError); assert.equal(thrown.code, 'UNSUPPORTED_OPERATION'); @@ -396,7 +447,14 @@ test('setIosSetting clear-app-state refuses an empty container answer with the s async () => { await assertRejectsAppError( () => - setIosSetting(IOS_TEST_SIMULATOR, 'clear-app-state', 'clear', 'com.apple.Preferences'), + setIosSetting( + IOS_TEST_SIMULATOR, + 'clear-app-state', + 'clear', + 'com.apple.Preferences', + undefined, + process.env, + ), { code: 'UNSUPPORTED_OPERATION', reason: IOS_NO_DATA_CONTAINER_REASON, @@ -440,6 +498,8 @@ test('setIosSetting clear-app-state leaves a fresh-install data container layout 'clear-app-state', 'clear', 'com.example.app', + undefined, + process.env, ); assert.equal(result?.cleared, true); assert.equal(result?.bundleId, 'com.example.app'); @@ -473,7 +533,14 @@ test('setIosSetting reset-keychain resets the whole simulator keychain', async ( return unexpectedArgs(args); }, async ({ calls }) => { - const result = await setIosSetting(IOS_TEST_SIMULATOR, 'reset-keychain', 'clear'); + const result = await setIosSetting( + IOS_TEST_SIMULATOR, + 'reset-keychain', + 'clear', + undefined, + undefined, + process.env, + ); assert.equal(result?.cleared, true); assert.equal(result?.scope, 'simulator'); @@ -491,7 +558,15 @@ test('setIosSetting reset-keychain rejects unsupported state', async () => { }, async () => { await assertRejectsAppError( - () => setIosSetting(IOS_TEST_SIMULATOR, 'reset-keychain', 'nope'), + () => + setIosSetting( + IOS_TEST_SIMULATOR, + 'reset-keychain', + 'nope', + undefined, + undefined, + process.env, + ), { code: 'INVALID_ARGS', message: /reset-keychain only supports clear/, @@ -515,9 +590,16 @@ test('setIosSetting permission runs the simctl plan on the simulator udid and su async ({ calls }) => { await assertRejectsAppError( () => - setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'deny', 'com.example.app', { - permissionTarget: 'notifications', - }), + setIosSetting( + IOS_TEST_SIMULATOR, + 'permission', + 'deny', + 'com.example.app', + { + permissionTarget: 'notifications', + }, + process.env, + ), { code: 'UNSUPPORTED_OPERATION', message: /does not support setting notifications permission/i, @@ -545,9 +627,16 @@ test('setIosSetting permission grants the bundle id a display name resolves to', mockEnsureBootedSimulator.mockResolvedValue(undefined); await withFakeAppleTool(installedAppsTool, async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'grant', 'Maps', { - permissionTarget: 'photos', - }); + await setIosSetting( + IOS_TEST_SIMULATOR, + 'permission', + 'grant', + 'Maps', + { + permissionTarget: 'photos', + }, + process.env, + ); assert.deepEqual(calls, [ ['simctl', 'listapps', 'sim-1'], ['simctl', 'privacy', 'sim-1', 'grant', 'photos', 'com.apple.Maps'], @@ -559,7 +648,14 @@ test('setIosSetting location grants an installed bundle id as named', async () = mockEnsureBootedSimulator.mockResolvedValue(undefined); await withFakeAppleTool(installedAppsTool, async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'location', 'on', 'com.example.located'); + await setIosSetting( + IOS_TEST_SIMULATOR, + 'location', + 'on', + 'com.example.located', + undefined, + process.env, + ); assert.deepEqual(calls, [ ['simctl', 'listapps', 'sim-1'], ['simctl', 'privacy', 'sim-1', 'grant', 'location', 'com.example.located'], @@ -571,9 +667,16 @@ test('setIosSetting permission resolves a dotted display name instead of passing mockEnsureBootedSimulator.mockResolvedValue(undefined); await withFakeAppleTool(installedAppsTool, async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'grant', 'Booking.com', { - permissionTarget: 'camera', - }); + await setIosSetting( + IOS_TEST_SIMULATOR, + 'permission', + 'grant', + 'Booking.com', + { + permissionTarget: 'camera', + }, + process.env, + ); assert.deepEqual(calls.at(-1), [ 'simctl', 'privacy', @@ -598,9 +701,16 @@ test.for([ await withFakeAppleTool(installedAppsTool, async ({ calls }) => { await assertRejectsAppError( () => - setIosSetting(IOS_TEST_SIMULATOR, setting, state, app, { - permissionTarget: 'photos', - }), + setIosSetting( + IOS_TEST_SIMULATOR, + setting, + state, + app, + { + permissionTarget: 'photos', + }, + process.env, + ), { code: 'APP_NOT_INSTALLED', reason: PRE_DISPATCH_REFUSAL_REASONS.appNotInstalled, @@ -628,6 +738,7 @@ test('setIosSetting permission keeps a failed app listing distinct from a missin 'grant', 'com.example.unlisted', { permissionTarget: 'photos' }, + process.env, ).catch((error: unknown) => error); assert.ok(thrown instanceof AppError); assert.equal(thrown.code, 'COMMAND_FAILED'); diff --git a/packages/platform-apple/src/core/__tests__/debug-symbols.test.ts b/packages/platform-apple/src/core/__tests__/debug-symbols.test.ts index a12116f14b..4d9391d618 100644 --- a/packages/platform-apple/src/core/__tests__/debug-symbols.test.ts +++ b/packages/platform-apple/src/core/__tests__/debug-symbols.test.ts @@ -69,7 +69,7 @@ test('symbolicates Apple text crash frames with a matching dSYM UUID', async () } return undefined; }, - async () => await symbolicateCrashArtifact({ artifact, dsym, out }), + async () => await symbolicateCrashArtifact({ cwd: process.cwd(), artifact, dsym, out }), ); const output = await fs.readFile(out, 'utf8'); @@ -132,7 +132,8 @@ test('matches dSYMs discovered under search path and symbolicates IPS frames', a } return undefined; }, - async () => await symbolicateCrashArtifact({ artifact, searchPath: buildDir, out }), + async () => + await symbolicateCrashArtifact({ cwd: process.cwd(), artifact, searchPath: buildDir, out }), ); const output = JSON.parse(await fs.readFile(out, 'utf8')) as any; @@ -183,7 +184,7 @@ test('preserves modern two-document IPS headers while symbolicating payload fram } return undefined; }, - async () => await symbolicateCrashArtifact({ artifact, dsym, out }), + async () => await symbolicateCrashArtifact({ cwd: process.cwd(), artifact, dsym, out }), ); const output = await fs.readFile(out, 'utf8'); @@ -239,7 +240,7 @@ test('reports a UUID mismatch with actionable details', async () => { } return undefined; }, - async () => await symbolicateCrashArtifact({ artifact, dsym }), + async () => await symbolicateCrashArtifact({ cwd: process.cwd(), artifact, dsym }), ), ); @@ -270,7 +271,9 @@ test('normalizes malformed IPS numeric fields into AppErrors', async () => { }), ); - const error = await readRejectedError(symbolicateCrashArtifact({ artifact, dsym })); + const error = await readRejectedError( + symbolicateCrashArtifact({ cwd: process.cwd(), artifact, dsym }), + ); assert.ok(error instanceof AppError); assert.equal(error.code, 'INVALID_ARGS'); @@ -291,7 +294,9 @@ test('normalizes malformed IPS image bases into AppErrors', async () => { }), ); - const error = await readRejectedError(symbolicateCrashArtifact({ artifact, dsym })); + const error = await readRejectedError( + symbolicateCrashArtifact({ cwd: process.cwd(), artifact, dsym }), + ); assert.ok(error instanceof AppError); assert.equal(error.code, 'INVALID_ARGS'); @@ -310,7 +315,9 @@ test('rejects nonexistent search paths with an actionable invalid-args hint', as ), ); - const error = await readRejectedError(symbolicateCrashArtifact({ artifact, searchPath })); + const error = await readRejectedError( + symbolicateCrashArtifact({ cwd: process.cwd(), artifact, searchPath }), + ); assert.ok(error instanceof AppError); assert.equal(error.code, 'INVALID_ARGS'); @@ -326,7 +333,9 @@ test('rejects oversized crash artifacts before loading them into memory', async await fs.writeFile(artifact, ''); await fs.truncate(artifact, 64 * 1024 * 1024 + 1); - const error = await readRejectedError(symbolicateCrashArtifact({ artifact, dsym })); + const error = await readRejectedError( + symbolicateCrashArtifact({ cwd: process.cwd(), artifact, dsym }), + ); assert.ok(error instanceof AppError); assert.equal(error.code, 'INVALID_ARGS'); @@ -383,7 +392,7 @@ test('uses address ranges for duplicate text crash image names and supports arm6 } return undefined; }, - async () => await symbolicateCrashArtifact({ artifact, dsym }), + async () => await symbolicateCrashArtifact({ cwd: process.cwd(), artifact, dsym }), ); assert.equal(result.symbolicatedFrames, 1); @@ -431,7 +440,7 @@ test('keeps atos output aligned and ignores unsymbolicated address echoes', asyn } return undefined; }, - async () => await symbolicateCrashArtifact({ artifact, dsym, out }), + async () => await symbolicateCrashArtifact({ cwd: process.cwd(), artifact, dsym, out }), ); const output = await fs.readFile(out, 'utf8'); @@ -460,7 +469,7 @@ test('reports missing Apple tools before attempting symbolication', async () => } return undefined; }, - async () => await symbolicateCrashArtifact({ artifact, dsym }), + async () => await symbolicateCrashArtifact({ cwd: process.cwd(), artifact, dsym }), ), ); @@ -482,7 +491,7 @@ test('defers Android crash symbolication with a clear hint', async () => { ); const error = await readRejectedError( - symbolicateCrashArtifact({ artifact, dsym: path.join(dir, 'mapping.txt') }), + symbolicateCrashArtifact({ cwd: process.cwd(), artifact, dsym: path.join(dir, 'mapping.txt') }), ); assert.ok(error instanceof AppError); diff --git a/packages/platform-apple/src/core/__tests__/display-inventory.test.ts b/packages/platform-apple/src/core/__tests__/display-inventory.test.ts index 416048de0f..d36467b29c 100644 --- a/packages/platform-apple/src/core/__tests__/display-inventory.test.ts +++ b/packages/platform-apple/src/core/__tests__/display-inventory.test.ts @@ -591,6 +591,8 @@ describe('captureSimulatorScreenshotWithFallback runner fallback', () => { // can tell the two sources apart rather than passing on a coincidence. const litPanel = displayWith({ pointScale: 2 }); await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/duo.png', { + env: process.env, + skipIosSimulatorBootCheck: true, pixelDensity: 2, deps: { @@ -621,6 +623,8 @@ describe('captureSimulatorScreenshotWithFallback runner fallback', () => { test('keeps the source unmeasured when a runner reports no display facts', async () => { const sourceScales: (number | undefined)[] = []; await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/duo.png', { + env: process.env, + skipIosSimulatorBootCheck: true, pixelDensity: 2, deps: { @@ -646,6 +650,8 @@ describe('captureSimulatorScreenshotWithFallback runner fallback', () => { test('names the resolved panel as the source for a simctl capture', async () => { const sourceScales: (number | undefined)[] = []; await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/duo.png', { + env: process.env, + skipIosSimulatorBootCheck: true, pixelDensity: 2, deps: { diff --git a/packages/platform-apple/src/core/__tests__/physical-device-control.test.ts b/packages/platform-apple/src/core/__tests__/physical-device-control.test.ts index f155fd6c90..ef2dbd2ec9 100644 --- a/packages/platform-apple/src/core/__tests__/physical-device-control.test.ts +++ b/packages/platform-apple/src/core/__tests__/physical-device-control.test.ts @@ -74,7 +74,7 @@ test('app lifecycle uses runner commands for an xctrace-only physical device', a await openIosApp(XCTEST_IOS_DEVICE, 'com.example.app', { appBundleId: 'com.example.app', }); - await closeIosApp(XCTEST_IOS_DEVICE, 'com.example.app'); + await closeIosApp(XCTEST_IOS_DEVICE, 'com.example.app', undefined, process.env); }, ); @@ -110,9 +110,13 @@ test('default interactor screenshots stay in-band without invoking devicectl', a }, { deviceId: XCTEST_IOS_DEVICE.id }, async () => - await createAppleInteractor(XCTEST_IOS_DEVICE, { - appBundleId: 'com.example.app', - }).screenshot(outPath), + await createAppleInteractor( + XCTEST_IOS_DEVICE, + { + appBundleId: 'com.example.app', + }, + { env: process.env, cwd: process.cwd() }, + ).screenshot(outPath), ), ); 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 51a982b47c..740f938fb7 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 @@ -47,7 +47,8 @@ test('copyCoreDeviceRunnerFile reports the last container error', async () => { calls.push(args); return { exitCode: 1, stderr: `container miss ${calls.length}` }; }, - async () => await copyCoreDeviceRunnerFile(IOS_DEVICE, '/tmp/shot.png', '/out/shot.png'), + async () => + await copyCoreDeviceRunnerFile(IOS_DEVICE, '/tmp/shot.png', '/out/shot.png', process.env), { device: IOS_DEVICE }, ), (error: unknown) => { @@ -84,7 +85,14 @@ test('copyCoreDeviceRunnerFile refuses to start once the deadline is spent', asy calls.push(args); return ''; }, - async () => await copyCoreDeviceRunnerFile(IOS_DEVICE, '/tmp/shot.png', '/out/shot.png', 0), + async () => + await copyCoreDeviceRunnerFile( + IOS_DEVICE, + '/tmp/shot.png', + '/out/shot.png', + process.env, + 0, + ), { device: IOS_DEVICE }, ), (error: unknown) => { @@ -109,7 +117,7 @@ async function runCopy(script: FakeAppleToolScript): Promise { return await withFakeAppleTool( script, async ({ calls }) => { - await copyCoreDeviceRunnerFile(IOS_DEVICE, '/tmp/shot.png', '/out/shot.png'); + await copyCoreDeviceRunnerFile(IOS_DEVICE, '/tmp/shot.png', '/out/shot.png', process.env); return calls; }, { device: IOS_DEVICE }, @@ -121,7 +129,8 @@ async function assertCopyFailure(script: FakeAppleToolScript, message: RegExp): async () => await withFakeAppleTool( script, - async () => await copyCoreDeviceRunnerFile(IOS_DEVICE, '/tmp/shot.png', '/out/shot.png'), + async () => + await copyCoreDeviceRunnerFile(IOS_DEVICE, '/tmp/shot.png', '/out/shot.png', process.env), { device: IOS_DEVICE }, ), message, diff --git a/packages/platform-apple/src/core/__tests__/physical-device-screenshot.test.ts b/packages/platform-apple/src/core/__tests__/physical-device-screenshot.test.ts index 0d8566c00a..d69fa26781 100644 --- a/packages/platform-apple/src/core/__tests__/physical-device-screenshot.test.ts +++ b/packages/platform-apple/src/core/__tests__/physical-device-screenshot.test.ts @@ -20,6 +20,8 @@ test('captureCoreDeviceScreenshot invokes devicectl with the current capture-scr }), async () => await captureCoreDeviceScreenshot(IOS_DEVICE, '/tmp/out.png', { + env: process.env, + runRunnerCommand: async () => { throw new Error('devicectl capture should have succeeded without a runner fallback'); }, diff --git a/packages/platform-apple/src/core/__tests__/screenshot-density.test.ts b/packages/platform-apple/src/core/__tests__/screenshot-density.test.ts index b1b8dfdd1a..c3bb6e1783 100644 --- a/packages/platform-apple/src/core/__tests__/screenshot-density.test.ts +++ b/packages/platform-apple/src/core/__tests__/screenshot-density.test.ts @@ -45,8 +45,8 @@ test('screenshotIos caches simulator screen scale per device', async () => { try { await withAppleToolProvider(provider, async () => { const options = { pixelDensity: 3, skipIosSimulatorBootCheck: true } as const; - await screenshotIos(device, outPath, options); - await screenshotIos(device, outPath, options); + await screenshotIos(device, outPath, { ...options, env: process.env }); + await screenshotIos(device, outPath, { ...options, env: process.env }); }); assert.equal( diff --git a/packages/platform-apple/src/core/__tests__/screenshot-simulator-capture.test.ts b/packages/platform-apple/src/core/__tests__/screenshot-simulator-capture.test.ts index 7d4556f410..2ad3791535 100644 --- a/packages/platform-apple/src/core/__tests__/screenshot-simulator-capture.test.ts +++ b/packages/platform-apple/src/core/__tests__/screenshot-simulator-capture.test.ts @@ -102,7 +102,7 @@ test('screenshotIos retries simulator capture timeouts and eventually succeeds', return unexpectedArgs(args); }, async ({ calls }) => { - await screenshotIos(IOS_TEST_SIMULATOR, outPath); + await screenshotIos(IOS_TEST_SIMULATOR, outPath, { env: process.env }); const png = PNG.sync.read(await fs.readFile(outPath)); assert.equal(png.width, 42); assert.equal(png.height, 91); @@ -144,7 +144,7 @@ test('screenshotIos keeps requested simulator pixel density', async () => { return unexpectedArgs(args); }, async () => { - await screenshotIos(IOS_TEST_SIMULATOR, outPath, { pixelDensity: 2 }); + await screenshotIos(IOS_TEST_SIMULATOR, outPath, { env: process.env, pixelDensity: 2 }); const png = PNG.sync.read(await fs.readFile(outPath)); assert.equal(png.width, 84); assert.equal(png.height, 182); diff --git a/packages/platform-apple/src/core/__tests__/screenshot.test.ts b/packages/platform-apple/src/core/__tests__/screenshot.test.ts index 1d7c1f7430..d88e18b466 100644 --- a/packages/platform-apple/src/core/__tests__/screenshot.test.ts +++ b/packages/platform-apple/src/core/__tests__/screenshot.test.ts @@ -162,6 +162,8 @@ test('captureSimulatorScreenshotWithFallback falls back to runner after retry ex try { const outPath = path.join(tmpDir, 'out.png'); await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, outPath, { + env: process.env, + appBundleId: 'com.example.app', deps: { ensureBooted: ensureBootedSimulator, @@ -208,6 +210,8 @@ test('captureSimulatorScreenshotWithFallback falls back to runner after simctl s try { const outPath = path.join(tmpDir, 'out.png'); await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, outPath, { + env: process.env, + appBundleId: 'com.example.app', deps: { ensureBooted: ensureBootedSimulator, @@ -234,6 +238,8 @@ test('captureSimulatorScreenshotWithFallback continues when status bar preparati mockOpenIosSimulatorApp.mockResolvedValue(undefined); mockRetryWithPolicy.mockResolvedValue(undefined); await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { + env: process.env, + appBundleId: 'com.example.app', normalizeStatusBar: true, deps: { normalizeDensity: async () => {} }, @@ -249,6 +255,8 @@ test('captureSimulatorScreenshotWithFallback can skip session-backed simulator b mockRetryWithPolicy.mockResolvedValue(undefined); await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { + env: process.env, + appBundleId: 'com.example.app', skipIosSimulatorBootCheck: true, deps: { normalizeDensity: async () => {} }, @@ -275,6 +283,8 @@ test('captureSimulatorScreenshotWithFallback boots skipped-check simulator after const captureWithRunner = vi.fn(async () => undefined); await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { + env: process.env, + appBundleId: 'com.example.app', skipIosSimulatorBootCheck: true, deps: { @@ -313,6 +323,8 @@ test('captureSimulatorScreenshotWithFallback keeps runner fallback after skipped const captureWithRunner = vi.fn(async () => undefined); await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { + env: process.env, + appBundleId: 'com.example.app', skipIosSimulatorBootCheck: true, deps: { @@ -339,6 +351,8 @@ test('captureSimulatorScreenshotWithFallback ignores status bar restore failures mockOpenIosSimulatorApp.mockResolvedValue(undefined); mockRetryWithPolicy.mockResolvedValue(undefined); await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { + env: process.env, + appBundleId: 'com.example.app', normalizeStatusBar: true, deps: { normalizeDensity: async () => {} }, @@ -355,6 +369,8 @@ test('captureSimulatorScreenshotWithFallback skips status bar normalization by d mockRetryWithPolicy.mockResolvedValue(undefined); await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { + env: process.env, + appBundleId: 'com.example.app', deps: { normalizeDensity: async () => {} }, }); @@ -398,6 +414,8 @@ test('captureSimulatorScreenshotWithFallback emits fallback diagnostic before us throw new Error(`Unexpected xcrun args: ${args.join(' ')}`); }); await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { + env: process.env, + appBundleId: 'com.example.app', deps: { ensureBooted: ensureBootedSimulator, @@ -450,6 +468,8 @@ test('captureSimulatorScreenshotWithFallback uses simulator runner fallback by d try { const outPath = path.join(tmpDir, 'out.png'); await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, outPath, { + env: process.env, + appBundleId: 'com.example.app', deps: { normalizeDensity: async () => {} }, }); @@ -496,7 +516,8 @@ test('captureScreenshotViaRunner treats a (null) data-container answer as no con try { const outPath = path.join(tmpDir, 'out.png'); await assert.rejects( - () => captureScreenshotViaRunner(device, outPath), + () => + captureScreenshotViaRunner(device, outPath, undefined, undefined, undefined, process.env), (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.code, 'COMMAND_FAILED'); @@ -527,8 +548,22 @@ test('captureScreenshotViaRunner reuses a verified simulator container path', as try { const firstPath = path.join(tmpDir, 'first.png'); const secondPath = path.join(tmpDir, 'second.png'); - await captureScreenshotViaRunner(device, firstPath); - await captureScreenshotViaRunner(device, secondPath); + await captureScreenshotViaRunner( + device, + firstPath, + undefined, + undefined, + undefined, + process.env, + ); + await captureScreenshotViaRunner( + device, + secondPath, + undefined, + undefined, + undefined, + process.env, + ); assert.equal(await fs.readFile(firstPath, 'utf8'), 'runner-image'); assert.equal(await fs.readFile(secondPath, 'utf8'), 'runner-image'); @@ -562,7 +597,14 @@ test('captureScreenshotViaRunner reports the display facts a runner capture meas try { const outPath = path.join(tmpDir, 'out.png'); - const metadata = await captureScreenshotViaRunner(device, outPath); + const metadata = await captureScreenshotViaRunner( + device, + outPath, + undefined, + undefined, + undefined, + process.env, + ); assert.deepEqual(metadata, MEASURED_CAPTURE.metadata); assert.equal(await fs.readFile(outPath, 'utf8'), 'runner-image'); } finally { @@ -587,7 +629,17 @@ test('captureScreenshotViaRunner reports no display facts for a pre-panel runner try { const outPath = path.join(tmpDir, 'out.png'); - assert.equal(await captureScreenshotViaRunner(device, outPath), undefined); + assert.equal( + await captureScreenshotViaRunner( + device, + outPath, + undefined, + undefined, + undefined, + process.env, + ), + undefined, + ); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } @@ -602,7 +654,14 @@ test('captureScreenshotViaRunner copies macOS runner screenshots from the host', mockRunCmd.mockRejectedValue(new Error('macOS screenshot must not invoke xcrun devicectl')); try { - await captureScreenshotViaRunner(MACOS_TEST_DEVICE, outPath); + await captureScreenshotViaRunner( + MACOS_TEST_DEVICE, + outPath, + undefined, + undefined, + undefined, + process.env, + ); assert.equal(await fs.readFile(outPath, 'utf8'), 'runner-image'); assert.equal(mockRunCmd.mock.calls.length, 0); } finally { @@ -612,6 +671,8 @@ test('captureScreenshotViaRunner copies macOS runner screenshots from the host', test('captureSimulatorScreenshotWithFallback reports the panel rotation for a simctl capture', async () => { const facts = await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/s.png', { + env: process.env, + skipIosSimulatorBootCheck: true, deps: { ensureBooted: async () => {}, @@ -627,6 +688,8 @@ test('captureSimulatorScreenshotWithFallback reports the panel rotation for a si test('captureSimulatorScreenshotWithFallback keeps the panel rotation for a runner fallback capture', async () => { const facts = await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/s.png', { + env: process.env, + skipIosSimulatorBootCheck: true, deps: { ensureBooted: async () => {}, diff --git a/packages/platform-apple/src/core/__tests__/settings-text-size.test.ts b/packages/platform-apple/src/core/__tests__/settings-text-size.test.ts index eb9b8515c0..a2f23db5df 100644 --- a/packages/platform-apple/src/core/__tests__/settings-text-size.test.ts +++ b/packages/platform-apple/src/core/__tests__/settings-text-size.test.ts @@ -41,6 +41,9 @@ test('setIosSetting text-size applies the category with simctl ui content_size', IOS_TEST_SIMULATOR, 'text-size', 'accessibility-extra-large', + undefined, + undefined, + process.env, ); assert.deepEqual(result, { setting: 'text-size', @@ -62,7 +65,14 @@ test('setIosSetting text-size sends the normalized category, not the caller casi return unexpectedArgs(args); }, async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'text-size', ' Extra-Small '); + await setIosSetting( + IOS_TEST_SIMULATOR, + 'text-size', + ' Extra-Small ', + undefined, + undefined, + process.env, + ); assert.deepEqual(simctlUiCalls(calls), ['simctl ui sim-1 content_size extra-small']); }, ); @@ -78,7 +88,15 @@ test('setIosSetting text-size refuses an off-ladder category without calling sim // `simctl ui ... content_size bogus` exits 0 with `Invalid argument`, so an unvalidated write // would be reported as a successful change that changed nothing. await assertRejectsAppError( - () => setIosSetting(IOS_TEST_SIMULATOR, 'text-size', 'gigantic'), + () => + setIosSetting( + IOS_TEST_SIMULATOR, + 'text-size', + 'gigantic', + undefined, + undefined, + process.env, + ), { code: 'INVALID_ARGS', }, diff --git a/packages/platform-apple/src/core/app-launch.ts b/packages/platform-apple/src/core/app-launch.ts index a6e86b3cbe..d5fb047603 100644 --- a/packages/platform-apple/src/core/app-launch.ts +++ b/packages/platform-apple/src/core/app-launch.ts @@ -32,6 +32,7 @@ import { } from './launch-diagnostics.ts'; import { ensureBootedSimulator } from './simulator.ts'; import { runXcrun, type ScopedSimctlCommand } from './tool-provider.ts'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { closeMacOsApp, openMacOsApp } from '../os/macos/apps.ts'; import { resolveIosApp } from './app-resolution.ts'; import { buildSimctlArgsForDevice } from './simctl.ts'; @@ -202,10 +203,11 @@ export async function openIosDevice(device: DeviceInfo): Promise { export async function closeIosApp( device: DeviceInfo, app: string, - runnerOptions?: AppleRunnerCommandOptions, + runnerOptions: AppleRunnerCommandOptions | undefined, + env: Readonly, ): Promise { if (isMacOs(device)) { - await closeMacOsApp(device, app); + await closeMacOsApp(device, app, env); return; } const bundleId = await resolveIosApp(device, app); diff --git a/packages/platform-apple/src/core/app-settings.ts b/packages/platform-apple/src/core/app-settings.ts index aea8ce2201..01c3f98768 100644 --- a/packages/platform-apple/src/core/app-settings.ts +++ b/packages/platform-apple/src/core/app-settings.ts @@ -11,6 +11,7 @@ import { type SettingName, } from '@agent-device/contracts/settings-catalog'; import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { AppError, sessionAppRequiredDetails, @@ -62,10 +63,11 @@ export async function setIosSetting( device: DeviceInfo, setting: string, state: string, - appBundleId?: string, - options?: SettingOptions, + appBundleId: string | undefined, + options: SettingOptions | undefined, + env: Readonly, ): Promise | void> { - if (isMacOs(device)) return await setMacOsHostSetting(setting, state, options); + if (isMacOs(device)) return await setMacOsHostSetting(setting, state, options, env); requireSimulatorDevice(device, 'settings'); const normalized = APPLE_SIMULATOR_SETTINGS.find((name) => name === setting.toLowerCase()); if (normalized === undefined) @@ -84,7 +86,7 @@ export async function setIosSetting( sessionAppRequiredDetails(), ); } - const result = await clearIosSimulatorAppState(device, appBundleId); + const result = await clearIosSimulatorAppState(device, appBundleId, env); return { bundleId: result.bundleId, containerPath: result.containerPath, cleared: true }; } case 'reset-keychain': { @@ -169,6 +171,7 @@ async function setMacOsHostSetting( setting: string, state: string, options: SettingOptions | undefined, + env: Readonly, ): Promise | void> { const normalized = MACOS_HOST_SETTINGS.find((name) => name === setting.toLowerCase()); switch (normalized) { @@ -184,7 +187,7 @@ async function setMacOsHostSetting( ); } const permissionTarget = parseMacOsPermissionTarget(options?.permissionTarget); - return await runMacOsPermissionAction(action, permissionTarget); + return await runMacOsPermissionAction(action, permissionTarget, env); } case undefined: throw new AppError('UNSUPPORTED_OPERATION', `Unsupported macOS setting: ${setting}`); @@ -257,6 +260,7 @@ async function resolveIosSettingsApp(device: DeviceInfo, app: string): Promise, ): Promise<{ bundleId: string; containerPath: string }> { if (!isIosFamily(device) || device.kind !== 'simulator') { throw new AppError( @@ -267,7 +271,7 @@ async function clearIosSimulatorAppState( const bundleId = await resolveIosSettingsApp(device, app); await ensureBootedSimulator(device); - await closeIosApp(device, bundleId); + await closeIosApp(device, bundleId, undefined, env); const result = requireExecSuccess( await runSimctlForDevice(device, ['get_app_container', device.id, bundleId, 'data'], { diff --git a/packages/platform-apple/src/core/debug-symbols.ts b/packages/platform-apple/src/core/debug-symbols.ts index 717df68b4d..3b8a2e843d 100644 --- a/packages/platform-apple/src/core/debug-symbols.ts +++ b/packages/platform-apple/src/core/debug-symbols.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import path from 'node:path'; import { ensureHostDirectory, @@ -19,14 +18,16 @@ import { AppError } from '@agent-device/kernel/errors'; const MAX_CRASH_ARTIFACT_BYTES = 64 * 1024 * 1024; export async function symbolicateCrashArtifact( - options: DebugSymbolsOptions, + // The public options keep `cwd` optional; the caller that owns a host resolves it first, so + // this leaf never reads an ambient working directory (#3794). + options: DebugSymbolsOptions & { cwd: string }, ): Promise { if (options.action !== undefined && options.action !== 'symbols') { throw new AppError('INVALID_ARGS', 'debug supports only the symbols workflow.', { hint: 'Use debug symbols --artifact --dsym or --search-path --out .', }); } - const cwd = options.cwd ?? commandHostEnvironment().cwd; + const cwd = options.cwd; const artifactPath = resolvePath(cwd, options.artifact); const outPath = resolvePath(cwd, options.out ?? defaultOutPath(artifactPath)); const artifactText = await readTextFile(artifactPath, 'crash artifact'); diff --git a/packages/platform-apple/src/core/physical-device-control.ts b/packages/platform-apple/src/core/physical-device-control.ts index 54ddb3651f..763fbe4f32 100644 --- a/packages/platform-apple/src/core/physical-device-control.ts +++ b/packages/platform-apple/src/core/physical-device-control.ts @@ -1,4 +1,5 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { AppError } from '@agent-device/kernel/errors'; import { execFailureDetails } from '@agent-device/host-kit/command'; import type { AppsFilter } from '@agent-device/contracts/device'; @@ -65,6 +66,7 @@ export type IosPhysicalDeviceControl = IosPhysicalDeviceRunnerControl & { device: DeviceInfo, remotePath: string, outPath: string, + env: Readonly, timeoutMs?: number, ): Promise; }; @@ -146,7 +148,12 @@ async function readXctestDeviceReadiness(device: DeviceInfo): Promise { +async function rejectXctestRunnerFileCopy( + device: DeviceInfo, + _remotePath: string, + _outPath: string, + _env: Readonly, +): Promise { throw new AppError( 'UNSUPPORTED_OPERATION', 'Runner file copy is unavailable on this XCTest-backed physical iOS device.', diff --git a/packages/platform-apple/src/core/physical-device-files.ts b/packages/platform-apple/src/core/physical-device-files.ts index ddb0ce3413..0aef0809da 100644 --- a/packages/platform-apple/src/core/physical-device-files.ts +++ b/packages/platform-apple/src/core/physical-device-files.ts @@ -1,6 +1,6 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { EnvMap } from '@agent-device/kernel/source-value'; 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 { resolveRunnerContainerBundleIds } from '../runner/index.ts'; @@ -10,11 +10,12 @@ export async function copyCoreDeviceRunnerFile( device: DeviceInfo, remotePath: string, outPath: string, + env: Readonly, timeoutMs = IOS_RUNNER_SCREENSHOT_COPY_TIMEOUT_MS, ): Promise { const deadline = Deadline.fromTimeoutMs(timeoutMs); let copyResult = { exitCode: 1, stdout: '', stderr: '' }; - for (const bundleId of resolveRunnerContainerBundleIds(commandHostEnvironment().env)) { + for (const bundleId of resolveRunnerContainerBundleIds(env)) { copyResult = await runXcrun( [ 'devicectl', diff --git a/packages/platform-apple/src/core/physical-device-screenshot.ts b/packages/platform-apple/src/core/physical-device-screenshot.ts index 0e45408ce4..675904afd8 100644 --- a/packages/platform-apple/src/core/physical-device-screenshot.ts +++ b/packages/platform-apple/src/core/physical-device-screenshot.ts @@ -1,4 +1,5 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { AppError } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { writeHostBinaryFile } from '@agent-device/host-kit/host-file'; @@ -13,6 +14,8 @@ export type IosPhysicalDeviceScreenshotOptions = { runnerOptions?: AppleRunnerCommandOptions; preferRunner?: boolean; runRunnerCommand: AppleRunnerCommandExecutor; + /** The calling runtime's host environment; runner-container configuration is read from it. */ + env: Readonly; }; export async function captureCoreDeviceScreenshot( @@ -40,7 +43,7 @@ export async function captureCoreDeviceScreenshot( if (await writeInlineRunnerScreenshot(result, outPath)) return; const remoteFileName = readRunnerScreenshotPath(result); - await copyCoreDeviceRunnerFile(device, remoteFileName, outPath); + await copyCoreDeviceRunnerFile(device, remoteFileName, outPath, options.env); } export async function captureXctestDeviceScreenshot( diff --git a/packages/platform-apple/src/core/runner-host.ts b/packages/platform-apple/src/core/runner-host.ts index 349d85ec19..9e6bee1435 100644 --- a/packages/platform-apple/src/core/runner-host.ts +++ b/packages/platform-apple/src/core/runner-host.ts @@ -3,7 +3,6 @@ import { publishFileSync, acquireProcessLock, withProcessLock } from '@agent-dev import { commandDeveloperDir, - commandHostEnvironment, isCommandTimeoutError, requireExecSuccess, runCmdBackground, @@ -11,6 +10,7 @@ import { runCmdSync, } from '@agent-device/host-kit/command'; import { shellQuote } from '@agent-device/kernel/device-shell'; +import { AppError } from '@agent-device/kernel/errors'; import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; import { isProcessAlive, @@ -29,6 +29,7 @@ import { withKeyedLock } from '@agent-device/kernel/keyed-lock'; import { findProjectRoot, readVersion } from '@agent-device/host-kit/version'; import { isRecord } from '@agent-device/kernel/record'; import { parseBooleanLiteral } from '@agent-device/kernel/source-value'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { createTtlMemo } from '@agent-device/kernel/ttl-memo'; import { @@ -41,6 +42,7 @@ import { bootFailureHint, classifyBootFailure } from '@agent-device/provision-ki import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; import { visitXmlPlistEntries } from './plist-xml.ts'; import { + getAppleRunnerHostEnvironment, getRunnerDeviceClaimAuthorityProbe, getRunnerLeaseOwnerStateDir, runnerRuntimeSlot, @@ -73,7 +75,15 @@ export const appleRunnerHost: AppleRunnerHost = { requireExecSuccess, isCommandTimeoutError, commandDeveloperDir, - hostEnvironment: commandHostEnvironment, + hostEnvironment: (): HostEnvironment => { + const host = getAppleRunnerHostEnvironment(); + if (!host) { + throw new AppError('COMMAND_FAILED', 'Apple runner configuration has no bound host.', { + reason: 'apple_runner_host_unbound', + }); + } + return host; + }, shellQuote, emitDiagnostic, withDiagnosticTimer, diff --git a/packages/platform-apple/src/core/runner-owner-state.test.ts b/packages/platform-apple/src/core/runner-owner-state.test.ts index fa4de52b7a..9923251c5d 100644 --- a/packages/platform-apple/src/core/runner-owner-state.test.ts +++ b/packages/platform-apple/src/core/runner-owner-state.test.ts @@ -1,4 +1,7 @@ import { expect, test } from 'vitest'; +import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; +import { AppError } from '@agent-device/kernel/errors'; +import { appleRunnerHost } from './runner-host.ts'; import { createAppleRunnerRuntimeState, getRunnerLeaseOwnerStateDir, @@ -9,8 +12,8 @@ import { const SLOT = Symbol('test-slot'); test('each runtime keeps its own owner state and registries', () => { - const first = createAppleRunnerRuntimeState(); - const second = createAppleRunnerRuntimeState(); + const first = createAppleRunnerRuntimeState(createLiveTestHostEnvironment()); + const second = createAppleRunnerRuntimeState(createLiveTestHostEnvironment()); first.run(() => { setRunnerLeaseOwnerStateDir(' /state/first '); @@ -31,10 +34,25 @@ test('each runtime keeps its own owner state and registries', () => { }); test('disposing a runtime drops its registries', () => { - const runtime = createAppleRunnerRuntimeState(); + const runtime = createAppleRunnerRuntimeState(createLiveTestHostEnvironment()); runtime.run(() => runnerRuntimeSlot(SLOT, () => new Map([['device', 'owned']]))); runtime.dispose(); expect(runtime.run(() => runnerRuntimeSlot(SLOT, () => new Map()).size)).toBe(0); }); + +// A configuration read outside every composed runtime has no host to answer from; it refuses +// instead of borrowing the process environment (#3794). +test('the runner host port refuses a configuration read outside any entered runtime', () => { + const error = (() => { + try { + appleRunnerHost.hostEnvironment(); + return undefined; + } catch (error) { + return error as AppError; + } + })(); + expect(error).toBeInstanceOf(AppError); + expect(error?.details?.reason).toBe('apple_runner_host_unbound'); +}); diff --git a/packages/platform-apple/src/core/runner-owner-state.ts b/packages/platform-apple/src/core/runner-owner-state.ts index d94303d549..0468c85c4c 100644 --- a/packages/platform-apple/src/core/runner-owner-state.ts +++ b/packages/platform-apple/src/core/runner-owner-state.ts @@ -1,4 +1,5 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { defineRuntimeSlots, type RuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; /** @@ -20,7 +21,27 @@ export type RunnerDeviceClaimAuthorityProbe = (device: DeviceInfo) => boolean; */ const runnerRuntime = defineRuntimeSlots(); -export const createAppleRunnerRuntimeState = (): RuntimeStateHandle => runnerRuntime.createHandle(); +/** + * The state of one runtime's Apple runner, with the host that runtime captured bound into it. + * Root composition passes the same host its deps carry, so runner configuration reads and every + * other configuration read of that runtime agree on one object (#3794). + */ +export function createAppleRunnerRuntimeState(host: HostEnvironment): RuntimeStateHandle { + const state = runnerRuntime.createHandle(); + const bind = (fn: () => T): T => + state.run(() => { + runnerOwner().hostEnvironment = host; + return fn(); + }); + return Object.freeze({ + run: bind, + enter: () => { + state.enter(); + runnerOwner().hostEnvironment = host; + }, + dispose: () => state.dispose(), + }); +} /** One piece of runner state this daemon runtime owns, created on first use. */ export const runnerRuntimeSlot = runnerRuntime.slot; @@ -28,14 +49,30 @@ export const runnerRuntimeSlot = runnerRuntime.slot; type RunnerOwner = { leaseOwnerStateDir: string | undefined; deviceClaimAuthorityProbe: RunnerDeviceClaimAuthorityProbe | undefined; + hostEnvironment: HostEnvironment | undefined; }; const RUNNER_OWNER_SLOT = Symbol('appleRunnerOwner'); const runnerOwner = (): RunnerOwner => runnerRuntimeSlot(RUNNER_OWNER_SLOT, (): RunnerOwner => ({ leaseOwnerStateDir: undefined, deviceClaimAuthorityProbe: undefined, + hostEnvironment: undefined, })); +/** + * Binds the host this runtime captured for the Apple runner. Root composition calls it once per + * runtime, inside the runtime's entered state, alongside the lease owner state; the runner host + * port's `hostEnvironment` member answers from it (#3794). + */ +export function setAppleRunnerHostEnvironment(host: HostEnvironment): void { + runnerOwner().hostEnvironment = host; +} + +/** The host this runtime bound, or undefined when composition never entered one. */ +export function getAppleRunnerHostEnvironment(): HostEnvironment | undefined { + return runnerOwner().hostEnvironment; +} + export function setRunnerLeaseOwnerStateDir(stateDir: string | undefined): void { runnerOwner().leaseOwnerStateDir = stateDir?.trim() || undefined; } diff --git a/packages/platform-apple/src/core/screenshot.ts b/packages/platform-apple/src/core/screenshot.ts index 717a09b507..a612c59f3a 100644 --- a/packages/platform-apple/src/core/screenshot.ts +++ b/packages/platform-apple/src/core/screenshot.ts @@ -1,6 +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 type { EnvMap } from '@agent-device/kernel/source-value'; 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'; @@ -64,14 +64,17 @@ type SimulatorScreenshotFlowDeps = { captureWithRunner: ( device: DeviceInfo, outPath: string, - appBundleId?: string, - fullscreen?: boolean, - runnerOptions?: AppleRunnerCommandOptions, + appBundleId: string | undefined, + fullscreen: boolean | undefined, + runnerOptions: AppleRunnerCommandOptions | undefined, + env: Readonly, ) => Promise; shouldFallbackToRunner: (error: unknown) => boolean; }; type SimulatorScreenshotFlowOptions = { + /** The calling runtime's host environment; runner-container configuration is read from it. */ + env: Readonly; appBundleId?: string; fullscreen?: boolean; pixelDensity?: number; @@ -97,7 +100,7 @@ const iosSimulatorRunnerContainerCache = new Map(); export async function screenshotIos( device: DeviceInfo, outPath: string, - options: Omit = {}, + options: Omit, ): Promise { if (isMacOs(device)) { await captureScreenshotViaRunner( @@ -106,6 +109,7 @@ export async function screenshotIos( options.appBundleId, options.fullscreen, options.runnerOptions, + options.env, ); return {}; } @@ -118,6 +122,7 @@ export async function screenshotIos( fullscreen: options.fullscreen, runnerOptions: options.runnerOptions, runRunnerCommand: runAppleRunnerCommand, + env: options.env, }); return {}; } @@ -125,7 +130,7 @@ export async function screenshotIos( export async function captureSimulatorScreenshotWithFallback( device: DeviceInfo, outPath: string, - options: SimulatorScreenshotFlowOptions = {}, + options: SimulatorScreenshotFlowOptions, ): Promise { if (device.kind !== 'simulator') { throw new AppError( @@ -184,6 +189,7 @@ export async function captureSimulatorScreenshotWithFallback( options.appBundleId, options.fullscreen, options.runnerOptions, + options.env, ); // The runner captures the display that actually hosts the app and reports the scale it encoded // that image at, so normalization reads the source instead of applying a panel the host guessed. @@ -234,9 +240,10 @@ export async function captureSimulatorScreenshotWithRetry( export async function captureScreenshotViaRunner( device: DeviceInfo, outPath: string, - appBundleId?: string, - fullscreen?: boolean, - runnerOptions?: AppleRunnerCommandOptions, + appBundleId: string | undefined, + fullscreen: boolean | undefined, + runnerOptions: AppleRunnerCommandOptions | undefined, + env: Readonly, ): Promise { if (device.kind === 'device' && !isMacOs(device)) { await resolveIosPhysicalDeviceControl(device).captureScreenshot(device, outPath, { @@ -245,6 +252,7 @@ export async function captureScreenshotViaRunner( runnerOptions, preferRunner: true, runRunnerCommand: runAppleRunnerCommand, + env, }); return undefined; } @@ -274,7 +282,7 @@ export async function captureScreenshotViaRunner( } if (device.kind === 'simulator') { - await copyRunnerScreenshotFromSimulator(device, remoteFileName, outPath); + await copyRunnerScreenshotFromSimulator(device, remoteFileName, outPath, env); return metadata; } throw new AppError('COMMAND_FAILED', 'Unsupported Apple screenshot target'); @@ -284,6 +292,7 @@ async function copyRunnerScreenshotFromSimulator( device: DeviceInfo, remoteFileName: string, outPath: string, + env: Readonly, ): Promise { const deadline = Deadline.fromTimeoutMs(IOS_RUNNER_SCREENSHOT_COPY_TIMEOUT_MS); let lastError = 'Unable to locate runner container for simulator screenshot'; @@ -298,7 +307,7 @@ async function copyRunnerScreenshotFromSimulator( lastError = cachedCopy.error; iosSimulatorRunnerContainerCache.delete(device.id); } - for (const bundleId of resolveRunnerContainerBundleIds(commandHostEnvironment().env)) { + for (const bundleId of resolveRunnerContainerBundleIds(env)) { const containerResult = await runSimctlForDevice( device, ['get_app_container', device.id, bundleId, 'data'], diff --git a/packages/platform-apple/src/foldable/fold-helper-cache.test.ts b/packages/platform-apple/src/foldable/fold-helper-cache.test.ts index 06bfe6b57a..1e1f9e4373 100644 --- a/packages/platform-apple/src/foldable/fold-helper-cache.test.ts +++ b/packages/platform-apple/src/foldable/fold-helper-cache.test.ts @@ -8,6 +8,7 @@ import { mkdtempForTest } from '../__tests__/tmp-dir.ts'; import { execKillTimeoutError } from '../native-build/__tests__/exec-timeout-fixture.ts'; import { createSnapshotSourceHost } from '../snapshot-source/host.ts'; import type { SnapshotSourceHost } from '../snapshot-source/types.ts'; +import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; import { buildFoldHelperCompileArgv, ensureFoldHelperBinary, @@ -18,7 +19,7 @@ function fakeFoldHelperHost( binary: () => string, xcodeVersion: () => string = () => 'Xcode 16.4\nBuild version 16F6', ): SnapshotSourceHost { - const real = createSnapshotSourceHost(); + const real = createSnapshotSourceHost(createLiveTestHostEnvironment()); return { ...real, cpuArch: async () => 'arm64', diff --git a/packages/platform-apple/src/index.ts b/packages/platform-apple/src/index.ts index ee7ac04c09..74a8618132 100644 --- a/packages/platform-apple/src/index.ts +++ b/packages/platform-apple/src/index.ts @@ -5,6 +5,7 @@ import type { PlatformModuleMetadata, } from '@agent-device/contracts/platform-module'; import type { PlatformPlugin } from '@agent-device/contracts/platform-plugin'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { PlatformRuntimeModule } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -40,8 +41,8 @@ export const applePlugin = { platforms: ['apple'], familySelector: 'apple', providers: { platformGatedResolvers: ['appleRunnerProvider', 'appleToolProvider'] }, - createInteractor: async (device: DeviceInfo, runner: RunnerContext) => { + createInteractor: async (device: DeviceInfo, runner: RunnerContext, host: HostEnvironment) => { const { createAppleInteractor } = await import('./interactor.ts'); - return createAppleInteractor(device, runner); + return createAppleInteractor(device, runner, host); }, } as const satisfies PlatformPlugin; diff --git a/packages/platform-apple/src/interactions.test.ts b/packages/platform-apple/src/interactions.test.ts index 6fbba09587..ef184b9bcd 100644 --- a/packages/platform-apple/src/interactions.test.ts +++ b/packages/platform-apple/src/interactions.test.ts @@ -94,9 +94,13 @@ function singlePanPlan(): Extract { test('iosRunnerOverrides uses synthesized iOS coordinate taps', async () => { mockRunAppleRunnerCommand.mockResolvedValue({}); - const { overrides } = iosRunnerOverrides(IOS_TEST_SIMULATOR, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + IOS_TEST_SIMULATOR, + { + appBundleId: 'com.example.App', + }, + process.env, + ); await overrides.tap(100, 200); await overrides.focus(110, 210); @@ -120,9 +124,13 @@ test('iosRunnerOverrides uses synthesized iOS coordinate taps', async () => { test('iosRunnerOverrides uses synthesized iOS coordinate taps for selectors', async () => { mockRunAppleRunnerCommand.mockResolvedValue({}); - const { overrides } = iosRunnerOverrides(IOS_TEST_DEVICE, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + IOS_TEST_DEVICE, + { + appBundleId: 'com.example.App', + }, + process.env, + ); await overrides.tapElementSelector!({ key: 'label', @@ -145,9 +153,13 @@ test('iosRunnerOverrides uses synthesized iOS coordinate taps for selectors', as test('iosRunnerOverrides does not request synthesis for visionOS coordinate taps', async () => { mockRunAppleRunnerCommand.mockResolvedValue({}); - const { overrides } = iosRunnerOverrides(VISIONOS_TEST_SIMULATOR, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + VISIONOS_TEST_SIMULATOR, + { + appBundleId: 'com.example.App', + }, + process.env, + ); await overrides.tap(100, 200); @@ -162,9 +174,13 @@ test('iosRunnerOverrides does not request synthesis for visionOS coordinate taps test('iosRunnerOverrides does not request synthesis for visionOS selector taps', async () => { mockRunAppleRunnerCommand.mockResolvedValue({}); - const { overrides } = iosRunnerOverrides(VISIONOS_TEST_SIMULATOR, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + VISIONOS_TEST_SIMULATOR, + { + appBundleId: 'com.example.App', + }, + process.env, + ); await overrides.tapElementSelector!({ key: 'label', @@ -188,9 +204,13 @@ test('iosRunnerOverrides does not request synthesis for visionOS fused presses', completedSteps: 3, sequenceResults: Array.from({ length: 3 }, () => ({ ok: true, kind: 'tap' })), }); - const { overrides } = iosRunnerOverrides(VISIONOS_TEST_SIMULATOR, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + VISIONOS_TEST_SIMULATOR, + { + appBundleId: 'com.example.App', + }, + process.env, + ); await overrides.pressPoint!( { x: 100, y: 200 }, @@ -218,9 +238,13 @@ test('iosRunnerOverrides owns fused repeated presses with deterministic jitter', completedSteps: 3, sequenceResults: Array.from({ length: 3 }, () => ({ ok: true, kind: 'tap' })), }); - const { overrides } = iosRunnerOverrides(IOS_TEST_SIMULATOR, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + IOS_TEST_SIMULATOR, + { + appBundleId: 'com.example.App', + }, + process.env, + ); await overrides.pressPoint!( { x: 100, y: 200 }, @@ -245,9 +269,13 @@ test('iosRunnerOverrides owns fused repeated presses with deterministic jitter', test('iosRunnerOverrides owns alternate-button presses', async () => { mockRunAppleRunnerCommand.mockResolvedValue({ clicked: true }); - const { overrides } = iosRunnerOverrides(MACOS_TEST_DEVICE, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + MACOS_TEST_DEVICE, + { + appBundleId: 'com.example.App', + }, + process.env, + ); await overrides.pressPoint!( { x: 100, y: 200 }, @@ -290,7 +318,7 @@ test('iosRunnerOverrides remaps a later chunk failure to global press indices', }, ], }); - const { overrides } = iosRunnerOverrides(IOS_TEST_SIMULATOR, {}); + const { overrides } = iosRunnerOverrides(IOS_TEST_SIMULATOR, {}, process.env); await assert.rejects( () => @@ -325,9 +353,13 @@ test('iosRunnerOverrides remaps a later chunk failure to global press indices', test('iosRunnerOverrides reads and validates the fresh gesture viewport', async () => { mockRunAppleRunnerCommand.mockResolvedValue({ x: 10, y: 20, x2: 300, y2: 500 }); - const { overrides } = iosRunnerOverrides(IOS_TEST_SIMULATOR, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + IOS_TEST_SIMULATOR, + { + appBundleId: 'com.example.App', + }, + process.env, + ); assert.ok(overrides.gestureViewport); assert.deepEqual(await overrides.gestureViewport(), { x: 10, y: 20, width: 300, height: 500 }); assert.deepEqual(mockRunAppleRunnerCommand.mock.calls[0]?.[1], { @@ -345,9 +377,13 @@ for (const [name, device] of [ test(`iosRunnerOverrides keeps ${name} coordinate taps on the standard path`, async () => { mockRunAppleRunnerCommand.mockResolvedValue({}); - const { overrides } = iosRunnerOverrides(device, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + device, + { + appBundleId: 'com.example.App', + }, + process.env, + ); await overrides.tap(100, 200); @@ -437,9 +473,13 @@ test('iosRunnerOverrides maps iOS scroll to a single fused scroll command', asyn referenceHeight: 800, }); - const { overrides } = iosRunnerOverrides(IOS_TEST_SIMULATOR, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + IOS_TEST_SIMULATOR, + { + appBundleId: 'com.example.App', + }, + process.env, + ); const result = await overrides.scroll('down', { durationMs: 50 }); @@ -475,9 +515,13 @@ test('iosRunnerOverrides keeps explicit iOS scroll distance with the controlled referenceHeight: 800, }); - const { overrides } = iosRunnerOverrides(IOS_TEST_SIMULATOR, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + IOS_TEST_SIMULATOR, + { + appBundleId: 'com.example.App', + }, + process.env, + ); await overrides.scroll('down', { pixels: 400 }); @@ -501,9 +545,13 @@ test('iosRunnerOverrides materializes the controlled iOS default scroll amount', referenceHeight: 800, }); - const { overrides } = iosRunnerOverrides(IOS_TEST_SIMULATOR, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + IOS_TEST_SIMULATOR, + { + appBundleId: 'com.example.App', + }, + process.env, + ); const result = await overrides.scroll('down'); @@ -533,9 +581,13 @@ test('iosRunnerOverrides maps tvOS scroll duration to remote press hold duration ok: true, }); - const { overrides } = iosRunnerOverrides(TVOS_TEST_SIMULATOR, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + TVOS_TEST_SIMULATOR, + { + appBundleId: 'com.example.App', + }, + process.env, + ); const result = await overrides.scroll('down', { durationMs: 50 }); @@ -557,9 +609,13 @@ test('iosRunnerOverrides maps macOS desktop scroll to a desktop wheel command', referenceHeight: 800, }); - const { overrides } = iosRunnerOverrides(MACOS_TEST_DEVICE, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + MACOS_TEST_DEVICE, + { + appBundleId: 'com.example.App', + }, + process.env, + ); const result = await overrides.scroll('down', { pixels: 200, durationMs: 50 }); @@ -582,9 +638,13 @@ test('iosRunnerOverrides maps macOS desktop scroll to a desktop wheel command', }); test('iosRunnerOverrides rejects macOS desktop scroll duration above the shared cap', async () => { - const { overrides } = iosRunnerOverrides(MACOS_TEST_DEVICE, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + MACOS_TEST_DEVICE, + { + appBundleId: 'com.example.App', + }, + process.env, + ); await assert.rejects(() => overrides.scroll('down', { pixels: 200, durationMs: 10_001 }), { code: 'INVALID_ARGS', @@ -604,9 +664,13 @@ test('iosRunnerOverrides narrows the runner type payload to the text-entry route referenceHeight: 800, }); - const { overrides } = iosRunnerOverrides(IOS_TEST_SIMULATOR, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + IOS_TEST_SIMULATOR, + { + appBundleId: 'com.example.App', + }, + process.env, + ); assert.deepEqual(await overrides.type('hello', 25), { textEntryRoute: 'synthesized-first-responder', @@ -616,9 +680,13 @@ test('iosRunnerOverrides narrows the runner type payload to the text-entry route test('iosRunnerOverrides reports no route when the runner names one it does not model', async () => { mockRunAppleRunnerCommand.mockResolvedValueOnce({ textEntryRoute: 'unmodelled-route' }); - const { overrides } = iosRunnerOverrides(IOS_TEST_SIMULATOR, { - appBundleId: 'com.example.App', - }); + const { overrides } = iosRunnerOverrides( + IOS_TEST_SIMULATOR, + { + appBundleId: 'com.example.App', + }, + process.env, + ); assert.deepEqual(await overrides.type('hello'), {}); }); diff --git a/packages/platform-apple/src/interactions.ts b/packages/platform-apple/src/interactions.ts index f1477d68f1..56a88b1d55 100644 --- a/packages/platform-apple/src/interactions.ts +++ b/packages/platform-apple/src/interactions.ts @@ -12,6 +12,7 @@ import { type TypeTextBackendResult, } from '@agent-device/contracts/interactor-types'; import { macOsHelperSurface, type MacOsHelperSurface } from '@agent-device/contracts/session'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { SCROLL_DURATION_MAX_MS, normalizeScrollDurationMs, @@ -74,6 +75,7 @@ export function resolveAppleBackRunnerCommand(mode?: BackMode): AppleBackRunnerC export function iosRunnerOverrides( device: DeviceInfo, ctx: RunnerContext, + env: Readonly, ): { overrides: IosRunnerOverrides; runnerOpts: RunnerOpts; @@ -96,7 +98,7 @@ export function iosRunnerOverrides( return await runAppleRunnerCommand(device, iosTapCommand(device, ctx, x, y), runnerOpts); }, pressPoint: async (point, options) => - await runApplePressPoint(device, ctx, runnerOpts, point, options), + await runApplePressPoint(device, ctx, runnerOpts, point, options, env), tapElementSelector: async (selector) => { return await runAppleRunnerCommand( device, @@ -205,11 +207,12 @@ async function runApplePressPoint( runnerOpts: RunnerOpts, point: { x: number; y: number }, options: PressPointOptions, + env: Readonly, ): Promise> { // The runner owner's routing: an app session reaches here only on the XCTest backend. const helper = isMacOs(device) ? macOsHelperSurface(options.surface, 'xctest') : undefined; if (helper) { - return await runMacOsSurfacePress(context, point, options, helper); + return await runMacOsSurfacePress(context, point, options, helper, env); } if (options.button !== 'primary') { return await runAppleAlternateClick(device, context, runnerOpts, point, options.button); @@ -231,6 +234,7 @@ async function runMacOsSurfacePress( point: { x: number; y: number }, options: PressPointOptions, surface: MacOsHelperSurface, + env: Readonly, ): Promise> { if (options.button !== 'primary') { throw new AppError( @@ -249,6 +253,7 @@ async function runMacOsSurfacePress( doubleClick: options.doubleTap, intervalMs: options.intervalMs, signal: context.signal, + env, }); // A hold shorter than what macOS delivers is raised before it is posted, so the // response carries the hold the helper actually used rather than the request. diff --git a/packages/platform-apple/src/interactor.ts b/packages/platform-apple/src/interactor.ts index b4d258ae70..3869058ef6 100644 --- a/packages/platform-apple/src/interactor.ts +++ b/packages/platform-apple/src/interactor.ts @@ -37,6 +37,7 @@ import type { } from '@agent-device/contracts/interactor-types'; import { captureMacOsSurfaceSnapshot } from './os/macos/surface-snapshot.ts'; import { hostMacOsAppBackend } from './os/macos/app-backend.ts'; +import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; import { macOsNativeAppInteractor } from './os/macos/native-app-interactor.ts'; import { presentAppleRunnerSnapshot, @@ -50,6 +51,7 @@ import { iosTargetActivationDisclosure } from '@agent-device/contracts/ios-targe export function createAppleInteractor( device: DeviceInfo, runnerContext: RunnerContext, + host: HostEnvironment, runnerProvider?: AppleRunnerProvider | AppleRunnerCommandExecutor, ): Interactor { // watchOS unsupported sentinel: XCUITest cannot drive watchOS UI (no @@ -62,8 +64,9 @@ export function createAppleInteractor( 'watchOS is not supported: XCUITest cannot drive watchOS UI, so this device has no runner backend.', ); } - const { overrides, runnerOpts } = iosRunnerOverrides(device, runnerContext); - const appBackend = isMacOs(device) ? hostMacOsAppBackend() : 'xctest'; + const env = host.env; + const { overrides, runnerOpts } = iosRunnerOverrides(device, runnerContext, env); + const appBackend = isMacOs(device) ? hostMacOsAppBackend(env) : 'xctest'; const helperSurface = (surface: SessionSurface | undefined) => isMacOs(device) ? macOsHelperSurface(surface, appBackend) : undefined; const interactor: Interactor = { @@ -77,17 +80,30 @@ export function createAppleInteractor( runnerOptions: runnerOpts, }), openDevice: () => openIosDevice(device), - close: (app) => closeIosApp(device, app, runnerOpts), + close: (app) => closeIosApp(device, app, runnerOpts, env), screenshot: (outPath, options) => - runAppleScreenshot(device, outPath, options, runnerOpts, helperSurface(options?.surface)), + runAppleScreenshot( + device, + outPath, + options, + runnerOpts, + helperSurface(options?.surface), + env, + ), snapshot: async (options) => - await captureAppleSnapshot(device, options, runnerOpts, helperSurface(options?.surface)), + await captureAppleSnapshot(device, options, runnerOpts, helperSurface(options?.surface), env), // The live text at a point: helper for a helper-routed macOS surface, XCTest runner for // every other Apple leaf. readTextAtPoint: async (point, options) => { const helper = helperSurface(options?.surface); return helper - ? await readMacOsSurfaceTextAtPoint(point, helper, options?.appBundleId, options?.signal) + ? await readMacOsSurfaceTextAtPoint( + point, + helper, + options?.appBundleId, + options?.signal, + env, + ) : await readRunnerTextAtPoint(device, point, options, runnerOpts); }, // The XCTest runner's own text reading: it observes the live accessibility hierarchy @@ -208,16 +224,16 @@ export function createAppleInteractor( readClipboard: () => readIosClipboardText(device), writeClipboard: (text) => writeIosClipboardText(device, text, runnerOpts), setSetting: (setting, state, appId, options) => - setIosSetting(device, setting, state, appId, options), + setIosSetting(device, setting, state, appId, options, env), readSetting: (setting) => readIosSetting(device, setting), - readAlert: (options) => readAppleAlert(device, runnerOpts, options), - awaitAlert: (options) => awaitAppleAlert(device, runnerOpts, options), - acceptAlert: (options) => actOnAppleAlert(device, runnerOpts, 'accept', options), - dismissAlert: (options) => actOnAppleAlert(device, runnerOpts, 'dismiss', options), + readAlert: (options) => readAppleAlert(device, runnerOpts, options, env), + awaitAlert: (options) => awaitAppleAlert(device, runnerOpts, options, env), + acceptAlert: (options) => actOnAppleAlert(device, runnerOpts, 'accept', options, env), + dismissAlert: (options) => actOnAppleAlert(device, runnerOpts, 'dismiss', options, env), ...overrides, }; const served = - appBackend === 'native' ? macOsNativeAppInteractor(interactor, runnerContext) : interactor; + appBackend === 'native' ? macOsNativeAppInteractor(interactor, runnerContext, env) : interactor; if (!runnerProvider) return served; return withInjectedAppleRunnerTransport(device, runnerContext, served, runnerProvider); } @@ -227,9 +243,10 @@ async function captureAppleSnapshot( options: SnapshotOptions | undefined, runnerOpts: RunnerCallOptions, helper: MacOsHelperSurface | undefined, + env: Readonly, ) { if (helper) { - return await captureMacOsSurfaceSnapshot({ ...options, surface: helper }, options?.signal); + return await captureMacOsSurfaceSnapshot({ ...options, surface: helper }, options?.signal, env); } return await captureAppleRunnerSnapshot(device, options, runnerOpts); } @@ -430,6 +447,7 @@ async function runAppleScreenshot( options: ScreenshotOptions = {}, runnerOpts: RunnerCallOptions, helper: MacOsHelperSurface | undefined, + env: Readonly, ): Promise { if (helper) { if (options.fullscreen) { @@ -446,6 +464,7 @@ async function runAppleScreenshot( surface: helper, ...(helper === 'app' ? { bundleId: options.appBundleId } : {}), signal: runnerOpts.signal, + env, }); return {}; } @@ -458,10 +477,12 @@ async function runAppleScreenshot( options.appBundleId, options.fullscreen, runnerOpts, + env, ); return {}; } return await screenshotIos(device, outPath, { + env, appBundleId: options.appBundleId, pixelDensity: options.pixelDensity, fullscreen: options.fullscreen, @@ -476,12 +497,14 @@ async function readMacOsSurfaceTextAtPoint( surface: MacOsHelperSurface, appBundleId: string | undefined, signal: AbortSignal | undefined, + env: Readonly, ): Promise { const { runMacOsReadTextAction } = await import('./os/macos/helper.ts'); const result = await runMacOsReadTextAction(point.x, point.y, { bundleId: appBundleId, surface, signal, + env, }); return result.text; } diff --git a/packages/platform-apple/src/launch-confirmation.test.ts b/packages/platform-apple/src/launch-confirmation.test.ts index acf3d2be22..9289556651 100644 --- a/packages/platform-apple/src/launch-confirmation.test.ts +++ b/packages/platform-apple/src/launch-confirmation.test.ts @@ -369,8 +369,8 @@ test('the launch confirmation waits for a system prompt that is still appearing' runner.mockRejectedValueOnce(alertNotFound()).mockResolvedValueOnce(CONFIRMATION); try { const interactor = { - readAlert: (options) => readAppleAlert(simulator, {}, options), - awaitAlert: (options) => awaitAppleAlert(simulator, {}, options), + readAlert: (options) => readAppleAlert(simulator, {}, options, process.env), + awaitAlert: (options) => awaitAppleAlert(simulator, {}, options, process.env), acceptAlert: vi.fn(), } satisfies Pick; const device = createLaunchConfirmationPort( @@ -398,8 +398,8 @@ test('an expired confirmation wait retains typed absence instead of an unreadabl simulator, { url: 'example://automation', appBundleId: 'com.example.app' }, { - awaitAlert: (options) => awaitAppleAlert(simulator, {}, options), - readAlert: (options) => readAppleAlert(simulator, {}, options), + awaitAlert: (options) => awaitAppleAlert(simulator, {}, options, process.env), + readAlert: (options) => readAppleAlert(simulator, {}, options, process.env), acceptAlert: vi.fn(), }, new AbortController().signal, @@ -420,10 +420,10 @@ test('a failed accept checks presence once without waiting for the dismissed pro vi.mocked(resolveIosSimulatorDeepLinkBundleId).mockResolvedValueOnce('com.example.app'); try { const readAlert = vi.fn>((options) => - readAppleAlert(simulator, {}, options), + readAppleAlert(simulator, {}, options, process.env), ); const awaitAlert = vi.fn>((options) => - awaitAppleAlert(simulator, {}, options), + awaitAppleAlert(simulator, {}, options, process.env), ); const interactor = { readAlert, @@ -461,8 +461,8 @@ test('a no-prompt presentation probe completes within a few seconds', async () = runner.mockRejectedValue(alertNotFound()); try { const interactor = { - readAlert: (options) => readAppleAlert(simulator, {}, options), - awaitAlert: (options) => awaitAppleAlert(simulator, {}, options), + readAlert: (options) => readAppleAlert(simulator, {}, options, process.env), + awaitAlert: (options) => awaitAppleAlert(simulator, {}, options, process.env), acceptAlert: vi.fn(), } satisfies Pick; const device = createLaunchConfirmationPort( diff --git a/packages/platform-apple/src/os/macos/app-backend.test.ts b/packages/platform-apple/src/os/macos/app-backend.test.ts index 8adfc7a804..9e3b1c6b7f 100644 --- a/packages/platform-apple/src/os/macos/app-backend.test.ts +++ b/packages/platform-apple/src/os/macos/app-backend.test.ts @@ -10,7 +10,7 @@ afterEach(() => { test('the host keeps XCTest for app sessions unless it opts into the native backend', () => { vi.stubEnv('AGENT_DEVICE_MACOS_APP_BACKEND', ''); - expect(hostMacOsAppBackend()).toBe('xctest'); + expect(hostMacOsAppBackend(process.env)).toBe('xctest'); vi.stubEnv('AGENT_DEVICE_MACOS_APP_BACKEND', 'native'); - expect(hostMacOsAppBackend()).toBe('native'); + expect(hostMacOsAppBackend(process.env)).toBe('native'); }); diff --git a/packages/platform-apple/src/os/macos/app-backend.ts b/packages/platform-apple/src/os/macos/app-backend.ts index 7e452a3328..1419794368 100644 --- a/packages/platform-apple/src/os/macos/app-backend.ts +++ b/packages/platform-apple/src/os/macos/app-backend.ts @@ -1,7 +1,7 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { readMacOsAppBackend, type MacOsAppBackend } from '@agent-device/contracts/session'; /** The app-session backend the host selected through `AGENT_DEVICE_MACOS_APP_BACKEND`. */ -export function hostMacOsAppBackend(): MacOsAppBackend { - return readMacOsAppBackend((name) => commandHostEnvironment().env[name]); +export function hostMacOsAppBackend(env: Readonly): MacOsAppBackend { + return readMacOsAppBackend((name) => env[name]); } diff --git a/packages/platform-apple/src/os/macos/apps.test.ts b/packages/platform-apple/src/os/macos/apps.test.ts index 6a9361d577..9690a6bda7 100644 --- a/packages/platform-apple/src/os/macos/apps.test.ts +++ b/packages/platform-apple/src/os/macos/apps.test.ts @@ -44,7 +44,7 @@ test('closeMacOsApp waits until an accepted termination has actually completed', forceTerminated: false, }); - await closeMacOsApp(MACOS_DEVICE, 'com.apple.systempreferences'); + await closeMacOsApp(MACOS_DEVICE, 'com.apple.systempreferences', process.env); expect(mockQuitMacOsApp).toHaveBeenCalledTimes(3); }); @@ -57,9 +57,9 @@ test('closeMacOsApp fails immediately when neither termination request is accept forceTerminated: false, }); - await expect(closeMacOsApp(MACOS_DEVICE, 'com.apple.systempreferences')).rejects.toThrow( - 'Failed to close macOS app com.apple.systempreferences', - ); + await expect( + closeMacOsApp(MACOS_DEVICE, 'com.apple.systempreferences', process.env), + ).rejects.toThrow('Failed to close macOS app com.apple.systempreferences'); expect(mockQuitMacOsApp).toHaveBeenCalledTimes(1); }); @@ -71,7 +71,9 @@ test('closeMacOsApp bounds termination confirmation', async () => { forceTerminated: false, }); - await expect(closeMacOsApp(MACOS_DEVICE, 'com.apple.systempreferences')).rejects.toMatchObject({ + await expect( + closeMacOsApp(MACOS_DEVICE, 'com.apple.systempreferences', process.env), + ).rejects.toMatchObject({ details: { reason: 'MACOS_APP_TERMINATION_TIMEOUT', attempts: 20 }, }); expect(mockQuitMacOsApp).toHaveBeenCalledTimes(20); diff --git a/packages/platform-apple/src/os/macos/apps.ts b/packages/platform-apple/src/os/macos/apps.ts index ec44a23c1f..c5650d3ff7 100644 --- a/packages/platform-apple/src/os/macos/apps.ts +++ b/packages/platform-apple/src/os/macos/apps.ts @@ -2,6 +2,7 @@ import type { AppsFilter } from '@agent-device/contracts/device'; import { isDeepLinkTarget } from '@agent-device/contracts/command'; import { parseAppearanceAction } from '@agent-device/contracts/settings'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { AppError } from '@agent-device/kernel/errors'; import { setTimeout as sleep } from 'node:timers/promises'; import { @@ -82,9 +83,13 @@ export async function openMacOsApp( await resolveMacOsHostProvider().openBundle(bundleId); } -export async function closeMacOsApp(_device: DeviceInfo, app: string): Promise { +export async function closeMacOsApp( + _device: DeviceInfo, + app: string, + env: Readonly, +): Promise { const bundleId = await resolveMacOsApp(app); - const request = await quitMacOsApp(bundleId); + const request = await quitMacOsApp(bundleId, env); if (!request.running) return; if (!request.terminated && !request.forceTerminated) { throw new AppError('COMMAND_FAILED', `Failed to close macOS app ${app}`, { @@ -96,7 +101,7 @@ export async function closeMacOsApp(_device: DeviceInfo, app: string): Promise { - const frontmost = await resolveFrontmostMacOsApp(); + const frontmost = await resolveFrontmostMacOsApp(process.env); assert.equal(frontmost.bundleId, 'com.example.Finder'); // The command runs the binary the freshness check just settled on, not a rebuild. assert.equal(layout.helperCommands.at(-1), `${installedHelperPath} app frontmost`); diff --git a/packages/platform-apple/src/os/macos/helper-resolution.test.ts b/packages/platform-apple/src/os/macos/helper-resolution.test.ts index ae0bf903ce..6b06fc77eb 100644 --- a/packages/platform-apple/src/os/macos/helper-resolution.test.ts +++ b/packages/platform-apple/src/os/macos/helper-resolution.test.ts @@ -30,7 +30,9 @@ test('AGENT_DEVICE_MACOS_HELPER_BIN rejects relative override paths', async () = process.env.AGENT_DEVICE_MACOS_HELPER_BIN = './agent-device-macos-helper'; try { - await assert.rejects(() => quitMacOsApp('com.example.App'), { code: 'INVALID_ARGS' }); + await assert.rejects(() => quitMacOsApp('com.example.App', process.env), { + code: 'INVALID_ARGS', + }); } finally { if (previousHelperPath === undefined) { delete process.env.AGENT_DEVICE_MACOS_HELPER_BIN; @@ -41,5 +43,5 @@ test('AGENT_DEVICE_MACOS_HELPER_BIN rejects relative override paths', async () = }); test('quitMacOsApp rejects invalid bundle identifiers before invoking helper', async () => { - await assert.rejects(() => quitMacOsApp('not a bundle id'), /reverse-DNS form/i); + await assert.rejects(() => quitMacOsApp('not a bundle id', process.env), /reverse-DNS form/i); }); diff --git a/packages/platform-apple/src/os/macos/helper.test.ts b/packages/platform-apple/src/os/macos/helper.test.ts index 12186afe9f..d89dd3ac72 100644 --- a/packages/platform-apple/src/os/macos/helper.test.ts +++ b/packages/platform-apple/src/os/macos/helper.test.ts @@ -45,7 +45,8 @@ test('macOS helper snapshot passes cancellation to the helper process', async () await withAppleToolProvider( provider, - async () => await runMacOsSnapshotAction(desktop, { signal: controller.signal }), + async () => + await runMacOsSnapshotAction(desktop, { env: process.env, signal: controller.signal }), ); assert.equal(receivedSignal, controller.signal); @@ -74,6 +75,7 @@ test('macOS helper press carries the hold, repeat count, and interval to the cli provider, async () => await runMacOsPressAction(12, 34, { + env: process.env, surface: menubar, bundleId: 'com.example.Menu', holdMs: 800, @@ -115,7 +117,13 @@ test('macOS helper press carries an explicit zero interval instead of dropping i await withAppleToolProvider( provider, - async () => await runMacOsPressAction(7, 8, { surface: desktop, clicks: 2, intervalMs: 0 }), + async () => + await runMacOsPressAction(7, 8, { + env: process.env, + surface: desktop, + clicks: 2, + intervalMs: 0, + }), ); assert.ok(receivedArgs.includes('--clicks'), receivedArgs.join(' ')); @@ -142,7 +150,12 @@ test('macOS helper press keeps repeats independent and names a double-click expl await withAppleToolProvider( provider, async () => - await runMacOsPressAction(7, 8, { surface: frontmostApp, clicks: 3, doubleClick: true }), + await runMacOsPressAction(7, 8, { + env: process.env, + surface: frontmostApp, + clicks: 3, + doubleClick: true, + }), ); // `--count 3 --double-tap` is three double-clicks: the count stays the press count and the @@ -174,6 +187,7 @@ test('macOS helper press outlives its own click schedule and forwards cancellati provider, async () => await runMacOsPressAction(1, 2, { + env: process.env, surface: desktop, holdMs: 10_000, clicks: 4, @@ -216,7 +230,7 @@ test('macOS helper press stays a single held click when nothing is repeated', as await withAppleToolProvider( provider, - async () => await runMacOsPressAction(5, 6, { surface: frontmostApp }), + async () => await runMacOsPressAction(5, 6, { env: process.env, surface: frontmostApp }), ); assert.equal(receivedArgs.includes('--clicks'), false); @@ -237,7 +251,8 @@ test('macOS helper screenshot argv carries only --out and --surface', async () = await withAppleToolProvider( provider, - async () => await runMacOsScreenshotAction('/tmp/out.png', { surface: desktop }), + async () => + await runMacOsScreenshotAction('/tmp/out.png', { env: process.env, surface: desktop }), ); assert.deepEqual(receivedArgs, ['screenshot', '--out', '/tmp/out.png', '--surface', 'desktop']); @@ -274,6 +289,7 @@ for (const hint of [ runMacOsScreenshotAction('/tmp/out.png', { surface: nativeApp, bundleId: 'com.apple.TextEdit', + env: process.env, }).then( () => undefined, (error: unknown) => error, @@ -295,7 +311,7 @@ test('helper entry points accept only an owner-routed surface', () => { // @ts-expect-error a bare literal skips the routing owner await runMacOsSnapshotAction('desktop'); // @ts-expect-error a bare literal skips the routing owner - await runMacOsScreenshotAction(out, { surface: 'menubar' }); + await runMacOsScreenshotAction(out, { env: process.env, surface: 'menubar' }); // @ts-expect-error the surface is required await runMacOsReadTextAction(1, 2, { bundleId: 'com.example' }); // @ts-expect-error the surface is required diff --git a/packages/platform-apple/src/os/macos/helper.ts b/packages/platform-apple/src/os/macos/helper.ts index 15034bbd6d..ae33d5179d 100644 --- a/packages/platform-apple/src/os/macos/helper.ts +++ b/packages/platform-apple/src/os/macos/helper.ts @@ -20,8 +20,8 @@ import { resolveExecutableOverridePath, runCmdBackground, type ExecBackgroundResult, - commandHostEnvironment, } from '@agent-device/host-kit/command'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { MacOsHelperSurface, SessionSurface } from '@agent-device/contracts/session'; import { hasScopedAppleToolProvider, @@ -115,9 +115,13 @@ const MACOS_GHOST_CURSOR_ENV = 'AGENT_DEVICE_MACOS_GHOST_CURSOR'; * A background action on an app session draws its own pointer so the user can follow it while * keeping the real one; `AGENT_DEVICE_MACOS_GHOST_CURSOR=0` turns the drawing off. */ -function appendGhostCursorArg(args: string[], surface: SessionSurface | undefined): void { +function appendGhostCursorArg( + args: string[], + surface: SessionSurface | undefined, + env: Readonly, +): void { if (surface !== 'app') return; - if (commandHostEnvironment().env[MACOS_GHOST_CURSOR_ENV]?.trim() === '0') return; + if (env[MACOS_GHOST_CURSOR_ENV]?.trim() === '0') return; args.push('--ghost-cursor'); } @@ -230,9 +234,9 @@ async function readInstalledMacOsHelperFingerprint(): Promise { } } -async function ensureMacOsHelperBinary(): Promise { +async function ensureMacOsHelperBinary(env: Readonly): Promise { const configuredPath = await resolveExecutableOverridePath( - commandHostEnvironment().env[MACOS_HELPER_ENV_PATH], + env[MACOS_HELPER_ENV_PATH], MACOS_HELPER_ENV_PATH, ); if (configuredPath) { @@ -270,8 +274,8 @@ async function ensureMacOsHelperBinary(): Promise { return installedPath; } -async function resolveMacOsHelperCommandPath(): Promise { - const configuredPath = commandHostEnvironment().env[MACOS_HELPER_ENV_PATH]?.trim(); +async function resolveMacOsHelperCommandPath(env: Readonly): Promise { + const configuredPath = env[MACOS_HELPER_ENV_PATH]?.trim(); if (configuredPath) { const resolvedPath = await resolveExecutableOverridePath(configuredPath, MACOS_HELPER_ENV_PATH); if (resolvedPath) return resolvedPath; @@ -282,15 +286,16 @@ async function resolveMacOsHelperCommandPath(): Promise { if (hostPlatform() !== 'darwin') { throw new AppError('UNSUPPORTED_PLATFORM', 'macOS helper is only available on macOS'); } - return await ensureMacOsHelperBinary(); + return await ensureMacOsHelperBinary(env); } export async function startMacOsAudioProbeProcess(options: { durationMs: number; bucketMs: number; statusPath: string; + env: Readonly; }): Promise { - const helperPath = await resolveMacOsHelperCommandPath(); + const helperPath = await resolveMacOsHelperCommandPath(options.env); return runCmdBackground( helperPath, [ @@ -318,7 +323,7 @@ const MACOS_HELPER_KILL_GRACE_MS = 1_000; async function runMacOsHelper>( args: string[], - options: { signal?: AbortSignal; timeoutMs?: number } = {}, + options: { signal?: AbortSignal; timeoutMs?: number; env: Readonly }, ): Promise { const helperOptions = { allowFailure: true, @@ -329,7 +334,7 @@ async function runMacOsHelper>( const helperProvider = resolveAppleToolProvider().macosHelper; const helperPath = helperProvider ? MACOS_HELPER_PRODUCT_NAME - : await resolveMacOsHelperCommandPath(); + : await resolveMacOsHelperCommandPath(options.env); const result = helperProvider ? await helperProvider.run(args, helperOptions) : await runAppleToolCommand(helperPath, args, helperOptions); @@ -366,26 +371,32 @@ async function runMacOsHelper>( }); } -export async function resolveFrontmostMacOsApp(): Promise<{ +export async function resolveFrontmostMacOsApp(env: Readonly): Promise<{ bundleId?: string; appName?: string; pid?: number; }> { - return await runMacOsHelper(['app', 'frontmost']); + return await runMacOsHelper(['app', 'frontmost'], { env }); } -export async function quitMacOsApp(bundleId: string): Promise<{ +export async function quitMacOsApp( + bundleId: string, + env: Readonly, +): Promise<{ bundleId: string; running: boolean; terminated: boolean; forceTerminated: boolean; }> { - return await runMacOsHelper(['app', 'quit', '--bundle-id', assertMacOsBundleId(bundleId)]); + return await runMacOsHelper(['app', 'quit', '--bundle-id', assertMacOsBundleId(bundleId)], { + env, + }); } export async function runMacOsPermissionAction( action: 'grant' | 'reset', target: MacOsPermissionTarget, + env: Readonly, ): Promise<{ target: MacOsPermissionTarget; granted: boolean; @@ -394,12 +405,12 @@ export async function runMacOsPermissionAction( action: 'grant' | 'reset'; message?: string; }> { - return await runMacOsHelper(['permission', action, target]); + return await runMacOsHelper(['permission', action, target], { env }); } export async function runMacOsAlertAction( action: 'get' | 'accept' | 'dismiss', - options: { bundleId?: string; surface?: SessionSurface } = {}, + options: { bundleId?: string; surface?: SessionSurface; env: Readonly }, ): Promise<{ title?: string; role?: string; @@ -409,12 +420,12 @@ export async function runMacOsAlertAction( }> { const args = ['alert', action]; appendMacOsHelperContextArgs(args, options); - return await runMacOsHelper(args); + return await runMacOsHelper(args, { env: options.env }); } export async function runMacOsSnapshotAction( surface: MacOsHelperSurface, - options: { bundleId?: string; signal?: AbortSignal } = {}, + options: { bundleId?: string; signal?: AbortSignal; env: Readonly }, ): Promise<{ surface: SessionSurface; nodes: MacOsSnapshotNode[]; @@ -425,19 +436,24 @@ export async function runMacOsSnapshotAction( }> { const args = ['snapshot', '--surface', surface]; appendMacOsHelperContextArgs(args, options); - return await runMacOsHelper(args, { signal: options.signal }); + return await runMacOsHelper(args, { signal: options.signal, env: options.env }); } export async function runMacOsReadTextAction( x: number, y: number, - options: { surface: MacOsHelperSurface; bundleId?: string; signal?: AbortSignal }, + options: { + surface: MacOsHelperSurface; + bundleId?: string; + signal?: AbortSignal; + env: Readonly; + }, ): Promise<{ text: string; }> { const args = ['read', '--x', String(x), '--y', String(y)]; appendMacOsHelperContextArgs(args, options); - return await runMacOsHelper(args, { signal: options.signal }); + return await runMacOsHelper(args, { signal: options.signal, env: options.env }); } // Mirrors the helper's own floors (`MouseClickSchedule.swift`): the schedule the helper runs @@ -482,6 +498,7 @@ export async function runMacOsPressAction( doubleClick?: boolean; intervalMs?: number; signal?: AbortSignal; + env: Readonly; }, ): Promise<{ x: number; @@ -511,9 +528,10 @@ export async function runMacOsPressAction( args.push('--double-click'); } appendMacOsHelperContextArgs(args, options); - appendGhostCursorArg(args, options.surface); + appendGhostCursorArg(args, options.surface, options.env); return await runMacOsHelper(args, { signal: options.signal, + env: options.env, timeoutMs: macOsClickScheduleMs(options) + MACOS_HELPER_TIMEOUT_MS + ghostCursorBudgetMs(args), }); } @@ -521,33 +539,50 @@ export async function runMacOsPressAction( /** Inserts text at the app session's focused element without activating the app. */ export async function runMacOsTypeAction( text: string, - options: { bundleId: string; delayMs?: number; signal?: AbortSignal }, + options: { + bundleId: string; + delayMs?: number; + signal?: AbortSignal; + env: Readonly; + }, ): Promise<{ mechanism?: MacOsDeliveryMechanism; role?: string; windowTitle?: string }> { const args = ['type', '--text', text]; if (options.delayMs && options.delayMs > 0) args.push('--delay-ms', String(options.delayMs)); appendMacOsHelperContextArgs(args, { bundleId: options.bundleId }); - appendGhostCursorArg(args, 'app'); + appendGhostCursorArg(args, 'app', options.env); return await runMacOsHelper(args, { signal: options.signal, + env: options.env, timeoutMs: MACOS_HELPER_TIMEOUT_MS + ghostCursorBudgetMs(args) + text.length * (options.delayMs ?? 0), }); } +/** Dispatches one prepared app-surface action with the ghost-cursor settle budget added. */ +function runMacOsAppSurfaceAction>( + args: string[], + options: { bundleId: string; signal?: AbortSignal; env: Readonly }, +): Promise { + appendMacOsHelperContextArgs(args, { bundleId: options.bundleId }); + appendGhostCursorArg(args, 'app', options.env); + return runMacOsHelper(args, { + signal: options.signal, + env: options.env, + timeoutMs: MACOS_HELPER_TIMEOUT_MS + ghostCursorBudgetMs(args), + }); +} + /** Replaces the value of the app session's text input at a point. */ export async function runMacOsFillAction( x: number, y: number, text: string, - options: { bundleId: string; signal?: AbortSignal }, + options: { bundleId: string; signal?: AbortSignal; env: Readonly }, ): Promise<{ mechanism?: MacOsDeliveryMechanism; role?: string; windowTitle?: string }> { - const args = ['fill', '--x', String(x), '--y', String(y), '--text', text]; - appendMacOsHelperContextArgs(args, { bundleId: options.bundleId }); - appendGhostCursorArg(args, 'app'); - return await runMacOsHelper(args, { - signal: options.signal, - timeoutMs: MACOS_HELPER_TIMEOUT_MS + ghostCursorBudgetMs(args), - }); + return await runMacOsAppSurfaceAction( + ['fill', '--x', String(x), '--y', String(y), '--text', text], + options, + ); } /** Scrolls the scroll area at the center of the app session's front window. */ @@ -558,27 +593,28 @@ export async function runMacOsScrollAction( amount?: number; pixels?: number; signal?: AbortSignal; + env: Readonly; }, ): Promise> { const args = ['scroll', '--direction', direction]; if (options.amount !== undefined) args.push('--amount', String(options.amount)); if (options.pixels !== undefined) args.push('--pixels', String(options.pixels)); - appendMacOsHelperContextArgs(args, { bundleId: options.bundleId }); - appendGhostCursorArg(args, 'app'); - return await runMacOsHelper(args, { - signal: options.signal, - timeoutMs: MACOS_HELPER_TIMEOUT_MS + ghostCursorBudgetMs(args), - }); + return await runMacOsAppSurfaceAction(args, options); } export async function runMacOsScreenshotAction( outPath: string, - options: { surface: MacOsHelperSurface; bundleId?: string; signal?: AbortSignal }, + options: { + surface: MacOsHelperSurface; + bundleId?: string; + signal?: AbortSignal; + env: Readonly; + }, ): Promise<{ path: string; surface?: SessionSurface; }> { const args = ['screenshot', '--out', outPath]; appendMacOsHelperContextArgs(args, options); - return await runMacOsHelper(args, { signal: options.signal }); + return await runMacOsHelper(args, { signal: options.signal, env: options.env }); } diff --git a/packages/platform-apple/src/os/macos/native-app-interactor.test.ts b/packages/platform-apple/src/os/macos/native-app-interactor.test.ts index 1e6a8ce33f..13d7cfcacb 100644 --- a/packages/platform-apple/src/os/macos/native-app-interactor.test.ts +++ b/packages/platform-apple/src/os/macos/native-app-interactor.test.ts @@ -29,7 +29,7 @@ function runnerInteractor(reached: string[] = []): Interactor { } function nativeInteractor(ctx: RunnerContext = context, reached: string[] = []): Interactor { - return macOsNativeAppInteractor(runnerInteractor(reached), ctx); + return macOsNativeAppInteractor(runnerInteractor(reached), ctx, process.env); } async function recordHelperCalls( diff --git a/packages/platform-apple/src/os/macos/native-app-interactor.ts b/packages/platform-apple/src/os/macos/native-app-interactor.ts index 630d04c27b..2bda964bef 100644 --- a/packages/platform-apple/src/os/macos/native-app-interactor.ts +++ b/packages/platform-apple/src/os/macos/native-app-interactor.ts @@ -6,6 +6,7 @@ import type { import { macOsHelperSurface } from '@agent-device/contracts/session'; import { assertScrollGestureInput } from '@agent-device/contracts/scroll-gesture'; import { AppError } from '@agent-device/kernel/errors'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { normalizeAppleScrollResultWithResolvedFrame } from '../../core/scroll.ts'; import { MACOS_HELPER_REFUSAL_REASONS, @@ -29,7 +30,11 @@ const REFUSAL_REASONS: ReadonlySet = new Set(MACOS_HELPER_REFUSAL_REASON * Runner-only commands are refused at admission by `macOsNativeBackendFacts`; the members the * interface requires for them refuse the same way. */ -export function macOsNativeAppInteractor(base: Interactor, ctx: RunnerContext): Interactor { +export function macOsNativeAppInteractor( + base: Interactor, + ctx: RunnerContext, + env: Readonly, +): Interactor { const bundleId = (): string => { if (ctx.appBundleId) return ctx.appBundleId; throw nativeBackendRefusal('an action on a session that names no app'); @@ -45,6 +50,7 @@ export function macOsNativeAppInteractor(base: Interactor, ctx: RunnerContext): clicks: options.count, intervalMs: options.intervalMs, signal: ctx.signal, + env, }), ); return actedOn(result); @@ -80,12 +86,12 @@ export function macOsNativeAppInteractor(base: Interactor, ctx: RunnerContext): focus: async (x, y) => await press({ x, y }), type: async (text, delayMs) => { await withHelperRefusals( - runMacOsTypeAction(text, { bundleId: bundleId(), delayMs, signal: ctx.signal }), + runMacOsTypeAction(text, { bundleId: bundleId(), delayMs, signal: ctx.signal, env }), ); }, fill: async (x, y, text) => { const result = await withHelperRefusals( - runMacOsFillAction(x, y, text, { bundleId: bundleId(), signal: ctx.signal }), + runMacOsFillAction(x, y, text, { bundleId: bundleId(), signal: ctx.signal, env }), ); return actedOn(result); }, @@ -97,6 +103,7 @@ export function macOsNativeAppInteractor(base: Interactor, ctx: RunnerContext): amount: options?.amount, pixels: options?.pixels, signal: ctx.signal, + env, }), ); return { diff --git a/packages/platform-apple/src/os/macos/surface-snapshot.test.ts b/packages/platform-apple/src/os/macos/surface-snapshot.test.ts index 35f1639aea..830f8741d2 100644 --- a/packages/platform-apple/src/os/macos/surface-snapshot.test.ts +++ b/packages/platform-apple/src/os/macos/surface-snapshot.test.ts @@ -30,10 +30,14 @@ test('an app snapshot carries the helper warning that web content may be missing const result = await withAppleToolProvider( provider, async () => - await captureMacOsSurfaceSnapshot({ - surface: macOsHelperSurface('app', 'native')!, - appBundleId: 'com.openai.codex', - }), + await captureMacOsSurfaceSnapshot( + { + surface: macOsHelperSurface('app', 'native')!, + appBundleId: 'com.openai.codex', + }, + undefined, + process.env, + ), ); expect(calls).toEqual([['snapshot', '--surface', 'app', '--bundle-id', 'com.openai.codex']]); expect(result.warnings).toEqual([warning]); diff --git a/packages/platform-apple/src/os/macos/surface-snapshot.ts b/packages/platform-apple/src/os/macos/surface-snapshot.ts index 36398e05bc..3db1b8cd25 100644 --- a/packages/platform-apple/src/os/macos/surface-snapshot.ts +++ b/packages/platform-apple/src/os/macos/surface-snapshot.ts @@ -1,5 +1,6 @@ import type { CaptureSnapshotInput } from '@agent-device/contracts/snapshot-runtime'; import type { MacOsHelperSurface } from '@agent-device/contracts/session'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { shapeDesktopSurfaceSnapshot } from '@agent-device/capture-kit/snapshot-desktop-projection'; type SnapshotSurfaceOptions = Omit, 'surface'> & { @@ -8,13 +9,15 @@ type SnapshotSurfaceOptions = Omit, export async function captureMacOsSurfaceSnapshot( options: SnapshotSurfaceOptions, - signal?: AbortSignal, + signal: AbortSignal | undefined, + env: Readonly, ) { const surface = options.surface; const { runMacOsSnapshotAction } = await import('./helper.ts'); const result = await runMacOsSnapshotAction(surface, { bundleId: surface === 'menubar' || surface === 'app' ? options.appBundleId : undefined, signal, + env, }); return shapeDesktopSurfaceSnapshot({ ...result, producer: 'macos-helper' }, options); } diff --git a/packages/platform-apple/src/runner-owner-facade.ts b/packages/platform-apple/src/runner-owner-facade.ts index 123e115748..53f6e2892d 100644 --- a/packages/platform-apple/src/runner-owner-facade.ts +++ b/packages/platform-apple/src/runner-owner-facade.ts @@ -1,5 +1,6 @@ export { createAppleRunnerRuntimeState, + setAppleRunnerHostEnvironment, setRunnerDeviceClaimAuthorityProbe, setRunnerLeaseOwnerStateDir, type RunnerDeviceClaimAuthorityProbe, diff --git a/packages/platform-apple/src/runner/__tests__/host-setup.ts b/packages/platform-apple/src/runner/__tests__/host-setup.ts index d506a521fb..c37952bc0e 100644 --- a/packages/platform-apple/src/runner/__tests__/host-setup.ts +++ b/packages/platform-apple/src/runner/__tests__/host-setup.ts @@ -5,6 +5,7 @@ import { installAppleRunnerTestHost, loadAppleRunnerHost, } from '../test-host.ts'; +import { setAppleRunnerHostEnvironment } from '../../core/runner-owner-state.ts'; // The apple-runner vitest project runs the package's own suites, which cannot // import root utilities directly (R11). This setup installs the real root host @@ -18,4 +19,7 @@ bindTestCommandHost(); beforeEach(() => { appleRunnerTestHost.reset(); + // Runner configuration reads take the runtime's host (#3794). A package suite runs outside a + // composed runtime, so it binds the live process host the command host above carries. + setAppleRunnerHostEnvironment({ env: process.env, cwd: process.cwd() }); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-lease-runtime-host.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lease-runtime-host.test.ts new file mode 100644 index 0000000000..a5db211675 --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-lease-runtime-host.test.ts @@ -0,0 +1,54 @@ +import { expect, test, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdtempForTestSync } from './tmp-dir.ts'; +import { createAppleRunnerRuntimeState } from '../../core/runner-owner-state.ts'; +import { currentRunnerLeaseOwnerToken, writeRunnerLease } from '../runner-lease.ts'; + +// Each composed runtime reads its runner configuration from the host it was composed with, not +// from the process (#3794). Two runtimes with different lease directories must resolve their own. +test("each runner runtime resolves its host's runner lease directory, not the process value", () => { + // The process itself names no lease directory, so the un-composed read below lands on the + // default home location rather than an operator-set directory. + vi.stubEnv('AGENT_DEVICE_IOS_RUNNER_LEASE_DIR', ''); + const root = mkdtempForTestSync('apple-runner-lease-host-'); + const dirA = path.join(root, 'a'); + const dirB = path.join(root, 'b'); + const runtimeA = createAppleRunnerRuntimeState({ + env: { ...process.env, AGENT_DEVICE_IOS_RUNNER_LEASE_DIR: dirA }, + cwd: process.cwd(), + }); + const runtimeB = createAppleRunnerRuntimeState({ + env: { ...process.env, AGENT_DEVICE_IOS_RUNNER_LEASE_DIR: dirB }, + cwd: process.cwd(), + }); + + runtimeA.run(() => + writeRunnerLease({ + schemaVersion: 1, + deviceId: 'device-1', + ownerToken: 'token-a', + ownerPid: process.pid, + ownerStartTime: null, + sessionId: 'session-a', + runnerPid: null, + runnerStartTime: null, + port: 8100, + xctestrunPath: path.join(root, 'run.xctestrun'), + jsonPath: path.join(root, 'run.json'), + createdAtMs: Date.now(), + }), + ); + + // A's lease is visible to A and invisible to B: each resolves its own directory. + expect(runtimeA.run(() => currentRunnerLeaseOwnerToken('device-1'))).toBe('token-a'); + expect(runtimeB.run(() => currentRunnerLeaseOwnerToken('device-1'))).toBeNull(); + // And invisible to the process default, which reads neither override. + expect(currentRunnerLeaseOwnerToken('device-1')).toBeNull(); + expect(fs.existsSync(path.join(dirA, 'device-1.json'))).toBe(true); + expect(fs.existsSync(path.join(dirB, 'device-1.json'))).toBe(false); + + runtimeA.dispose(); + runtimeB.dispose(); + fs.rmSync(root, { recursive: true, force: true }); +}); diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-runtime-isolation.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-runtime-isolation.test.ts index 6d0e3db2c7..09f480fa5d 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-runtime-isolation.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-runtime-isolation.test.ts @@ -7,6 +7,7 @@ import { appleRunnerTestHost } from '../test-host.ts'; import { registerRunnerPrepProcess } from '../runner-artifact.ts'; import { stopAllIosRunnerSessions } from '../runner-session.ts'; import { openRunnerStartAdmission, runnerPrepProcessChildren } from '../runner-xctestrun.ts'; +import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; const mockSignalProcessGroupBestEffort = vi.fn(); const mockRunAppleToolCommand = vi.fn(); @@ -29,8 +30,8 @@ function prepChild(pid: number): ExecBackgroundResult['child'] { } test("stopping every runner of one runtime leaves another runtime's prep build and admission alone", async () => { - const runtimeA = createAppleRunnerRuntimeState(); - const runtimeB = createAppleRunnerRuntimeState(); + const runtimeA = createAppleRunnerRuntimeState(createLiveTestHostEnvironment()); + const runtimeB = createAppleRunnerRuntimeState(createLiveTestHostEnvironment()); const deviceId = 'runner-runtime-isolation-sim'; const buildA = prepChild(6101); const buildB = prepChild(6102); diff --git a/packages/platform-apple/src/runtime.fixtures.ts b/packages/platform-apple/src/runtime.fixtures.ts index 2009265840..f63ec91570 100644 --- a/packages/platform-apple/src/runtime.fixtures.ts +++ b/packages/platform-apple/src/runtime.fixtures.ts @@ -6,6 +6,7 @@ import { hostFixture } from './logs/runtime.fixtures.ts'; export function platformRuntimeHostFixture(): PlatformRuntimeHost { return { ...hostFixture().host, + hostEnvironment: { env: process.env, cwd: process.cwd() }, appLogs: { readRecent: async () => ({ path: '/sessions/one/app.log', diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index 9f387781d0..7b3f8a67d5 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -265,7 +265,9 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR // bindings agree on it. Other Apple devices never read it, so a bad value cannot fail them. let resolvedMacOsAppBackend: MacOsAppBackend | undefined; const macOsAppBackend = (device: DeviceInfo): MacOsAppBackend => - isMacOs(device) ? (resolvedMacOsAppBackend ??= hostMacOsAppBackend()) : 'xctest'; + isMacOs(device) + ? (resolvedMacOsAppBackend ??= hostMacOsAppBackend(host.hostEnvironment.env)) + : 'xctest'; const inspectFacts = async (device: DeviceInfo) => { const logs = await appLogs.inspectFacts(device); const deployment = appleAppDeploymentFacts(device); diff --git a/packages/platform-apple/src/snapshot-route.ts b/packages/platform-apple/src/snapshot-route.ts index 0e49378f80..ae6d9a6c90 100644 --- a/packages/platform-apple/src/snapshot-route.ts +++ b/packages/platform-apple/src/snapshot-route.ts @@ -84,7 +84,7 @@ export function createAppleSnapshotRoute( systemSurfacePresent?: SystemSurfacePresenceProbe; }> = {}, ): AppleSnapshotRoute { - const source = options.source ?? createSimulatorSnapshotSource(); + const source = options.source ?? createSimulatorSnapshotSource(host.hostEnvironment); const resolveTarget = options.resolveTarget ?? createSimulatorSnapshotTargetResolver(); const systemSurfacePresent = options.systemSurfacePresent ?? createSystemSurfacePresenceProbe(); const disabledGenerations = new Set(); diff --git a/packages/platform-apple/src/snapshot-source-facade.ts b/packages/platform-apple/src/snapshot-source-facade.ts index dc58e1edcd..9a4e9fa13e 100644 --- a/packages/platform-apple/src/snapshot-source-facade.ts +++ b/packages/platform-apple/src/snapshot-source-facade.ts @@ -11,6 +11,8 @@ export type { SnapshotSourceTarget, } from './snapshot-source/types.ts'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; + import type { SnapshotSourceOutcome, SnapshotSourceRequest } from './snapshot-source/types.ts'; export type SimulatorSnapshotSource = Readonly<{ @@ -18,14 +20,16 @@ export type SimulatorSnapshotSource = Readonly<{ close(): Promise; }>; -export function createSimulatorSnapshotSource(): SimulatorSnapshotSource { +export function createSimulatorSnapshotSource(host: HostEnvironment): SimulatorSnapshotSource { let implementation: | Promise | undefined; let closed = false; const load = async () => { - implementation ??= import('./snapshot-source/adapter.ts').then( - ({ createSimulatorSnapshotSource: create }) => create(), + implementation ??= import('./snapshot-source/host.ts').then(({ createSnapshotSourceHost }) => + import('./snapshot-source/adapter.ts').then(({ createSimulatorSnapshotSource: create }) => + create({ host: createSnapshotSourceHost(host) }), + ), ); return await implementation; }; diff --git a/packages/platform-apple/src/snapshot-source/adapter.test.ts b/packages/platform-apple/src/snapshot-source/adapter.test.ts index 0340185f3d..3c9df88ae0 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.test.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.test.ts @@ -24,6 +24,7 @@ import type { SnapshotSourceSocket, } from './types.ts'; import { mkdtempForTest } from '../__tests__/tmp-dir.ts'; +import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; test('the Simulator AX source returns raw acquisition facts and discloses unsupported facets', async () => { const root = await mkdtempForTest('agent-device-snapshot-adapter-'); @@ -493,7 +494,7 @@ function targetForTest(simulatorSetPath?: string) { } function createAdapterHost(buildDelayMs = 0): AdapterFixture { - const realHost = createSnapshotSourceHost(); + const realHost = createSnapshotSourceHost(createLiveTestHostEnvironment()); const fixture: AdapterFixture = { host: undefined as never, builds: 0, diff --git a/packages/platform-apple/src/snapshot-source/adapter.ts b/packages/platform-apple/src/snapshot-source/adapter.ts index 3ac46f6390..c721cbe59c 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.ts @@ -12,7 +12,6 @@ import { resolveSnapshotSourceLimits } from './limits.ts'; import { readSnapshotBridgeRecovery, type SnapshotBridgeEnvelope } from './protocol.ts'; import { createSnapshotBridgePreparation } from './preparation.ts'; import { decodeSnapshotBridgeTree } from './tree.ts'; -import { createSnapshotSourceHost } from './host.ts'; import type { SnapshotSourceHost, SnapshotSourceLimits, @@ -23,7 +22,7 @@ import type { const SNAPSHOT_SOURCE_PRODUCER = 'simulator-ax-bridge' as const; export type SimulatorSnapshotSourceOptions = Readonly<{ - host?: SnapshotSourceHost; + host: SnapshotSourceHost; limits?: Partial; sourceRoot?: string; cacheRoot?: string; @@ -35,9 +34,9 @@ export type SimulatorSnapshotSource = Readonly<{ }>; export function createSimulatorSnapshotSource( - options: SimulatorSnapshotSourceOptions = {}, + options: SimulatorSnapshotSourceOptions, ): SimulatorSnapshotSource { - const host = options.host ?? createSnapshotSourceHost(); + const host = options.host; const manager = new SnapshotBridgeManager(host); const depthHints = new AcceptedDepthHints(); // Preparation is daemon-scoped work: it reads the toolchain identity, fingerprints the bridge diff --git a/packages/platform-apple/src/snapshot-source/cache-identity.test.ts b/packages/platform-apple/src/snapshot-source/cache-identity.test.ts index 8eb74a651d..d98a3f4ee2 100644 --- a/packages/platform-apple/src/snapshot-source/cache-identity.test.ts +++ b/packages/platform-apple/src/snapshot-source/cache-identity.test.ts @@ -6,6 +6,7 @@ import { readSnapshotSourceToolchain } from './cache-identity.ts'; import { createSnapshotSourceDeadline } from './deadline.ts'; import { SnapshotSourceError } from './errors.ts'; import type { SnapshotSourceHost } from './types.ts'; +import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; // The retry, deadline, and cancellation behavior this composes lives with the shared identity read // it delegates to: see `native-build/toolchain-identity.test.ts`. These cases cover only what this @@ -15,7 +16,7 @@ function fakeToolchainHost( run: (command: string, args: string[], options: ExecOptions) => ExecResult, cpuArch = 'arm64', ): SnapshotSourceHost { - const real = createSnapshotSourceHost(); + const real = createSnapshotSourceHost(createLiveTestHostEnvironment()); return { ...real, cpuArch: async () => cpuArch, diff --git a/packages/platform-apple/src/snapshot-source/cache.test.ts b/packages/platform-apple/src/snapshot-source/cache.test.ts index 11511753de..4fcacccead 100644 --- a/packages/platform-apple/src/snapshot-source/cache.test.ts +++ b/packages/platform-apple/src/snapshot-source/cache.test.ts @@ -12,6 +12,7 @@ import { DEFAULT_SNAPSHOT_SOURCE_LIMITS } from './limits.ts'; import type { SnapshotSourceHost } from './types.ts'; import { execKillTimeoutError } from '../native-build/__tests__/exec-timeout-fixture.ts'; import { mkdtempForTest } from '../__tests__/tmp-dir.ts'; +import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt or stale entries', async () => { const root = await mkdtempForTest('agent-device-snapshot-source-'); @@ -345,7 +346,7 @@ function createFakeBuildHost( binary: string | (() => string | Promise), getXcode: () => string = () => 'Xcode 16.4\nBuild version 16F6', ): SnapshotSourceHost { - const real = createSnapshotSourceHost(); + const real = createSnapshotSourceHost(createLiveTestHostEnvironment()); return { ...real, cpuArch: async () => 'arm64', diff --git a/packages/platform-apple/src/snapshot-source/host.test.ts b/packages/platform-apple/src/snapshot-source/host.test.ts index 8461b0e4c2..3780799c66 100644 --- a/packages/platform-apple/src/snapshot-source/host.test.ts +++ b/packages/platform-apple/src/snapshot-source/host.test.ts @@ -4,7 +4,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { test, vi } from 'vitest'; import { runCmdBackground, withCommandHost } from '@agent-device/host-kit/command'; -import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + bindTestCommandHost, + createLiveTestHostEnvironment, + mkdtempForTestSync, +} from '@agent-device/test-kit'; import { simulatorAddressFor } from '../core/simctl.ts'; import { createSnapshotSourceHost, snapshotSourceSocketPath } from './host.ts'; @@ -16,7 +20,7 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ })); test('snapshot bridge socket paths stay within the AF_UNIX limit and are target-specific', () => { - const host = createSnapshotSourceHost(); + const host = createSnapshotSourceHost(createLiveTestHostEnvironment()); const first = snapshotSourceSocketPath(host, 'simulator-1', 'owner-1'); const second = snapshotSourceSocketPath(host, 'simulator-2', 'owner-1'); const otherOwner = snapshotSourceSocketPath(host, 'simulator-1', 'owner-2'); @@ -45,7 +49,7 @@ test.each([ wait: new Promise(() => {}), }); - const started = createSnapshotSourceHost().start( + const started = createSnapshotSourceHost(createLiveTestHostEnvironment()).start( simulatorAddressFor({ platform: 'apple', id: 'simulator-1', @@ -95,7 +99,7 @@ test('the detached bridge starts in the host directory, not the process director await withCommandHost( { host: { env: { PATH: bin, EVIDENCE: evidence }, cwd: hostCwd }, requestEnv: undefined }, async () => { - createSnapshotSourceHost().start( + createSnapshotSourceHost({ env: { PATH: bin, EVIDENCE: evidence }, cwd: hostCwd }).start( simulatorAddressFor({ platform: 'apple', id: 'simulator-1', diff --git a/packages/platform-apple/src/snapshot-source/host.ts b/packages/platform-apple/src/snapshot-source/host.ts index fe006334db..ef3153cc03 100644 --- a/packages/platform-apple/src/snapshot-source/host.ts +++ b/packages/platform-apple/src/snapshot-source/host.ts @@ -1,7 +1,8 @@ import { createHash } from 'node:crypto'; import net from 'node:net'; import path from 'node:path'; -import { commandHostEnvironment, runCmd, runCmdBackground } from '@agent-device/host-kit/command'; +import { runCmd, runCmdBackground } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; 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'; @@ -16,7 +17,7 @@ const BRIDGE_IDLE_TIMEOUT_SECONDS = 60; const MAX_PROCESS_LOG_BYTES = 64 * 1024; const SNAPSHOT_SOCKET_ROOT = '/tmp'; -export function createSnapshotSourceHost(): SnapshotSourceHost { +export function createSnapshotSourceHost(host: HostEnvironment): SnapshotSourceHost { // The bridge's build/cache access is the shared native-build host (#2970); this adds only what a // bridge session needs beyond a build: socket start/connect, diagnostics, and target inspection. // A native-build cache failure surfaces here as `NativeBuildError`; the cache's own callers @@ -27,7 +28,8 @@ export function createSnapshotSourceHost(): SnapshotSourceHost { ), projectRoot: findProjectRoot, homeDirectory: hostHomeDirectory, - start: startSnapshotBridge, + start: (simulator, bridgePath, socketPath, options) => + startSnapshotBridge(host, simulator, bridgePath, socketPath, options), connect: connectSnapshotBridge, emitDiagnostic, withDiagnosticTimer, @@ -36,6 +38,7 @@ export function createSnapshotSourceHost(): SnapshotSourceHost { } function startSnapshotBridge( + host: HostEnvironment, simulator: SimulatorAddress, bridgePath: string, socketPath: string, @@ -45,7 +48,7 @@ function startSnapshotBridge( 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 { env, cwd } = host; const started = runCmdBackground( 'xcrun', buildSimctlArgsForAddress(simulator, [ diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts index f817182837..8f4785c109 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.test.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.test.ts @@ -11,6 +11,7 @@ import { } from './protocol.ts'; import { SnapshotBridgeManager } from './lifecycle.ts'; import { simulatorAddressFor } from '../core/simctl.ts'; +import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; import type { SnapshotSourceHost, SnapshotSourceLimits, @@ -436,7 +437,7 @@ function createLifecycleFixture( const diagnostics: LifecycleFixture['diagnostics'] = []; const socketPaths: string[] = []; const startedTargets: LifecycleFixture['startedTargets'] = []; - const realHost = createSnapshotSourceHost(); + const realHost = createSnapshotSourceHost(createLiveTestHostEnvironment()); const host: SnapshotSourceHost = { ...realHost, emitDiagnostic: (event) => diagnostics.push(event), diff --git a/packages/platform-apple/src/snapshot-source/preparation.test.ts b/packages/platform-apple/src/snapshot-source/preparation.test.ts index 17c68600cc..33d66c0a7b 100644 --- a/packages/platform-apple/src/snapshot-source/preparation.test.ts +++ b/packages/platform-apple/src/snapshot-source/preparation.test.ts @@ -12,6 +12,7 @@ import { SNAPSHOT_BRIDGE_PREPARATION_RETRY_AFTER_MS, } from './preparation.ts'; import type { SnapshotSourceBridgeBinary, SnapshotSourceHost } from './types.ts'; +import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; const BRIDGE_SOURCES = [ 'SnapshotBridge.m', @@ -265,7 +266,7 @@ function createPreparationHost( signals: (AbortSignal | undefined)[]; } = { host: undefined as never, builds: 0, signals: [] }; const host: SnapshotSourceHost = { - ...createSnapshotSourceHost(), + ...createSnapshotSourceHost(createLiveTestHostEnvironment()), cpuArch: async () => 'arm64', run: async (command, args, options) => { if (command === 'xcrun' && args.includes('clang')) { diff --git a/packages/platform-harmonyos/src/__tests__/hdc.test.ts b/packages/platform-harmonyos/src/__tests__/hdc.test.ts index f4e9ab8a35..d12126ae10 100644 --- a/packages/platform-harmonyos/src/__tests__/hdc.test.ts +++ b/packages/platform-harmonyos/src/__tests__/hdc.test.ts @@ -144,7 +144,7 @@ test('ensureHarmonyToolchainPathConfigured defaults to the host environment', as vi.stubEnv('PATH', '/usr/bin'); vi.stubEnv('HDC_SDK_PATH', toolchains); - await ensureHarmonyToolchainPathConfigured(); + await ensureHarmonyToolchainPathConfigured(process.env); assert.equal(process.env.PATH, [toolchains, '/usr/bin'].join(path.delimiter)); }); diff --git a/packages/platform-harmonyos/src/hdc.ts b/packages/platform-harmonyos/src/hdc.ts index 09cefcee82..e2b7a1dd6b 100644 --- a/packages/platform-harmonyos/src/hdc.ts +++ b/packages/platform-harmonyos/src/hdc.ts @@ -10,7 +10,6 @@ import { runCmd, type ExecOptions, type ExecResult, - commandHostEnvironment, } from '@agent-device/host-kit/command'; export type HarmonyHdcOptions = Pick< @@ -49,9 +48,7 @@ export async function runHarmonyShell( * DevEco's command-line tools do not amend PATH for non-interactive processes. * Honor the documented roots so the daemon sees the same HDC binary as a shell. */ -export async function ensureHarmonyToolchainPathConfigured( - env: NodeJS.ProcessEnv = commandHostEnvironment().env, -): Promise { +export async function ensureHarmonyToolchainPathConfigured(env: NodeJS.ProcessEnv): Promise { const toolchainRoots = [ env.HDC_SDK_PATH, env.DEVECO_SDK_HOME diff --git a/packages/platform-linux/src/__tests__/input-actions.test.ts b/packages/platform-linux/src/__tests__/input-actions.test.ts index 1f5ce950be..18a1b7fc78 100644 --- a/packages/platform-linux/src/__tests__/input-actions.test.ts +++ b/packages/platform-linux/src/__tests__/input-actions.test.ts @@ -8,7 +8,6 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => { }); import { runCmd, whichCmd } from '@agent-device/host-kit/command'; -import { resetInputToolCache } from '../linux-env.ts'; import { fillLinux, pressLinux, scrollLinux, typeLinux, sendKey } from '../input-actions.ts'; bindTestCommandHost(); @@ -23,7 +22,6 @@ function setupXdotool(): void { Object.defineProperty(process, 'platform', { value: 'linux' }); process.env['XDG_SESSION_TYPE'] = 'x11'; delete process.env['WAYLAND_DISPLAY']; - resetInputToolCache(); mockWhichCmd.mockImplementation(async (cmd) => cmd === 'xdotool'); mockRunCmd.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); } @@ -32,7 +30,6 @@ function setupYdotool(): void { Object.defineProperty(process, 'platform', { value: 'linux' }); process.env['XDG_SESSION_TYPE'] = 'wayland'; process.env['WAYLAND_DISPLAY'] = 'wayland-0'; - resetInputToolCache(); mockWhichCmd.mockImplementation(async (cmd) => cmd === 'ydotool'); mockRunCmd.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); } @@ -45,7 +42,6 @@ function calls(): Array<[string, string[]]> { beforeEach(() => { mockRunCmd.mockReset(); mockWhichCmd.mockReset(); - resetInputToolCache(); }); afterAll(() => { diff --git a/packages/platform-linux/src/__tests__/interactor-host-isolation.test.ts b/packages/platform-linux/src/__tests__/interactor-host-isolation.test.ts new file mode 100644 index 0000000000..b993301f8a --- /dev/null +++ b/packages/platform-linux/src/__tests__/interactor-host-isolation.test.ts @@ -0,0 +1,84 @@ +import { afterAll, beforeEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import { bindTestCommandHost } from '@agent-device/test-kit'; + +vi.mock('@agent-device/host-kit/command', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runCmd: vi.fn(), whichCmd: vi.fn() }; +}); + +import { runCmd, whichCmd } from '@agent-device/host-kit/command'; +import { createLinuxInteractor } from '../interactor.ts'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; + +bindTestCommandHost(); + +// Two composing runtimes drive the plain local provider through their own hosts. Each interactor +// resolves its display decision from its own provider: A(X11) → B(Wayland) → A answers +// xdotool → ydotool → xdotool, against a process environment that claims Wayland for everyone. +// The old ambient decision answered host A's X11 for host B (#3794). + +const originalPlatform = process.platform; +const originalEnv = { ...process.env }; + +const mockRunCmd = vi.mocked(runCmd); +const mockWhichCmd = vi.mocked(whichCmd); + +function hostWithEnv(env: Record): HostEnvironment { + return { env: { ...process.env, ...env }, cwd: process.cwd() }; +} + +beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + // The process environment claims a Wayland session for the whole machine; neither host names + // this display, so any answer read from the process instead of the runtime host is wrong here. + process.env['WAYLAND_DISPLAY'] = 'wayland-9'; + process.env['XDG_SESSION_TYPE'] = 'wayland'; + mockRunCmd.mockReset(); + mockWhichCmd.mockReset(); + mockWhichCmd.mockImplementation(async (cmd) => cmd === 'xdotool' || cmd === 'ydotool'); + mockRunCmd.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); +}); + +afterAll(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); +}); + +function synthesisCommands(): string[] { + return mockRunCmd.mock.calls + .map(([cmd, args]) => ({ cmd, first: (args as string[])[0] })) + .filter(({ cmd, first }) => cmd !== 'xdotool' || first !== 'getmouselocation') + .map(({ cmd }) => cmd); +} + +test('interactors on different hosts resolve input through their own host display', async () => { + const hostA = hostWithEnv({ WAYLAND_DISPLAY: undefined, XDG_SESSION_TYPE: 'x11' }); + const hostB = hostWithEnv({ WAYLAND_DISPLAY: 'wayland-1', XDG_SESSION_TYPE: 'wayland' }); + const interactorA = createLinuxInteractor(hostA); + const interactorB = createLinuxInteractor(hostB); + + // Phase A: the X11 host resolves xdotool from its own host env. + await interactorA.tap!(1, 2); + assert.deepEqual(synthesisCommands(), ['xdotool', 'xdotool']); + + // Phase B: the Wayland host must resolve ydotool even though A resolved xdotool first. The + // old ambient decision answered host A's X11 here, against B's own host. + mockRunCmd.mockClear(); + await interactorB.tap!(3, 4); + assert.deepEqual( + mockRunCmd.mock.calls.map(([cmd, args]) => [cmd, ...(args as string[])]), + [ + ['ydotool', 'mousemove', '--absolute', '-x', '3', '-y', '4'], + ['ydotool', 'click', '0xC0'], + ], + ); + + // Phase A again: B's Wayland answer never leaked into A's provider. + mockRunCmd.mockClear(); + await interactorA.tap!(5, 6); + assert.deepEqual(synthesisCommands(), ['xdotool', 'xdotool']); +}); diff --git a/packages/platform-linux/src/__tests__/tool-provider.test.ts b/packages/platform-linux/src/__tests__/tool-provider.test.ts index 95f0c654c4..1bd97bdf03 100644 --- a/packages/platform-linux/src/__tests__/tool-provider.test.ts +++ b/packages/platform-linux/src/__tests__/tool-provider.test.ts @@ -2,7 +2,6 @@ import { bindTestCommandHost } from '@agent-device/test-kit'; import { afterAll, beforeEach, test } from 'vitest'; import assert from 'node:assert/strict'; import { pressLinux } from '../input-actions.ts'; -import { resetInputToolCache } from '../linux-env.ts'; import { createLocalLinuxToolProvider, withLinuxToolProvider } from '../tool-provider.ts'; bindTestCommandHost(); @@ -14,12 +13,12 @@ beforeEach(() => { Object.defineProperty(process, 'platform', { value: 'linux' }); process.env['XDG_SESSION_TYPE'] = 'x11'; delete process.env['WAYLAND_DISPLAY']; - resetInputToolCache(); }); test('scoped Linux tool provider handles input discovery and command execution', async () => { const commands: Array<[string, string[]]> = []; const provider = createLocalLinuxToolProvider({ + host: { env: { ...process.env, XDG_SESSION_TYPE: 'x11' }, cwd: process.cwd() }, whichCommand: async (cmd) => cmd === 'xdotool', runCommand: async (cmd, args) => { commands.push([cmd, args]); @@ -42,6 +41,7 @@ test('scoped Linux tool provider handles input discovery and command execution', test('xdotool press skips the --sync move when the pointer already sits on the target', async () => { const commands: Array<[string, string[]]> = []; const provider = createLocalLinuxToolProvider({ + host: { env: { ...process.env, XDG_SESSION_TYPE: 'x11' }, cwd: process.cwd() }, whichCommand: async (cmd) => cmd === 'xdotool', runCommand: async (cmd, args) => { commands.push([cmd, args]); @@ -64,6 +64,7 @@ test('xdotool press skips the --sync move when the pointer already sits on the t test('scoped Linux input provider handles semantic input without host tool discovery', async () => { const inputCalls: Array<[string, string, string, string]> = []; const provider = createLocalLinuxToolProvider({ + host: { env: { ...process.env, XDG_SESSION_TYPE: 'x11' }, cwd: process.cwd() }, whichCommand: async (cmd) => { throw new Error(`unexpected input discovery: ${cmd}`); }, @@ -92,10 +93,18 @@ test('scoped Linux input provider handles semantic input without host tool disco test('Linux tool provider scopes do not share cached input tool resolution', async () => { const providerA = createLocalLinuxToolProvider({ + host: { + env: { ...process.env, XDG_SESSION_TYPE: 'x11', WAYLAND_DISPLAY: '' }, + cwd: process.cwd(), + }, whichCommand: async (cmd) => cmd === 'xdotool', runCommand: async () => ({ exitCode: 0, stdout: '', stderr: '' }), }); const providerB = createLocalLinuxToolProvider({ + host: { + env: { ...process.env, XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' }, + cwd: process.cwd(), + }, whichCommand: async (cmd) => cmd === 'ydotool', runCommand: async () => ({ exitCode: 0, stdout: '', stderr: '' }), }); @@ -110,6 +119,10 @@ test('Linux tool provider scopes do not share cached input tool resolution', asy await withLinuxToolProvider( createLocalLinuxToolProvider({ + host: { + env: { ...process.env, XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' }, + cwd: process.cwd(), + }, ...providerB, runCommand: async (cmd, args) => { commands.push([cmd, args]); @@ -130,6 +143,7 @@ test('Linux tool provider scopes do not share cached input tool resolution', asy test('local Linux desktop provider translates semantic lifecycle calls to host tools', async () => { const commands: Array<[string, string[], boolean | undefined]> = []; const provider = createLocalLinuxToolProvider({ + host: { env: { ...process.env, XDG_SESSION_TYPE: 'x11' }, cwd: process.cwd() }, whichCommand: async (cmd) => cmd === 'wmctrl', runCommand: async (cmd, args, options) => { commands.push([cmd, args, options?.allowFailure]); @@ -151,6 +165,7 @@ test('local Linux desktop provider translates semantic lifecycle calls to host t test('local Linux desktop provider falls back to pkill when wmctrl is unavailable', async () => { const commands: Array<[string, string[], boolean | undefined]> = []; const provider = createLocalLinuxToolProvider({ + host: { env: { ...process.env, XDG_SESSION_TYPE: 'x11' }, cwd: process.cwd() }, whichCommand: async () => false, runCommand: async (cmd, args, options) => { commands.push([cmd, args, options?.allowFailure]); @@ -166,6 +181,7 @@ test('local Linux desktop provider falls back to pkill when wmctrl is unavailabl test('local Linux screenshot provider translates capture to available host tool', async () => { const commands: Array<[string, string[]]> = []; const provider = createLocalLinuxToolProvider({ + host: { env: { ...process.env, XDG_SESSION_TYPE: 'x11' }, cwd: process.cwd() }, whichCommand: async (cmd) => cmd === 'scrot', runCommand: async (cmd, args) => { commands.push([cmd, args]); diff --git a/packages/platform-linux/src/display.ts b/packages/platform-linux/src/display.ts new file mode 100644 index 0000000000..c1cf0cb8c9 --- /dev/null +++ b/packages/platform-linux/src/display.ts @@ -0,0 +1,26 @@ +import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; + +/** The Linux family's display-server vocabulary, from one detector (#3794). */ +export type DisplayServer = 'wayland' | 'x11'; + +/** + * The display decision bound to one host. A local provider builds it at construction from its + * runtime's host, so input, clipboard and screenshot selection share one answer that no other + * runtime's host can change (#3794). + */ +export type LinuxDisplayConfig = { + detectDisplayServer(): DisplayServer; +}; + +/** Reads the display server from one host's environment; the only display decision in the family. */ +export function detectLinuxDisplayServer(env: Readonly): DisplayServer { + if (env['WAYLAND_DISPLAY']) return 'wayland'; + if (env['XDG_SESSION_TYPE'] === 'wayland') return 'wayland'; + return 'x11'; +} + +export function createLinuxDisplayConfig(host: HostEnvironment): LinuxDisplayConfig { + return Object.freeze({ + detectDisplayServer: () => detectLinuxDisplayServer(host.env), + }); +} diff --git a/packages/platform-linux/src/input-tool.ts b/packages/platform-linux/src/input-tool.ts new file mode 100644 index 0000000000..3806ed2ca3 --- /dev/null +++ b/packages/platform-linux/src/input-tool.ts @@ -0,0 +1,6 @@ +import type { DisplayServer } from './display.ts'; + +export type InputTool = 'xdotool' | 'ydotool'; + +/** The input tool one provider resolved, tagged with the display decision it resolved for. */ +export type LinuxInputSelection = { tool: InputTool; display: DisplayServer }; diff --git a/packages/platform-linux/src/interactor-facade.ts b/packages/platform-linux/src/interactor-facade.ts index c873d59ef0..8bd90035b7 100644 --- a/packages/platform-linux/src/interactor-facade.ts +++ b/packages/platform-linux/src/interactor-facade.ts @@ -1,6 +1,7 @@ import type { Interactor } from '@agent-device/contracts/interactor-types'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; -export async function createLinuxInteractor(): Promise { +export async function createLinuxInteractor(host: HostEnvironment): Promise { const { createLinuxInteractor: create } = await import('./interactor.ts'); - return create(); + return create(host); } diff --git a/packages/platform-linux/src/interactor.test.ts b/packages/platform-linux/src/interactor.test.ts index db4b34d80e..ff77884224 100644 --- a/packages/platform-linux/src/interactor.test.ts +++ b/packages/platform-linux/src/interactor.test.ts @@ -29,7 +29,7 @@ vi.mock('./index.ts', () => ({ beforeEach(() => vi.clearAllMocks()); test('Linux owns alternate buttons while the runtime binder owns primary series', async () => { - const interactor = createLinuxInteractor(); + const interactor = createLinuxInteractor({ env: process.env, cwd: process.cwd() }); await interactor.alternateClick!({ x: 10, y: 20 }, 'secondary'); await interactor.alternateClick!({ x: 30, y: 40 }, 'middle'); @@ -46,7 +46,7 @@ test('Linux executes a planned fling as one drag with the resolved duration', as 'linux', ); - await createLinuxInteractor().performGesture!(plan); + await createLinuxInteractor({ env: process.env, cwd: process.cwd() }).performGesture!(plan); assert.deepEqual(vi.mocked(swipeLinux).mock.calls, [[10, 20, 110, 20, 100]]); }); diff --git a/packages/platform-linux/src/interactor.ts b/packages/platform-linux/src/interactor.ts index ef5790bd72..92fb1c4834 100644 --- a/packages/platform-linux/src/interactor.ts +++ b/packages/platform-linux/src/interactor.ts @@ -1,5 +1,12 @@ import { AppError } from '@agent-device/kernel/errors'; import { withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; +import { withMethodScope } from '@agent-device/kernel/scoped-provider'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import { + createLocalLinuxToolProvider, + hasScopedLinuxToolProvider, + withLinuxToolProvider, +} from './tool-provider.ts'; import { backLinux, captureLinuxSurfaceSnapshot, @@ -24,7 +31,18 @@ import { import { singlePointerPlanEndpoints } from '@agent-device/contracts/gesture-plan'; import type { Interactor } from '@agent-device/contracts/interactor-types'; -export function createLinuxInteractor(): Interactor { +export function createLinuxInteractor(host: HostEnvironment): Interactor { + // The interactor builds its own provider over the composing runtime's host: the display + // decision lives on that instance, never in module state, so another runtime's host cannot + // reach it (#3794). A request-scoped provider still wins: this one enters only when the + // request scopes none. + const provider = createLocalLinuxToolProvider({ host }); + return withMethodScope(createLinuxInteractorBody(), async (task) => + hasScopedLinuxToolProvider() ? await task() : await withLinuxToolProvider(provider, task), + ); +} + +function createLinuxInteractorBody(): Interactor { return { open: (app) => openLinuxApp(app), openDevice: () => Promise.resolve(), diff --git a/packages/platform-linux/src/linux-env.ts b/packages/platform-linux/src/linux-env.ts index 3a86d3df4c..b99c589ad3 100644 --- a/packages/platform-linux/src/linux-env.ts +++ b/packages/platform-linux/src/linux-env.ts @@ -1,65 +1,32 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; /** - * Shared Linux environment detection — display server and input tool. + * Shared Linux input-tool resolution — display server and input tool. * - * Results are cached after the first probe so that every action - * (press, type, scroll…) does not re-run `which` on every call. + * The display decision comes from the resolved provider's bound host, never an ambient read + * (#3794). Nothing is memoized: the interactor that enters a provider is built per request, so a + * cache would not outlive the request it serves. A refusal simply throws. */ import { AppError } from '@agent-device/kernel/errors'; import { resolveLinuxToolProvider, type LinuxToolProvider } from './tool-provider.ts'; +import type { DisplayServer } from './display.ts'; +import type { LinuxInputSelection } from './input-tool.ts'; -export type DisplayServer = 'wayland' | 'x11'; -export type InputTool = 'xdotool' | 'ydotool'; - -function detectDisplayServer(): DisplayServer { - const environment = commandHostEnvironment().env; - if (environment['WAYLAND_DISPLAY']) return 'wayland'; - if (environment['XDG_SESSION_TYPE'] === 'wayland') return 'wayland'; - return 'x11'; -} - -// ── Cached input tool resolution ─────────────────────────────────────── - -let cachedInputTool: { - tool: InputTool; - display: DisplayServer; - provider: LinuxToolProvider; -} | null = null; - -export async function ensureInputTool(): Promise<{ - tool: InputTool; - display: DisplayServer; -}> { - const provider = resolveLinuxToolProvider(); - if (cachedInputTool?.provider === provider) return cachedInputTool; - - const display = detectDisplayServer(); +export async function ensureInputTool( + provider: LinuxToolProvider = resolveLinuxToolProvider(), +): Promise { + const display: DisplayServer = provider.display.detectDisplayServer(); if (display === 'wayland') { - if (await provider.whichCommand('ydotool')) { - cachedInputTool = { tool: 'ydotool', display, provider }; - return cachedInputTool; - } + if (await provider.whichCommand('ydotool')) return { tool: 'ydotool', display }; throw new AppError( 'TOOL_MISSING', 'ydotool is required for input synthesis on Wayland (xdotool does not work on Wayland). Install it via your package manager.', ); } - if (await provider.whichCommand('xdotool')) { - cachedInputTool = { tool: 'xdotool', display, provider }; - return cachedInputTool; - } + if (await provider.whichCommand('xdotool')) return { tool: 'xdotool', display }; throw new AppError( 'TOOL_MISSING', 'xdotool is required for input synthesis on X11. Install it via your package manager.', ); } - -/** - * @internal Test isolation hook for the cached Linux input tool. - */ -export function resetInputToolCache(): void { - cachedInputTool = null; -} diff --git a/packages/platform-linux/src/tool-provider.ts b/packages/platform-linux/src/tool-provider.ts index 2ac2038408..645523a2dd 100644 --- a/packages/platform-linux/src/tool-provider.ts +++ b/packages/platform-linux/src/tool-provider.ts @@ -12,12 +12,18 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { sleep } from '@agent-device/host-kit/retry'; import { createScopedProvider } from '@agent-device/kernel/scoped-provider'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { LinuxAccessibilityTree, LinuxSnapshotSurface, LinuxTraversalOptions, } from './accessibility-types.ts'; +import { + createLinuxDisplayConfig, + detectLinuxDisplayServer, + type LinuxDisplayConfig, +} from './display.ts'; export type LinuxToolCommandExecutor = ( cmd: string, @@ -70,6 +76,8 @@ export type LinuxInputProvider = { }; export type LinuxToolProvider = { + /** The bound display decision every local tool selection in this provider shares (#3794). */ + display: LinuxDisplayConfig; runCommand: LinuxToolCommandExecutor; whichCommand: LinuxToolAvailabilityChecker; desktop: LinuxDesktopProvider; @@ -82,43 +90,69 @@ export type LinuxToolProvider = { const whichOnPath = async (cmd: string): Promise => await whichCmd(cmd, commandHostEnvironment().env); -const localLinuxToolProvider: LinuxToolProvider = { - runCommand: runCmd, - whichCommand: whichOnPath, - desktop: createLocalLinuxDesktopProvider(runCmd, whichOnPath), - clipboard: createLocalLinuxClipboardProvider(runCmd, whichOnPath), - screenshot: createLocalLinuxScreenshotProvider(runCmd, whichOnPath), -}; - -const linuxToolProviderScope = createScopedProvider( - localLinuxToolProvider, - createLocalLinuxToolProvider, -); +/** A provider under construction; only the display decision is required up front. */ +type LinuxToolProviderParts = { display: LinuxDisplayConfig } & Partial; -export function createLocalLinuxToolProvider( - provider: Partial = {}, -): LinuxToolProvider { - const merged = { - ...localLinuxToolProvider, - ...provider, - }; +/** Adds the local parts a partial provider omits; every part then belongs to this instance. */ +function completeLocalLinuxToolProvider(provider: LinuxToolProviderParts): LinuxToolProvider { + const runCommand = provider.runCommand ?? runCmd; + const whichCommand = provider.whichCommand ?? whichOnPath; + const display = provider.display; return { - ...merged, - desktop: - provider.desktop ?? createLocalLinuxDesktopProvider(merged.runCommand, merged.whichCommand), + runCommand, + whichCommand, + display, + desktop: provider.desktop ?? createLocalLinuxDesktopProvider(runCommand, whichCommand), clipboard: - provider.clipboard ?? - createLocalLinuxClipboardProvider(merged.runCommand, merged.whichCommand), + provider.clipboard ?? createLocalLinuxClipboardProvider(runCommand, whichCommand, display), screenshot: - provider.screenshot ?? - createLocalLinuxScreenshotProvider(merged.runCommand, merged.whichCommand), + provider.screenshot ?? createLocalLinuxScreenshotProvider(runCommand, whichCommand, display), + ...(provider.accessibility ? { accessibility: provider.accessibility } : {}), + ...(provider.input ? { input: provider.input } : {}), }; } +/** + * Builds a local provider that reads its configuration from `host`, never from the ambient one: + * one provider instance per composing runtime, owning its display decision (#3794). + */ +export function createLocalLinuxToolProvider( + options: { host: HostEnvironment } & Partial, +): LinuxToolProvider { + const { host, ...provider } = options; + return completeLocalLinuxToolProvider({ + ...provider, + display: provider.display ?? createLinuxDisplayConfig(host), + }); +} + +/** + * The fallback for callers outside every composed runtime (standalone leaf calls, scripts that + * name no host): it reads the display decision from the command host of the call, like the + * ambient read it replaces. A composed runtime binds its own host at `createLinuxInteractor`, so + * its display decision never comes from here (#3794). + */ +const localLinuxToolProvider = completeLocalLinuxToolProvider({ + runCommand: runCmd, + whichCommand: whichOnPath, + display: { + detectDisplayServer: () => detectLinuxDisplayServer(commandHostEnvironment().env), + }, +}); + +const linuxToolProviderScope = createScopedProvider( + localLinuxToolProvider, + completeLocalLinuxToolProvider, +); + export function resolveLinuxToolProvider(provider?: LinuxToolProvider): LinuxToolProvider { return linuxToolProviderScope.resolve(provider); } +export function hasScopedLinuxToolProvider(): boolean { + return linuxToolProviderScope.hasScope(); +} + export async function withLinuxToolProvider( provider: LinuxToolProvider | undefined, fn: () => Promise, @@ -173,15 +207,16 @@ function createLocalLinuxDesktopProvider( function createLocalLinuxClipboardProvider( runCommand: LinuxToolCommandExecutor, whichCommand: LinuxToolAvailabilityChecker, + display: LinuxDisplayConfig, ): LinuxClipboardProvider { return { async readText() { - const tool = await resolveLocalLinuxTool(whichCommand, LOCAL_CLIPBOARD_TOOLS); + const tool = await resolveLocalLinuxTool(whichCommand, display, LOCAL_CLIPBOARD_TOOLS); const command = LOCAL_CLIPBOARD_READ_COMMANDS[tool]; return (await runCommand(command.cmd, command.args, command.options)).stdout; }, async writeText(text) { - const tool = await resolveLocalLinuxTool(whichCommand, LOCAL_CLIPBOARD_TOOLS); + const tool = await resolveLocalLinuxTool(whichCommand, display, LOCAL_CLIPBOARD_TOOLS); const command = LOCAL_CLIPBOARD_WRITE_COMMANDS[tool](text); await runCommand(command.cmd, command.args, command.options); }, @@ -191,10 +226,11 @@ function createLocalLinuxClipboardProvider( function createLocalLinuxScreenshotProvider( runCommand: LinuxToolCommandExecutor, whichCommand: LinuxToolAvailabilityChecker, + display: LinuxDisplayConfig, ): LinuxScreenshotProvider { return { async capture(outPath) { - const tool = await resolveLocalLinuxTool(whichCommand, LOCAL_SCREENSHOT_TOOLS); + const tool = await resolveLocalLinuxTool(whichCommand, display, LOCAL_SCREENSHOT_TOOLS); const command = LOCAL_SCREENSHOT_COMMANDS[tool](outPath); await runCommand(command.cmd, command.args, command.options); }, @@ -300,16 +336,12 @@ const LOCAL_SCREENSHOT_COMMANDS: Record< async function resolveLocalLinuxTool( whichCommand: LinuxToolAvailabilityChecker, + display: LinuxDisplayConfig, config: LocalLinuxToolConfig, ): Promise { - const display = isLinuxWayland() ? 'wayland' : 'x11'; - for (const candidate of config[display]) { + const server = display.detectDisplayServer(); + for (const candidate of config[server]) { if (await whichCommand(candidate.command)) return candidate.tool; } - throw new AppError('TOOL_MISSING', display === 'wayland' ? config.waylandError : config.x11Error); -} - -function isLinuxWayland(): boolean { - const environment = commandHostEnvironment().env; - return Boolean(environment['WAYLAND_DISPLAY']) || environment['XDG_SESSION_TYPE'] === 'wayland'; + throw new AppError('TOOL_MISSING', server === 'wayland' ? config.waylandError : config.x11Error); } diff --git a/packages/platform-vega/src/__tests__/doctor.test.ts b/packages/platform-vega/src/__tests__/doctor.test.ts index e65d1ea3ff..a673dc2747 100644 --- a/packages/platform-vega/src/__tests__/doctor.test.ts +++ b/packages/platform-vega/src/__tests__/doctor.test.ts @@ -4,10 +4,14 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors'; import type { DoctorCheck } from '@agent-device/contracts/observability'; import type { HostDiagnosticsContext } from '@agent-device/contracts/host-diagnostics'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { VegaToolProvider } from '../tool-provider.ts'; import { withVegaToolProvider } from '../tool-provider.ts'; import { vegaToolchainCheck } from '../doctor.ts'; +// Doctor resolves against the host it is handed; a scoped test provider still wins. +const DOCTOR_HOST: HostEnvironment = { env: process.env, cwd: process.cwd() }; + function contextWith(local: () => Promise): HostDiagnosticsContext { return Object.freeze({ stateDir: '/tmp/state', @@ -35,7 +39,10 @@ function shouldPropagateInventoryProbeError(error: unknown): boolean { test('Vega doctor reports CLI version and connected-device readiness through semantic provider', async () => { const check = await withVegaToolProvider(makeVegaProvider(), async () => - vegaToolchainCheck(contextWith(async () => [VEGA_VVD])), + vegaToolchainCheck( + contextWith(async () => [VEGA_VVD]), + DOCTOR_HOST, + ), ); assert.deepEqual(check, { @@ -60,6 +67,7 @@ test('Vega doctor does not report an unvalidated physical TV as supported readin { listedSerials: ['G071R20720350DT6'] }, ); }), + DOCTOR_HOST, ), ); @@ -78,6 +86,7 @@ test('Vega doctor propagates canonical request cancellation', async () => { contextWith(async () => { throw new AppError('COMMAND_FAILED', 'request canceled'); }), + DOCTOR_HOST, ), ), (error: unknown) => error instanceof AppError && error.message === 'request canceled', @@ -93,6 +102,7 @@ test('Vega doctor propagates missing request inventory context', async () => { reason: 'device_inventory_context_unavailable', }); }), + DOCTOR_HOST, ), ), (error: unknown) => @@ -110,6 +120,7 @@ test('Vega doctor preserves the missing-tool diagnostic without probing inventor contextWith(async () => { throw new Error('inventory must not be probed'); }), + DOCTOR_HOST, ), ); @@ -132,6 +143,7 @@ test('Vega doctor keeps version failures diagnostic when neutral inventory fails contextWith(async () => { throw new Error('inventory failed'); }), + DOCTOR_HOST, ), ); diff --git a/packages/platform-vega/src/__tests__/tmp-dir.ts b/packages/platform-vega/src/__tests__/tmp-dir.ts new file mode 100644 index 0000000000..eba4b0c227 --- /dev/null +++ b/packages/platform-vega/src/__tests__/tmp-dir.ts @@ -0,0 +1,8 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// fallow-ignore-next-line code-duplication +export function mkdtempForTestSync(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} diff --git a/packages/platform-vega/src/__tests__/tool-provider-host-isolation.test.ts b/packages/platform-vega/src/__tests__/tool-provider-host-isolation.test.ts new file mode 100644 index 0000000000..0ba4af0221 --- /dev/null +++ b/packages/platform-vega/src/__tests__/tool-provider-host-isolation.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, rmSync, writeFileSync, chmodSync } from 'node:fs'; +import path from 'node:path'; +import { afterAll, beforeEach, test, vi } from 'vitest'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import { mkdtempForTestSync } from './tmp-dir.ts'; + +vi.mock('@agent-device/host-kit/command', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runCmd: vi.fn(), whichCmd: vi.fn() }; +}); + +import { runCmd, whichCmd } from '@agent-device/host-kit/command'; +import { createLocalVegaCliAdapter, createLocalVegaToolProvider } from '../tool-provider.ts'; + +const mockRunCmd = vi.mocked(runCmd); +const mockWhichCmd = vi.mocked(whichCmd); + +const scratch = mkdtempForTestSync('vega-host-isolation-'); +afterAll(() => { + rmSync(scratch, { recursive: true, force: true }); +}); + +/** A real executable file so the default-install probe answers on the filesystem, not on mocks. */ +function makeExecutable(filePath: string): void { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, '#!/bin/sh\nexit 0\n'); + chmodSync(filePath, 0o755); +} + +const hostAPathDir = path.join(scratch, 'host-a', 'bin'); +const hostBHome = path.join(scratch, 'host-b', 'home'); +makeExecutable(path.join(hostAPathDir, 'vega')); +makeExecutable(path.join(hostBHome, 'vega', 'bin', 'vega')); + +// Two independent hosts. Runtime A carries vega on its PATH; runtime B carries it only under +// its own HOME default install. Neither host sees the other's location, and the process PATH +// and HOME name a third location with no vega at all, so any answer read from the process +// instead of the runtime host is wrong here. +const hostA: HostEnvironment = { + env: { PATH: hostAPathDir, HOME: path.join(scratch, 'host-a', 'home') }, + cwd: scratch, +}; +const hostB: HostEnvironment = { + env: { PATH: path.join(scratch, 'empty-bin'), HOME: hostBHome }, + cwd: scratch, +}; + +beforeEach(() => { + mockRunCmd.mockReset(); + mockWhichCmd.mockReset(); + // PATH lookups answer from the environment passed in, like the real whichCmd: only host A's + // PATH carries vega. + mockWhichCmd.mockImplementation(async (cmd: string, env: NodeJS.ProcessEnv) => + Boolean(cmd === 'vega' && env?.PATH?.includes(hostAPathDir)), + ); + mockRunCmd.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); +}); + +test('two hosts sharing the process resolve the vega executable from their own host', async () => { + const providerA = createLocalVegaToolProvider(createLocalVegaCliAdapter(hostA)); + const providerB = createLocalVegaToolProvider(createLocalVegaCliAdapter(hostB)); + + assert.equal(await providerA.isAvailable(), true); + assert.equal(await providerB.isAvailable(), true); + + // Resolve B first through a run, then A: each must invoke the executable named by its own + // host — A the PATH entry, B the HOME default-install path. Under a shared module cache the + // second resolution would reuse the first host's answer. + await providerB.version(); + await providerA.version(); + + const executables = mockRunCmd.mock.calls.map(([executable]) => executable); + assert.deepEqual(executables, [path.join(hostBHome, 'vega', 'bin', 'vega'), 'vega']); + + // The memo lives on each adapter instance: a later call never re-probes and never reads the + // other host's answer. + mockWhichCmd.mockReset(); + await providerA.version(); + await providerB.version(); + assert.equal(mockWhichCmd.mock.calls.length, 0); + assert.deepEqual( + mockRunCmd.mock.calls.slice(2).map(([executable]) => executable), + ['vega', path.join(hostBHome, 'vega', 'bin', 'vega')], + ); +}); + +test('a host without the vega executable refuses, and the refusal is not memoized', async () => { + // Host C names no home at all; host D names a HOME with no default install. Neither PATH + // carries vega, so both report unavailable — and each later call re-probes, so a tool + // installed after the miss is still found. + const hostC: HostEnvironment = { env: { PATH: path.join(scratch, 'empty-bin') }, cwd: scratch }; + const hostD: HostEnvironment = { + env: { PATH: path.join(scratch, 'empty-bin'), HOME: path.join(scratch, 'host-d', 'home') }, + cwd: scratch, + }; + const providerC = createLocalVegaToolProvider(createLocalVegaCliAdapter(hostC)); + const providerD = createLocalVegaToolProvider(createLocalVegaCliAdapter(hostD)); + + assert.equal(await providerC.isAvailable(), false); + assert.equal(await providerD.isAvailable(), false); + assert.equal(await providerD.isAvailable(), false); + // One PATH probe per call: the second miss on D re-probed instead of trusting a memo. + assert.equal(mockWhichCmd.mock.calls.length, 3); +}); diff --git a/packages/platform-vega/src/doctor.ts b/packages/platform-vega/src/doctor.ts index b567587576..2823cfdc0e 100644 --- a/packages/platform-vega/src/doctor.ts +++ b/packages/platform-vega/src/doctor.ts @@ -2,16 +2,30 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import type { DoctorCheck } from '@agent-device/contracts/observability'; import type { HostDiagnosticsContext } from '@agent-device/contracts/host-diagnostics'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { firstOutputLine, TOOLCHAIN_TIMEOUT_MS } from '@agent-device/provision-kit/toolchain-probe'; -import { resolveVegaToolProvider } from './tool-provider.ts'; +import { + createLocalVegaCliAdapter, + createLocalVegaToolProvider, + hasScopedVegaToolProvider, + resolveVegaToolProvider, +} from './tool-provider.ts'; type VegaInventoryProbe = Readonly<{ devices: readonly DeviceInfo[]; listedSerials: readonly string[]; }>; -export async function vegaToolchainCheck(context: HostDiagnosticsContext): Promise { - const provider = resolveVegaToolProvider(); +export async function vegaToolchainCheck( + context: HostDiagnosticsContext, + host: HostEnvironment, +): Promise { + // Doctor answers for the host `createHostDiagnostics` was composed with: it builds the same + // host-bound adapter the interactor route binds, so CLI resolution agrees with every other + // lookup of this runtime (#3794). A test-scoped provider still wins. + const provider = hasScopedVegaToolProvider() + ? resolveVegaToolProvider() + : createLocalVegaToolProvider(createLocalVegaCliAdapter(host)); if (!(await provider.isAvailable())) { return { id: 'toolchain', diff --git a/packages/platform-vega/src/index.test.ts b/packages/platform-vega/src/index.test.ts index 33cf97b9f0..a4743a8c72 100644 --- a/packages/platform-vega/src/index.test.ts +++ b/packages/platform-vega/src/index.test.ts @@ -1,6 +1,17 @@ import assert from 'node:assert/strict'; -import { test } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync, chmodSync } from 'node:fs'; +import path from 'node:path'; +import { afterAll, beforeEach, test, vi } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import { mkdtempForTestSync } from './__tests__/tmp-dir.ts'; + +vi.mock('@agent-device/host-kit/command', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runCmd: vi.fn(), whichCmd: vi.fn() }; +}); + +import { runCmd, whichCmd, withCommandHost } from '@agent-device/host-kit/command'; import { vegaPlugin } from './index.ts'; const VEGA_VVD = { @@ -12,14 +23,97 @@ const VEGA_VVD = { booted: true, } satisfies DeviceInfo; +const mockRunCmd = vi.mocked(runCmd); +const mockWhichCmd = vi.mocked(whichCmd); + +const scratch = mkdtempForTestSync('vega-plugin-host-binding-'); +const hostAPathDir = path.join(scratch, 'host-a', 'bin'); +const hostBHome = path.join(scratch, 'host-b', 'home'); +const processHome = path.join(scratch, 'process-home'); +for (const dir of [hostAPathDir, path.join(hostBHome, 'vega', 'bin'), processHome]) { + mkdirSync(dir, { recursive: true }); +} +for (const file of [path.join(hostAPathDir, 'vega'), path.join(hostBHome, 'vega', 'bin', 'vega')]) { + writeFileSync(file, '#!/bin/sh\nexit 0\n'); + chmodSync(file, 0o755); +} + +const originalEnv = { ...process.env }; +// The bound test command host — and with it the process environment — names a third location +// where no vega exists, so an answer read from the process instead of the per-call host is +// wrong here. +process.env['PATH'] = path.join(scratch, 'process-bin'); +process.env['HOME'] = processHome; + +const hostA: HostEnvironment = { + env: { PATH: hostAPathDir, HOME: processHome }, + cwd: scratch, +}; +const hostB: HostEnvironment = { + env: { PATH: path.join(scratch, 'empty-bin'), HOME: hostBHome }, + cwd: scratch, +}; + +afterAll(() => { + rmSync(scratch, { recursive: true, force: true }); + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); +}); + +function withProcessCommandHost(run: () => Promise): Promise { + // The process command host names a third location; the per-call hosts below must win (#3794). + return withCommandHost( + { host: { env: process.env, cwd: process.cwd() }, requestEnv: undefined }, + run, + ); +} + +beforeEach(() => { + mockRunCmd.mockReset(); + mockWhichCmd.mockReset(); + mockWhichCmd.mockImplementation(async (cmd: string, env: NodeJS.ProcessEnv) => + Boolean(cmd === 'vega' && env?.PATH?.includes(hostAPathDir)), + ); + mockRunCmd.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); +}); + +function runExecutables(): string[] { + return mockRunCmd.mock.calls.map(([executable]) => executable); +} + test('Vega plugin owns the Vega platform', () => { assert.deepEqual(vegaPlugin.platforms, ['vega']); }); test('Vega plugin creates the Vega interactor lazily', async () => { - const interactor = await vegaPlugin.createInteractor(VEGA_VVD, {}); + const interactor = await vegaPlugin.createInteractor( + VEGA_VVD, + {}, + { + env: process.env, + cwd: process.cwd(), + }, + ); assert.equal(typeof interactor.tvRemote, 'function'); assert.equal(typeof interactor.back, 'function'); assert.equal(typeof interactor.home, 'function'); }); + +test('createInteractor binds each caller host into the interactor it returns', async () => { + // Removing the host-binding wrap in index.ts makes both calls fall through to the unbound + // scope default and refuse with `vega-adapter-host-unbound` (#3794). + await withProcessCommandHost(async () => { + const interactorA = await vegaPlugin.createInteractor(VEGA_VVD, {}, hostA); + const interactorB = await vegaPlugin.createInteractor(VEGA_VVD, {}, hostB); + + await interactorA.back(); + assert.deepEqual(runExecutables(), ['vega']); + + mockRunCmd.mockClear(); + await interactorB.back(); + assert.deepEqual(runExecutables(), [path.join(hostBHome, 'vega', 'bin', 'vega')]); + }); +}); diff --git a/packages/platform-vega/src/index.ts b/packages/platform-vega/src/index.ts index c777623e6a..72f331e542 100644 --- a/packages/platform-vega/src/index.ts +++ b/packages/platform-vega/src/index.ts @@ -6,6 +6,7 @@ import type { PlatformRuntimeModule } from '@agent-device/contracts/platform-run import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; import type { PlatformPlugin } from '@agent-device/contracts/platform-plugin'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; // fallow-ignore-next-line code-duplication type DeferredFunction = (...args: never[]) => unknown; @@ -49,9 +50,28 @@ export const vegaPlugin = { createInteractor: async ( device: DeviceInfo, runnerContext: RunnerContext, + host: HostEnvironment, ): Promise => { const { createVegaInteractor } = await import('./interactor.ts'); - return createVegaInteractor(device, runnerContext); + // The interactor binds the CLI adapter to the composing runtime's host: executable + // resolution reads that host's PATH and HOME and memoizes on this instance (#3794). A + // request-scoped provider still wins: this one enters only when the request scopes none. + // The facade stays eager-free, so the scope helper arrives with its module, not above it. + const [{ withMethodScope }, toolProvider] = await Promise.all([ + import('@agent-device/kernel/scoped-provider'), + import('./tool-provider.ts'), + ]); + const { + createLocalVegaCliAdapter, + createLocalVegaToolProvider, + hasScopedVegaToolProvider, + withVegaToolProvider, + } = toolProvider; + const provider = createLocalVegaToolProvider(createLocalVegaCliAdapter(host)); + const interactor = createVegaInteractor(device, runnerContext); + return withMethodScope(interactor, async (task) => + hasScopedVegaToolProvider() ? await task() : await withVegaToolProvider(provider, task), + ); }, } as const satisfies PlatformPlugin; diff --git a/packages/platform-vega/src/inventory.ts b/packages/platform-vega/src/inventory.ts index de45201746..d1ae8f40d7 100644 --- a/packages/platform-vega/src/inventory.ts +++ b/packages/platform-vega/src/inventory.ts @@ -1,6 +1,6 @@ -import path from 'node:path'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { resolveVegaExecutable } from './vega-executable.ts'; import type { DeviceInventoryHostFor, HostCommandResult, @@ -35,7 +35,7 @@ async function discoverVegaDevices( host: DeviceInventoryHostFor<'vega'>, scope: PlatformRequestScope, ): Promise { - const executable = await resolveVegaExecutable(host); + const executable = await resolveVegaExecutableForInventory(host); if (!executable) { throw new AppError('TOOL_MISSING', 'Vega CLI not found in PATH', { hint: 'Install Vega Developer Tools, source ~/vega/env, and retry devices.', @@ -66,13 +66,16 @@ async function discoverVegaDevices( return devices; } -async function resolveVegaExecutable( +async function resolveVegaExecutableForInventory( host: DeviceInventoryHostFor<'vega'>, ): Promise { - const resolved = await host.commands.which('vega'); - if (resolved) return resolved; - const defaultInstall = path.join(host.homeDirectory, 'vega', 'bin', 'vega'); - return (await host.files.isExecutable(defaultInstall)) ? defaultInstall : undefined; + // The one Vega lookup shared with the CLI adapter and doctor: PATH, then the runtime host's + // home default install (#3794). + return await resolveVegaExecutable({ + which: (executable) => host.commands.which(executable), + isExecutableFile: (candidate) => host.files.isExecutable(candidate), + homeDirectory: host.homeDirectory, + }); } function assertCommandSuccess(result: HostCommandResult): void { diff --git a/packages/platform-vega/src/tool-provider.ts b/packages/platform-vega/src/tool-provider.ts index 9ee64547c3..5e63e7224e 100644 --- a/packages/platform-vega/src/tool-provider.ts +++ b/packages/platform-vega/src/tool-provider.ts @@ -1,15 +1,9 @@ -import path from 'node:path'; import type { VegaTvRemoteKey } from '@agent-device/contracts/tv-remote'; -import { - isExecutablePath, - runCmd, - whichCmd, - type ExecOptions, - type ExecResult, - commandHostEnvironment, -} from '@agent-device/host-kit/command'; -import { hostHomeDirectory } from '@agent-device/host-kit/host-file'; +import { AppError } from '@agent-device/kernel/errors'; +import { runCmd, type ExecOptions, type ExecResult } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { createScopedProvider } from '@agent-device/kernel/scoped-provider'; +import { createVegaExecutableHostFromEnv, resolveVegaExecutable } from './vega-executable.ts'; export type VegaToolProvider = { isAvailable(): Promise; @@ -31,18 +25,47 @@ export type VegaCliAdapter = { isAvailable(): Promise; }; -const localVegaCliAdapter: VegaCliAdapter = { - run: async (args, options) => - await runCmd((await resolveVegaCliExecutable()) ?? 'vega', args, options), - isAvailable: async () => Boolean(await resolveVegaCliExecutable()), -}; +/** + * Builds the local CLI adapter for one host. It resolves the vega executable through the one + * resolver (#3794), deriving PATH and home from that host's environment, and memoizes the answer + * on this adapter instance, so a second runtime with a different host never runs the first + * runtime's binary. A refusal is not memoized; only a found executable is, so a later install + * still resolves. + */ +export function createLocalVegaCliAdapter(host: HostEnvironment): VegaCliAdapter { + const executableHost = createVegaExecutableHostFromEnv(host.env); + let resolvedExecutable: string | undefined; + const resolveCliExecutable = async (): Promise => { + if (resolvedExecutable) return resolvedExecutable; + resolvedExecutable = await resolveVegaExecutable(executableHost); + return resolvedExecutable; + }; + return { + run: async (args, options) => + await runCmd((await resolveCliExecutable()) ?? 'vega', args, options), + isAvailable: async () => Boolean(await resolveCliExecutable()), + }; +} -const localVegaToolProvider = createLocalVegaToolProvider(); +/** + * The scope default for callers that name no host. Every composed route (the plugin interactor + * wrapper, doctor's host-bound adapter) enters its own host-bound provider before a command + * runs, so reaching this default means the caller forgot to bind one — refuse instead of reading + * an ambient environment (#3794). + */ +function unboundVegaCliAdapter(): VegaCliAdapter { + const refuse = async (): Promise => { + throw new AppError('COMMAND_FAILED', 'The Vega CLI adapter is not bound to a host.', { + reason: 'vega-adapter-host-unbound', + }); + }; + return { run: refuse, isAvailable: refuse }; +} + +const localVegaToolProvider = createLocalVegaToolProvider(unboundVegaCliAdapter()); const vegaToolProviderScope = createScopedProvider(localVegaToolProvider); -export function createLocalVegaToolProvider( - adapter: VegaCliAdapter = localVegaCliAdapter, -): VegaToolProvider { +export function createLocalVegaToolProvider(adapter: VegaCliAdapter): VegaToolProvider { return { isAvailable: async () => await adapter.isAvailable(), version: async (options) => await adapter.run(['--version'], options), @@ -83,24 +106,13 @@ export function resolveVegaToolProvider(provider?: VegaToolProvider): VegaToolPr return vegaToolProviderScope.resolve(provider); } +export function hasScopedVegaToolProvider(): boolean { + return vegaToolProviderScope.hasScope(); +} + export async function withVegaToolProvider( provider: VegaToolProvider | undefined, fn: () => Promise, ): Promise { return await vegaToolProviderScope.run(provider, fn); } - -let cachedVegaCliExecutable: string | undefined; - -async function resolveVegaCliExecutable(): Promise { - if (cachedVegaCliExecutable) return cachedVegaCliExecutable; - if (await whichCmd('vega', commandHostEnvironment().env)) { - cachedVegaCliExecutable = 'vega'; - return cachedVegaCliExecutable; - } - const defaultInstall = path.join(hostHomeDirectory(), 'vega', 'bin', 'vega'); - if (await isExecutablePath(defaultInstall)) { - cachedVegaCliExecutable = defaultInstall; - } - return cachedVegaCliExecutable; -} diff --git a/packages/platform-vega/src/vega-executable.ts b/packages/platform-vega/src/vega-executable.ts new file mode 100644 index 0000000000..64cd9db278 --- /dev/null +++ b/packages/platform-vega/src/vega-executable.ts @@ -0,0 +1,45 @@ +import path from 'node:path'; +import { isExecutablePath, whichCmd } from '@agent-device/host-kit/command'; +import { hostHomeDirectoryFromEnv } from '@agent-device/host-kit/host-file'; +import type { EnvMap } from '@agent-device/kernel/source-value'; + +/** + * The host ports one Vega executable lookup needs — the same three the device-inventory host + * exposes (`commands.which`, `files.isExecutable`, `homeDirectory`) — so inventory, doctor and + * the CLI adapter all resolve through one resolver over their runtime host (#3794). A host that + * names no home omits `homeDirectory`; then the default-install probe is skipped, never guessed + * from the process (#3753). + */ +export type VegaExecutableHost = Readonly<{ + which: (executable: string) => Promise; + isExecutableFile: (candidate: string) => Promise; + homeDirectory: string | undefined; +}>; + +/** One lookup: PATH first, then the host home's default install, else nothing. */ +export function resolveVegaExecutable(host: VegaExecutableHost): Promise { + return lookupVegaExecutable(host); +} + +async function lookupVegaExecutable(host: VegaExecutableHost): Promise { + if (await host.which('vega')) return 'vega'; + if (host.homeDirectory === undefined) return undefined; + const defaultInstall = path.join(host.homeDirectory, 'vega', 'bin', 'vega'); + return (await host.isExecutableFile(defaultInstall)) ? defaultInstall : undefined; +} + +/** + * Derives the resolver's ports from a runtime host environment: PATH through the host env, the + * home through the host env's own HOME/USERPROFILE, never the process home. A host env that names + * no home yields no `homeDirectory`, and the resolver then skips the default-install probe. + * `createDeviceInventoryHost` derives its `homeDirectory` with the same helper, so the inventory + * route and this one agree. + */ +export function createVegaExecutableHostFromEnv(env: EnvMap): VegaExecutableHost { + return Object.freeze({ + which: async (executable: string) => + (await whichCmd(executable, env)) ? executable : undefined, + isExecutableFile: async (candidate: string) => await isExecutablePath(candidate), + homeDirectory: hostHomeDirectoryFromEnv(env), + }); +} diff --git a/packages/platform-web/src/__tests__/agent-browser-provider-boundary-faults.test.ts b/packages/platform-web/src/__tests__/agent-browser-provider-boundary-faults.test.ts index 6221138b17..8c92c8eb71 100644 --- a/packages/platform-web/src/__tests__/agent-browser-provider-boundary-faults.test.ts +++ b/packages/platform-web/src/__tests__/agent-browser-provider-boundary-faults.test.ts @@ -29,7 +29,11 @@ for (const [commandClass, runOperation] of Object.entries(COMMAND_CLASS_OPERATIO const stateDir = mkdtempForTestSync(`agent-device-exec-boundary-${commandClass}-`); try { installFakeManagedAgentBrowser(stateDir); - const provider = createAgentBrowserWebProvider({ stateDir, session: 'boundary' }); + const provider = createAgentBrowserWebProvider({ + host: { env: process.env, cwd: process.cwd() }, + stateDir, + session: 'boundary', + }); const failure = new AppError('COMMAND_FAILED', 'injected command failure', { hint: 'retry the boundary test', diagnosticId: `diag-${commandClass}`, diff --git a/packages/platform-web/src/__tests__/agent-browser-provider-dispatch-disclosure.test.ts b/packages/platform-web/src/__tests__/agent-browser-provider-dispatch-disclosure.test.ts index ac379a5d9e..fba363b1ab 100644 --- a/packages/platform-web/src/__tests__/agent-browser-provider-dispatch-disclosure.test.ts +++ b/packages/platform-web/src/__tests__/agent-browser-provider-dispatch-disclosure.test.ts @@ -134,6 +134,7 @@ async function scrollRefusedOnThirdStep(): Promise { /** The managed backend is not installed, so the CLI is never spawned. */ async function openWithMissingBackend(): Promise { const provider = createAgentBrowserWebProvider({ + host: { env: process.env, cwd: process.cwd() }, stateDir: mkdtempForTestSync('agent-device-web-disclosure-missing-'), }); await provider.open('https://example.test'); diff --git a/packages/platform-web/src/__tests__/agent-browser-provider-record-retention.test.ts b/packages/platform-web/src/__tests__/agent-browser-provider-record-retention.test.ts index 90004955cd..88470c87d7 100644 --- a/packages/platform-web/src/__tests__/agent-browser-provider-record-retention.test.ts +++ b/packages/platform-web/src/__tests__/agent-browser-provider-record-retention.test.ts @@ -151,6 +151,7 @@ async function withFleetRecordStore( store.replace({ kind: 'daemon' }, [seed]); await runScenario({ provider: createAgentBrowserWebProvider({ + host: { env: process.env, cwd: process.cwd() }, session: 'web-session', stateDir, openWebSessionNames: () => ['web-session'], diff --git a/packages/platform-web/src/__tests__/doctor.test.ts b/packages/platform-web/src/__tests__/doctor.test.ts index d50ae85ac1..ac1d5f1be2 100644 --- a/packages/platform-web/src/__tests__/doctor.test.ts +++ b/packages/platform-web/src/__tests__/doctor.test.ts @@ -42,7 +42,10 @@ test('web doctor lifecycle check reports live managed Chrome process count', asy ], } satisfies AgentBrowserProcessSummary); - const check = await webBrowserLifecycleCheck(stateDir); + const check = await webBrowserLifecycleCheck(stateDir, { + env: process.env, + cwd: process.cwd(), + }); assert.equal(check.id, 'web-agent-browser-processes'); assert.equal(check.status, 'info'); @@ -57,7 +60,10 @@ test('web doctor lifecycle check reports live managed Chrome process count', asy test('web doctor lifecycle check stays informational when managed backend is missing', async () => { const stateDir = mkdtempForTestSync('agent-device-web-doctor-missing-'); try { - const check = await webBrowserLifecycleCheck(stateDir); + const check = await webBrowserLifecycleCheck(stateDir, { + env: process.env, + cwd: process.cwd(), + }); assert.equal(check.status, 'info'); assert.match(check.summary ?? '', /not installed/); diff --git a/packages/platform-web/src/__tests__/provider-composition-fallback.test.ts b/packages/platform-web/src/__tests__/provider-composition-fallback.test.ts new file mode 100644 index 0000000000..fbccb3ead3 --- /dev/null +++ b/packages/platform-web/src/__tests__/provider-composition-fallback.test.ts @@ -0,0 +1,51 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { AppError } from '@agent-device/kernel/errors'; +import { resolveWebProvider, withWebProvider, type WebProvider } from '../provider.ts'; + +// The scope's un-composed fallback is the single policy for a request that reaches a web device +// without a composed provider: it refuses with `web-provider-missing` instead of borrowing a host +// it was never given (#3794). +test('an un-scoped web provider resolves to a refusal naming the missing composition', async () => { + const provider = resolveWebProvider(); + const operations: readonly [string, () => Promise][] = [ + ['open', () => provider.open('https://example.test')], + ['close', () => provider.close()], + ['snapshot', () => provider.snapshot()], + ['screenshot', () => provider.screenshot('/tmp/out.png')], + ['setViewport', () => provider.setViewport(800, 600)], + ['click', () => provider.click(1, 2)], + ['fill', () => provider.fill(1, 2, 'text')], + ['typeText', () => provider.typeText('text')], + ['scroll', () => provider.scroll('down')], + ]; + for (const [operation, call] of operations) { + const error = await call().then( + () => undefined, + (error: unknown) => error, + ); + assert.ok(error instanceof AppError, `${operation} refuses with an AppError`); + assert.equal(error.details?.reason, 'web-provider-missing', operation); + } +}); + +test('a composed request provider answers instead of the fallback refusal', async () => { + const opened: string[] = []; + const provider: WebProvider = { + open: async (target) => { + opened.push(target); + }, + close: async () => {}, + snapshot: async () => ({ nodes: [] }), + screenshot: async () => {}, + setViewport: async () => {}, + click: async () => {}, + fill: async () => {}, + typeText: async () => {}, + scroll: async () => {}, + }; + await withWebProvider(provider, async () => { + await resolveWebProvider().open('https://example.test'); + }); + assert.deepEqual(opened, ['https://example.test']); +}); diff --git a/packages/platform-web/src/__tests__/test-utils.ts b/packages/platform-web/src/__tests__/test-utils.ts index e2d2d89c2e..9367b8923c 100644 --- a/packages/platform-web/src/__tests__/test-utils.ts +++ b/packages/platform-web/src/__tests__/test-utils.ts @@ -68,7 +68,13 @@ export async function withManagedAgentBrowserProvider( ): Promise { const stateDir = mkdtempForTestSync('agent device web provider '); installFakeManagedAgentBrowser(stateDir); - await testFn(createAgentBrowserWebProvider({ ...options, stateDir })); + await testFn( + createAgentBrowserWebProvider({ + host: { env: process.env, cwd: process.cwd() }, + ...options, + stateDir, + }), + ); } /** The answer shape the managed backend answers every command with. */ diff --git a/packages/platform-web/src/agent-browser-install.ts b/packages/platform-web/src/agent-browser-install.ts index 056b5d1665..dd5d443a39 100644 --- a/packages/platform-web/src/agent-browser-install.ts +++ b/packages/platform-web/src/agent-browser-install.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; -import { runCmd, commandHostEnvironment } from '@agent-device/host-kit/command'; +import { runCmd } from '@agent-device/host-kit/command'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { ensureHostDirectorySync, hostFileStatSync, @@ -20,11 +21,12 @@ export async function installManagedAgentBrowserPackage(params: { packageRoot: string; packageSpec: string; timeoutMs: number; + env: Readonly; }): Promise { ensureHostDirectorySync(params.packageRoot); // `--no-global` keeps an ambient `npm_config_global` from redirecting the // install out of the managed prefix, where the backend entry would be missed. - const npm = npmCommand([ + const npm = npmCommand(params.env, [ 'install', '--prefix', params.packageRoot, @@ -35,7 +37,7 @@ export async function installManagedAgentBrowserPackage(params: { params.packageSpec, ]); await runCmd(npm.command, npm.args, { - env: commandHostEnvironment().env, + env: params.env, timeoutMs: params.timeoutMs, }); } @@ -66,9 +68,9 @@ export function writeManagedAgentBrowserManifest(params: { * without a shell since the CVE-2024-27980 fix, so its JS entry runs under the * current Node runtime instead (#2022). */ -function npmCommand(args: string[]): { command: string; args: string[] } { +function npmCommand(env: Readonly, args: string[]): { command: string; args: string[] } { if (hostPlatform() !== 'win32') return { command: 'npm', args }; - const npmCliScript = resolveWindowsNpmCliScript(commandHostEnvironment().env); + const npmCliScript = resolveWindowsNpmCliScript(env); if (!npmCliScript) { throw new AppError('TOOL_MISSING', 'npm not found in PATH', { nodeExecPath: hostNodeExecutablePath(), diff --git a/packages/platform-web/src/agent-browser-lifecycle.test.ts b/packages/platform-web/src/agent-browser-lifecycle.test.ts index ea74bc7d1f..4a82c7f2c4 100644 --- a/packages/platform-web/src/agent-browser-lifecycle.test.ts +++ b/packages/platform-web/src/agent-browser-lifecycle.test.ts @@ -55,7 +55,10 @@ test('managed Chrome launch args include a stable agent-device ownership marker const stateDir = mkdtempForTestSync('agent-device-web-life-'); try { installFakeManagedAgentBrowser(stateDir); - const status = getManagedAgentBrowserStatus({ stateDir }); + const status = getManagedAgentBrowserStatus({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); const marker = agentBrowserChromeLaunchMarker(status); assert.match(marker, /^--agent-device-managed-web=[a-f0-9]{16}$/); @@ -100,7 +103,10 @@ test('ownership detection accepts only Chrome-like processes with managed marker const stateDir = mkdtempForTestSync('agent-device-web-life-'); try { installFakeManagedAgentBrowser(stateDir); - const status = getManagedAgentBrowserStatus({ stateDir }); + const status = getManagedAgentBrowserStatus({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); const marker = agentBrowserChromeLaunchMarker(status); const managedChrome = path.join( status.homeDir, @@ -146,8 +152,12 @@ test('cleanup skips reaping when the daemon reports open web sessions', async () const stateDir = mkdtempForTestSync('agent-device-web-life-'); try { installFakeManagedAgentBrowser(stateDir); - const status = getManagedAgentBrowserStatus({ stateDir }); + const status = getManagedAgentBrowserStatus({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); const result = await cleanupManagedAgentBrowserOrphans(status, 'daemon-startup', { + env: process.env, openWebSessionNames: ['default-web'], }); @@ -165,11 +175,16 @@ test('cleanup skips reaping when managed browser socket activity is recent', asy process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS = '60000'; try { installFakeManagedAgentBrowser(stateDir); - const status = getManagedAgentBrowserStatus({ stateDir }); + const status = getManagedAgentBrowserStatus({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); fs.mkdirSync(status.socketDir, { recursive: true }); fs.writeFileSync(path.join(status.socketDir, 'agent-browser.sock'), ''); - const result = await cleanupManagedAgentBrowserOrphans(status, 'daemon-startup'); + const result = await cleanupManagedAgentBrowserOrphans(status, 'daemon-startup', { + env: process.env, + }); assert.equal(result.skipped?.reason, 'recent-browser-activity'); assert.equal(mockRunCmd.mock.calls.length, 0); @@ -189,11 +204,16 @@ test('cleanup does not treat the shared socket directory mtime as browser activi process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS = '60000'; try { installFakeManagedAgentBrowser(stateDir); - const status = getManagedAgentBrowserStatus({ stateDir }); + const status = getManagedAgentBrowserStatus({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); fs.mkdirSync(status.socketDir, { recursive: true }); mockRunCmd.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); - const result = await cleanupManagedAgentBrowserOrphans(status, 'daemon-startup'); + const result = await cleanupManagedAgentBrowserOrphans(status, 'daemon-startup', { + env: process.env, + }); assert.equal(result.skipped, undefined); assert.equal(mockRunCmd.mock.calls.length, 0); @@ -243,8 +263,12 @@ test('cleanup reads recorded browser identities without reconstructing a process try { installFakeManagedAgentBrowser(stateDir); - const status = getManagedAgentBrowserStatus({ stateDir }); + const status = getManagedAgentBrowserStatus({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); const result = await cleanupManagedAgentBrowserOrphans(status, 'provider-startup', { + env: process.env, ownedProcessRecords, }); @@ -266,7 +290,10 @@ test('process summary reports only conservatively owned managed Chrome processes const stateDir = mkdtempForTestSync('agent-device-web-life-'); try { installFakeManagedAgentBrowser(stateDir); - const status = getManagedAgentBrowserStatus({ stateDir }); + const status = getManagedAgentBrowserStatus({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); const marker = agentBrowserChromeLaunchMarker(status); const summary = summarizeAgentBrowserProcesses( [ @@ -292,7 +319,10 @@ test('records the managed browser daemon and Chrome fleet at the spawn owner', a const stateDir = mkdtempForTestSync('agent-device-web-life-'); try { installFakeManagedAgentBrowser(stateDir); - const status = getManagedAgentBrowserStatus({ stateDir }); + const status = getManagedAgentBrowserStatus({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); const marker = agentBrowserChromeLaunchMarker(status); const store = { replace: vi.fn(), clear: vi.fn(), read: vi.fn(() => []) }; mockRunCmd.mockResolvedValue({ @@ -331,7 +361,10 @@ test('recording the fleet owns the recordable agent-browser daemon', async () => const stateDir = mkdtempForTestSync('agent-device-web-life-'); try { installFakeManagedAgentBrowser(stateDir); - const status = getManagedAgentBrowserStatus({ stateDir }); + const status = getManagedAgentBrowserStatus({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); const command = `${process.execPath} ${status.entryScript}`; const store = { replace: vi.fn(), clear: vi.fn(), read: vi.fn(() => []) }; mockProcessList([` 101 1 ${command}`]); @@ -370,7 +403,10 @@ test('a fleet left running by a previous version stays owned after the pin moves const stateDir = mkdtempForTestSync('agent-device-web-life-'); try { installFakeManagedAgentBrowser(stateDir); - const status = getManagedAgentBrowserStatus({ stateDir }); + const status = getManagedAgentBrowserStatus({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); const { previous, daemonCommand, markedChromeCommand, homeChromeCommand } = installPreviousVersionFleet(stateDir); assert.notEqual(previous.installDir, status.installDir); @@ -408,7 +444,10 @@ test('a previous version fleet from another state dir is not claimed', async () const otherStateDir = mkdtempForTestSync('agent-device-web-life-other-'); try { installFakeManagedAgentBrowser(stateDir); - const status = getManagedAgentBrowserStatus({ stateDir }); + const status = getManagedAgentBrowserStatus({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); const { daemonCommand, markedChromeCommand } = installPreviousVersionFleet(otherStateDir); const store = { replace: vi.fn(), clear: vi.fn(), read: vi.fn(() => []) }; mockProcessList([` 101 1 ${daemonCommand}`, ` 201 1 ${markedChromeCommand}`]); diff --git a/packages/platform-web/src/agent-browser-lifecycle.ts b/packages/platform-web/src/agent-browser-lifecycle.ts index 20d1ca450e..ad32d8135f 100644 --- a/packages/platform-web/src/agent-browser-lifecycle.ts +++ b/packages/platform-web/src/agent-browser-lifecycle.ts @@ -1,5 +1,5 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import path from 'node:path'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { uniqueStrings } from '@agent-device/kernel/collections'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { hostFileStatSync, readHostDirectorySync } from '@agent-device/host-kit/host-file'; @@ -37,6 +37,8 @@ export type AgentBrowserCleanupResult = AgentBrowserProcessSummary & { }; export type AgentBrowserCleanupOptions = { + /** The caller's host: its environment carries the idle-timeout overrides. */ + env: Readonly; openWebSessionNames?: readonly string[]; ownedProcessRecords?: OwnedProcessRecordStore; }; @@ -54,7 +56,7 @@ export function resolveAgentBrowserIdleTimeoutMs(env: NodeJS.ProcessEnv): number export async function cleanupManagedAgentBrowserOrphans( status: AgentBrowserToolStatus, reason: 'daemon-startup' | 'provider-startup', - options: AgentBrowserCleanupOptions = {}, + options: AgentBrowserCleanupOptions, ): Promise { const openWebSessionNames = uniqueStrings( (options.openWebSessionNames ?? []).filter((value) => value.length > 0), @@ -63,7 +65,7 @@ export async function cleanupManagedAgentBrowserOrphans( return skippedCleanupResult('open-web-session', { openWebSessionNames }); } - const idleTimeoutMs = resolveAgentBrowserIdleTimeoutMs(commandHostEnvironment().env); + const idleTimeoutMs = resolveAgentBrowserIdleTimeoutMs(options.env); const latestActivityMs = readLatestManagedBrowserActivityMs(status); if (latestActivityMs !== undefined && Date.now() - latestActivityMs < idleTimeoutMs) { return skippedCleanupResult('recent-browser-activity', { idleTimeoutMs, latestActivityMs }); @@ -102,7 +104,7 @@ export async function cleanupManagedAgentBrowserOrphans( export async function cleanupManagedAgentBrowserOrphansForProviderStartup( status: AgentBrowserToolStatus, - options: AgentBrowserCleanupOptions = {}, + options: AgentBrowserCleanupOptions, ): Promise { const now = Date.now(); const key = path.resolve(status.stateDir); diff --git a/packages/platform-web/src/agent-browser-provider-rects.test.ts b/packages/platform-web/src/agent-browser-provider-rects.test.ts index 55cafe1aa3..609604b1a1 100644 --- a/packages/platform-web/src/agent-browser-provider-rects.test.ts +++ b/packages/platform-web/src/agent-browser-provider-rects.test.ts @@ -442,7 +442,13 @@ async function withWebProvider( const stateDir = mkdtempForTestSync('agent-device-web-provider-rects-'); try { installFakeManagedAgentBrowser(stateDir); - await testFn(createAgentBrowserWebProvider({ stateDir, session: 'web-session' })); + await testFn( + createAgentBrowserWebProvider({ + host: { env: process.env, cwd: process.cwd() }, + stateDir, + session: 'web-session', + }), + ); } finally { fs.rmSync(stateDir, { recursive: true, force: true }); } diff --git a/packages/platform-web/src/agent-browser-provider.test.ts b/packages/platform-web/src/agent-browser-provider.test.ts index 57fde3ce26..0fdc7308c1 100644 --- a/packages/platform-web/src/agent-browser-provider.test.ts +++ b/packages/platform-web/src/agent-browser-provider.test.ts @@ -130,6 +130,7 @@ test('agent-browser provider runs provider-startup cleanup before the first mana assert.deepEqual(events, ['provider-startup-cleanup', 'agent-browser open']); assert.equal(mockProviderStartupCleanup.mock.calls.length, 1); assert.deepEqual(mockProviderStartupCleanup.mock.calls[0]?.[1], { + env: process.env, openWebSessionNames: [], }); }); @@ -390,7 +391,10 @@ test('agent-browser provider adds doctor guidance for missing binary and invalid await withNodeRuntime({ version: '24.0.0' }, async () => { const missingStateDir = mkdtempForTestSync('agent-device-web-provider-missing-'); try { - const provider = createAgentBrowserWebProvider({ stateDir: missingStateDir }); + const provider = createAgentBrowserWebProvider({ + host: { env: process.env, cwd: process.cwd() }, + stateDir: missingStateDir, + }); await assert.rejects( async () => await provider.open('https://example.test'), (error: unknown) => @@ -424,7 +428,10 @@ test('agent-browser provider preserves Node version guidance for missing managed await withNodeRuntime({ version: '22.19.0' }, async () => { const missingStateDir = mkdtempForTestSync('agent-device-web-provider-node-'); try { - const provider = createAgentBrowserWebProvider({ stateDir: missingStateDir }); + const provider = createAgentBrowserWebProvider({ + host: { env: process.env, cwd: process.cwd() }, + stateDir: missingStateDir, + }); await assert.rejects( async () => await provider.open('https://example.test'), (error: unknown) => diff --git a/packages/platform-web/src/agent-browser-provider.ts b/packages/platform-web/src/agent-browser-provider.ts index e992a5c18a..e8bee2f14d 100644 --- a/packages/platform-web/src/agent-browser-provider.ts +++ b/packages/platform-web/src/agent-browser-provider.ts @@ -9,6 +9,7 @@ import { } from '@agent-device/kernel/errors'; import type { Rect } from '@agent-device/kernel/snapshot'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { buildAudioProbeEvalScript, normalizeAgentBrowserAudioProbeResult, @@ -40,6 +41,8 @@ const AGENT_BROWSER_DOCTOR_HINT = 'Run `agent-device web setup` to install the managed web backend.'; type AgentBrowserProviderOptions = { + /** The composing runtime's host; every backend spawn and status read uses it. */ + host: HostEnvironment; session?: string; stateDir?: string; openWebSessionNames?: () => readonly string[]; @@ -52,9 +55,7 @@ type AgentBrowserRunOptions = { stdin?: string; }; -export function createAgentBrowserWebProvider( - options: AgentBrowserProviderOptions = {}, -): WebProvider { +export function createAgentBrowserWebProvider(options: AgentBrowserProviderOptions): WebProvider { const session = options.session?.trim(); const runJson = async (args: string[], run?: AgentBrowserRunOptions): Promise => await runAgentBrowserJson(args, { session, options, ...run }); @@ -449,10 +450,11 @@ async function runAgentBrowserCommand( let exitCode = 0; let commandCompleted = false; let semanticSuccess = false; - const status = getManagedAgentBrowserStatus({ stateDir: options.stateDir }); + const status = getManagedAgentBrowserStatus({ host: options.host, stateDir: options.stateDir }); try { await cleanupProviderStartupOrphans(options); const result = await runManagedAgentBrowser(cliArgs, { + host: options.host, stateDir: options.stateDir, allowFailure: true, timeoutMs: AGENT_BROWSER_TIMEOUT_MS, @@ -585,10 +587,11 @@ function canClearAgentBrowserRecord( async function cleanupProviderStartupOrphans(options: AgentBrowserProviderOptions): Promise { if (!options.openWebSessionNames) return; - const status = getManagedAgentBrowserStatus({ stateDir: options.stateDir }); + const status = getManagedAgentBrowserStatus({ host: options.host, stateDir: options.stateDir }); if (!status.installed) return; try { await cleanupManagedAgentBrowserOrphansForProviderStartup(status, { + env: options.host.env, openWebSessionNames: options.openWebSessionNames(), ...(options.ownedProcessRecords === undefined ? {} diff --git a/packages/platform-web/src/agent-browser-tool.test.ts b/packages/platform-web/src/agent-browser-tool.test.ts index 314b9120db..1b405b7c6d 100644 --- a/packages/platform-web/src/agent-browser-tool.test.ts +++ b/packages/platform-web/src/agent-browser-tool.test.ts @@ -26,7 +26,12 @@ test('managed agent-browser reports actionable guidance when install is missing' const stateDir = mkdtempForTestSync('agent-device-web-tool-'); try { await assert.rejects( - () => runManagedAgentBrowser(['doctor'], { stateDir, timeoutMs: 1_000 }), + () => + runManagedAgentBrowser(['doctor'], { + host: { env: process.env, cwd: process.cwd() }, + stateDir, + timeoutMs: 1_000, + }), (error: unknown) => error instanceof AppError && error.code === 'TOOL_MISSING' && @@ -54,7 +59,11 @@ test('managed agent-browser runs its JS entry with the current Node runtime, not return { stdout: '', stderr: '', exitCode: 0 }; }, async () => - await runManagedAgentBrowser(['snapshot', '--json'], { stateDir, timeoutMs: 1_000 }), + await runManagedAgentBrowser(['snapshot', '--json'], { + host: { env: process.env, cwd: process.cwd() }, + stateDir, + timeoutMs: 1_000, + }), ); assert.ok(stateDir.includes(' '), 'the fixture must exercise a path containing spaces'); @@ -78,7 +87,12 @@ test('managed agent-browser passes the managed runtime home and socket dir to th env = options.env; return { stdout: '', stderr: '', exitCode: 0 }; }, - async () => await runManagedAgentBrowser(['doctor'], { stateDir, timeoutMs: 1_000 }), + async () => + await runManagedAgentBrowser(['doctor'], { + host: { env: process.env, cwd: process.cwd() }, + stateDir, + timeoutMs: 1_000, + }), ); assert.equal(env?.HOME, install.runtimeHomeDir); @@ -103,7 +117,10 @@ test('managed agent-browser status ignores a bin shim without the backend packag fs.writeFileSync(path.join(binDir, 'agent-browser.cmd'), '@echo off\n'); fs.writeFileSync(path.join(installDir, 'manifest.json'), '{}'); - const status = getManagedAgentBrowserStatus({ stateDir }); + const status = getManagedAgentBrowserStatus({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); assert.equal(status.entryScript, undefined); assert.equal(status.installed, false); @@ -170,7 +187,11 @@ for (const scenario of [ } return { stdout: '', stderr: '', exitCode: 0 }; }, - async () => await setupManagedAgentBrowser({ stateDir }), + async () => + await setupManagedAgentBrowser({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }), ); if (scenario.expected === 'missing') { await assert.rejects( @@ -228,7 +249,11 @@ test('managed agent-browser setup reports an install that produced no entry', as async () => ({ stdout: '', stderr: '', exitCode: 0 }), async () => await assert.rejects( - () => setupManagedAgentBrowser({ stateDir }), + () => + setupManagedAgentBrowser({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }), (error: unknown) => error instanceof AppError && error.code === 'TOOL_MISSING' && @@ -254,10 +279,16 @@ test('managed agent-browser setup gives the install lock back on every path out' return { stdout: '', stderr: '', exitCode: 0 }; }, async () => { - await setupManagedAgentBrowser({ stateDir }); + await setupManagedAgentBrowser({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); // The package exists by now, so this second call returns from *inside* the lock // rather than running to the end of it. - await setupManagedAgentBrowser({ stateDir }); + await setupManagedAgentBrowser({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); }, ); }); diff --git a/packages/platform-web/src/agent-browser-tool.ts b/packages/platform-web/src/agent-browser-tool.ts index d6ef392fef..8ab9c70f79 100644 --- a/packages/platform-web/src/agent-browser-tool.ts +++ b/packages/platform-web/src/agent-browser-tool.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { runCmd, type ExecResult, commandHostEnvironment } from '@agent-device/host-kit/command'; +import { runCmd, type ExecResult } from '@agent-device/host-kit/command'; import { acquireProcessLock, withProcessLock } from '@agent-device/host-kit/file'; import { createHostDirectoryLinkSync, @@ -19,6 +19,7 @@ import { readProcessStartTime, } from '@agent-device/host-kit/process'; import { AppError, asAppError } from '@agent-device/kernel/errors'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { ManagedWebBackendStatus } from '@agent-device/contracts/managed-web-backend'; import { installManagedAgentBrowserPackage, @@ -40,6 +41,8 @@ const DOCTOR_TIMEOUT_MS = 60_000; export type AgentBrowserToolStatus = ManagedWebBackendStatus; export type ManagedAgentBrowserRunOptions = { + /** The caller's host: its environment selects the state dir and seeds the backend env. */ + host: HostEnvironment; stateDir?: string; timeoutMs: number; allowFailure?: boolean; @@ -57,12 +60,13 @@ export async function runManagedAgentBrowser( args: readonly string[], options: ManagedAgentBrowserRunOptions, ): Promise { - const status = getManagedAgentBrowserStatus({ stateDir: options.stateDir }); + const status = getManagedAgentBrowserStatus({ host: options.host, stateDir: options.stateDir }); if (!status.installed) throw missingManagedToolError(status); return await spawnManagedAgentBrowser(status, args, options); } export async function setupManagedAgentBrowser(options: { + host: HostEnvironment; stateDir?: string; }): Promise { const status = getManagedAgentBrowserStatus(options); @@ -89,12 +93,17 @@ export async function setupManagedAgentBrowser(options: { packageRoot: path.join(freshStatus.installDir, 'package'), packageSpec: `${AGENT_BROWSER}@${MANAGED_AGENT_BROWSER_VERSION}`, timeoutMs: SETUP_TIMEOUT_MS, + env: options.host.env, }); // The backend entry only exists once npm has written the package. const installedStatus = getManagedAgentBrowserStatus(options); if (!installedStatus.entryScript) throw unusableInstallError(installedStatus); - await spawnManagedAgentBrowser(installedStatus, ['install'], { timeoutMs: SETUP_TIMEOUT_MS }); + await spawnManagedAgentBrowser(installedStatus, ['install'], { + host: options.host, + timeoutMs: SETUP_TIMEOUT_MS, + }); await spawnManagedAgentBrowser(installedStatus, ['doctor', '--offline', '--quick'], { + host: options.host, timeoutMs: DOCTOR_TIMEOUT_MS, }); writeManagedAgentBrowserManifest({ @@ -108,6 +117,7 @@ export async function setupManagedAgentBrowser(options: { } export async function doctorManagedAgentBrowser(options: { + host: HostEnvironment; stateDir?: string; }): Promise<{ status: AgentBrowserToolStatus; stdout: string; stderr: string; exitCode: number }> { const status = getManagedAgentBrowserStatus(options); @@ -115,6 +125,7 @@ export async function doctorManagedAgentBrowser(options: { throw missingManagedToolError(status); } const result = await spawnManagedAgentBrowser(status, ['doctor', '--offline', '--quick'], { + host: options.host, timeoutMs: DOCTOR_TIMEOUT_MS, allowFailure: true, }); @@ -122,10 +133,11 @@ export async function doctorManagedAgentBrowser(options: { } export function getManagedAgentBrowserStatus(options: { + host: HostEnvironment; stateDir?: string; }): AgentBrowserToolStatus { const stateDir = - options.stateDir ?? commandHostEnvironment().env.AGENT_DEVICE_STATE_DIR ?? defaultStateDir(); + options.stateDir ?? options.host.env.AGENT_DEVICE_STATE_DIR ?? defaultStateDir(options.host); const { installDir, homeDir, runtimeHomeDir, socketDir } = managedInstallPaths( stateDir, MANAGED_AGENT_BROWSER_VERSION, @@ -161,7 +173,7 @@ async function spawnManagedAgentBrowser( if (!status.entryScript) throw missingManagedToolError(status); return await runCmd(hostNodeExecutablePath(), [status.entryScript, ...args], { allowFailure: options.allowFailure, - env: managedAgentBrowserEnv(status, commandHostEnvironment().env), + env: managedAgentBrowserEnv(status, options.host.env), timeoutMs: options.timeoutMs, signal: options.signal, stdin: options.stdin, @@ -287,11 +299,8 @@ function assertWebNodeSupported(nodeMajor: number): void { }); } -function defaultStateDir(): string { - return path.join( - commandHostEnvironment().env.HOME ?? commandHostEnvironment().cwd, - '.agent-device', - ); +function defaultStateDir(host: HostEnvironment): string { + return path.join(host.env.HOME ?? host.cwd, '.agent-device'); } export function mapManagedAgentBrowserError(error: unknown): AppError { diff --git a/packages/platform-web/src/doctor.ts b/packages/platform-web/src/doctor.ts index 1cfa8f4d0f..7de9290ab3 100644 --- a/packages/platform-web/src/doctor.ts +++ b/packages/platform-web/src/doctor.ts @@ -1,9 +1,13 @@ import type { DoctorCheck } from '@agent-device/contracts/observability'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { inspectManagedAgentBrowserProcesses } from './agent-browser-lifecycle.ts'; import { getManagedAgentBrowserStatus } from './agent-browser-tool.ts'; -export async function webBrowserLifecycleCheck(stateDir: string): Promise { - const status = getManagedAgentBrowserStatus({ stateDir }); +export async function webBrowserLifecycleCheck( + stateDir: string, + host: HostEnvironment, +): Promise { + const status = getManagedAgentBrowserStatus({ stateDir, host }); if (!status.installed) { return { id: 'web-agent-browser-processes', diff --git a/packages/platform-web/src/index.ts b/packages/platform-web/src/index.ts index 27b8f11d2a..0949ba011e 100644 --- a/packages/platform-web/src/index.ts +++ b/packages/platform-web/src/index.ts @@ -77,6 +77,9 @@ export const resolveWebProvider = deferred<(typeof import('./provider.ts'))['res export const hasScopedWebProvider = deferred< (typeof import('./provider.ts'))['hasScopedWebProvider'] >(async () => (await import('./provider.ts')).hasScopedWebProvider); +export const resolveWebProviderForHost = deferred< + (typeof import('./resolve-provider.ts'))['resolveWebProviderForHost'] +>(async () => (await import('./resolve-provider.ts')).resolveWebProviderForHost); export async function withWebProvider( provider: import('./provider.ts').WebProvider | undefined, diff --git a/packages/platform-web/src/interactor-facade.ts b/packages/platform-web/src/interactor-facade.ts index 388de123e6..9e5da0998e 100644 --- a/packages/platform-web/src/interactor-facade.ts +++ b/packages/platform-web/src/interactor-facade.ts @@ -1,7 +1,11 @@ import type { Interactor } from '@agent-device/contracts/interactor-types'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { WebProvider } from './provider.ts'; -export async function createWebInteractor(provider?: WebProvider): Promise { +export async function createWebInteractor( + host: HostEnvironment, + provider?: WebProvider, +): Promise { const { createWebInteractor: create } = await import('./interactor.ts'); - return await create(provider); + return await create(host, provider); } diff --git a/packages/platform-web/src/interactor.test.ts b/packages/platform-web/src/interactor.test.ts index 9fb26f5aaf..64a51de135 100644 --- a/packages/platform-web/src/interactor.test.ts +++ b/packages/platform-web/src/interactor.test.ts @@ -44,7 +44,7 @@ test('web interactor delegates first-slice operations to the scoped provider', a }); const snapshot = await withWebProvider(provider, async () => { - const interactor = await createWebInteractor(); + const interactor = await createWebInteractor({ env: process.env, cwd: process.cwd() }); await interactor.open('https://example.test'); await interactor.open('app-shell', { url: 'https://example.test/deep' }); await interactor.close('app-shell'); @@ -81,7 +81,7 @@ test('web interactor delegates first-slice operations to the scoped provider', a test('web interactor reports hover unsupported when the provider lacks it', async () => { await withWebProvider(makeWebProvider(), async () => { - const interactor = await createWebInteractor(); + const interactor = await createWebInteractor({ env: process.env, cwd: process.cwd() }); assert.equal(interactor.tapRef, undefined); assert.equal(interactor.hover, undefined); assert.equal(interactor.hoverRef, undefined); @@ -93,14 +93,14 @@ test('web interactor reports hover unsupported when the provider lacks it', asyn // web session must carry no fused double-click of its own. test('web interactor carries no fused double-click mechanic', async () => { await withWebProvider(makeWebProvider(), async () => { - const interactor = await createWebInteractor(); + const interactor = await createWebInteractor({ env: process.env, cwd: process.cwd() }); assert.equal(interactor.doubleTap, undefined); }); }); test('web interactor reports unsupported operations explicitly', async () => { - const interactor = await createWebInteractor(); + const interactor = await createWebInteractor({ env: process.env, cwd: process.cwd() }); await assert.rejects( () => interactor.back(), diff --git a/packages/platform-web/src/interactor.ts b/packages/platform-web/src/interactor.ts index fcd8ef92e0..02aeaaf3ac 100644 --- a/packages/platform-web/src/interactor.ts +++ b/packages/platform-web/src/interactor.ts @@ -2,11 +2,17 @@ import type { Interactor } from '@agent-device/contracts/interactor-types'; import { AppError } from '@agent-device/kernel/errors'; import { stripAtPrefix } from '@agent-device/selectors/interaction-positionals'; import { withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; -import { resolveWebProvider, type WebProvider } from './index.ts'; +import { resolveWebProviderForHost, type WebProvider } from './index.ts'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { createUnsupportedInteractor } from './unsupported-interactor.ts'; -export async function createWebInteractor(provider?: WebProvider): Promise { - const resolvedProvider = provider ?? (await resolveWebProvider()); +export async function createWebInteractor( + host: HostEnvironment, + provider?: WebProvider, +): Promise { + // A request's scoped provider wins; an out-of-request local path binds the composing root's + // host into its own default provider instead of reading an ambient one (#3794). + const resolvedProvider = await resolveWebProviderForHost(host, provider); const clickRef = resolvedProvider.clickRef; const hover = resolvedProvider.hover; const hoverRef = resolvedProvider.hoverRef; diff --git a/packages/platform-web/src/provider.ts b/packages/platform-web/src/provider.ts index 38d50209fd..f13fb6d080 100644 --- a/packages/platform-web/src/provider.ts +++ b/packages/platform-web/src/provider.ts @@ -1,5 +1,6 @@ import type { ScrollDirection } from '@agent-device/contracts/scroll-gesture'; import type { SessionSurface } from '@agent-device/contracts/session'; +import { AppError } from '@agent-device/kernel/errors'; import { createScopedProvider } from '@agent-device/kernel/scoped-provider'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; import type { @@ -7,7 +8,6 @@ import type { BackendDumpNetworkResult, } from '@agent-device/contracts/backend-diagnostics'; import type { AudioProbeResult } from '@agent-device/contracts/audio-probe-result'; -import { createAgentBrowserWebProvider } from './agent-browser-provider.ts'; export type WebOpenOptions = { url?: string; @@ -72,9 +72,32 @@ export type WebProvider = { probeAudio?(options: WebAudioProbeOptions): Promise; }; -const localWebProvider: WebProvider = createAgentBrowserWebProvider(); +/** + * The un-composed fallback: a request that reaches a web device without a composed provider is a + * composition bug, so it fails closed instead of borrowing a host it was never given (#3794). + * Optional capabilities stay absent, exactly as a provider that lacks them reports them. + */ +function uncomposedWebProviderFailure(): Promise { + return Promise.reject( + new AppError('COMMAND_FAILED', 'The web provider is not composed for this request.', { + reason: 'web-provider-missing', + }), + ); +} + +const uncomposedWebProvider: WebProvider = { + open: () => uncomposedWebProviderFailure(), + close: () => uncomposedWebProviderFailure(), + snapshot: () => uncomposedWebProviderFailure(), + screenshot: () => uncomposedWebProviderFailure(), + setViewport: () => uncomposedWebProviderFailure(), + click: () => uncomposedWebProviderFailure(), + fill: () => uncomposedWebProviderFailure(), + typeText: () => uncomposedWebProviderFailure(), + scroll: () => uncomposedWebProviderFailure(), +}; -const webProviderScope = createScopedProvider(localWebProvider); +const webProviderScope = createScopedProvider(uncomposedWebProvider); export function resolveWebProvider(provider?: WebProvider): WebProvider { return webProviderScope.resolve(provider); diff --git a/packages/platform-web/src/resolve-provider.ts b/packages/platform-web/src/resolve-provider.ts new file mode 100644 index 0000000000..2e1c17cf4b --- /dev/null +++ b/packages/platform-web/src/resolve-provider.ts @@ -0,0 +1,21 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import type { WebProvider } from './provider.ts'; + +/** + * The one policy for the provider of a web request: a request-scoped provider wins; a request + * that reaches a web device with no composed provider gets the agent-browser provider bound to + * the composing runtime's host. Every reader of one web request — the interactor, the network + * transport, the audio probe — resolves through here, so they always agree on one provider + * (#3794). + */ +export async function resolveWebProviderForHost( + host: HostEnvironment, + provider?: WebProvider, +): Promise { + if (provider) return provider; + const [{ hasScopedWebProvider, resolveWebProvider }, { createAgentBrowserWebProvider }] = + await Promise.all([import('./provider.ts'), import('./agent-browser-provider.ts')]); + return (await hasScopedWebProvider()) + ? resolveWebProvider() + : await createAgentBrowserWebProvider({ host }); +} diff --git a/packages/test-kit/src/host-environment.ts b/packages/test-kit/src/host-environment.ts index 10655324a9..2ae988a3e9 100644 --- a/packages/test-kit/src/host-environment.ts +++ b/packages/test-kit/src/host-environment.ts @@ -3,6 +3,15 @@ import { withCommandHost } from '@agent-device/host-kit/command'; import { captureHostEnvironment } from '@agent-device/host-kit/process'; import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; +/** + * A host for tests that name one per call. Code converted to an explicit host (#3794) reads its + * configuration from the object it is handed, so a suite passes this instead of binding a command + * host. Its environment is the live `process.env` reference, so `vi.stubEnv` still reaches it. + */ +export function createLiveTestHostEnvironment(): HostEnvironment { + return { env: process.env, cwd: process.cwd() }; +} + /** * A host for tests that boot daemon code in-process. It snapshots the process environment and * working directory unless a test names its own, so a suite never shares a mutable reference to diff --git a/packages/test-kit/src/index.ts b/packages/test-kit/src/index.ts index 78dabdb0d2..787770ff54 100644 --- a/packages/test-kit/src/index.ts +++ b/packages/test-kit/src/index.ts @@ -36,7 +36,11 @@ export { dispatchDisclosureRowsOwnedBy, } from './dispatch-disclosure.fixtures.ts'; export { restoreEnv } from './env.ts'; -export { bindTestCommandHost, createTestHostEnvironment } from './host-environment.ts'; +export { + bindTestCommandHost, + createLiveTestHostEnvironment, + createTestHostEnvironment, +} from './host-environment.ts'; export { eagerClosureGraphOf, eagerClosureOf, diff --git a/scripts/clean-daemon.ts b/scripts/clean-daemon.ts index 444ab02d9d..8b7b10cdaa 100644 --- a/scripts/clean-daemon.ts +++ b/scripts/clean-daemon.ts @@ -41,8 +41,14 @@ if (retirement.status === 'retained') { if (retirement.status === 'retired') { const { cleanupRunnerLeasesForOwner } = await import('@agent-device/platform-apple/runner/operations'); + const { setAppleRunnerHostEnvironment } = + await import('@agent-device/platform-apple/runner-owner'); + const host = captureHostEnvironment(process); + // Runner configuration reads take the runtime's host (#3794); this script is the whole + // process here, so it seeds the runner state with the same host it enters as a command host. + setAppleRunnerHostEnvironment(host); await withCommandHost( - { host: captureHostEnvironment(process), requestEnv: undefined }, + { host, requestEnv: undefined }, async () => await cleanupRunnerLeasesForOwner(retirement.termination.identity), ); } diff --git a/scripts/write-xcuitest-cache-metadata.ts b/scripts/write-xcuitest-cache-metadata.ts index d3d68c33e8..b0c4caa70a 100644 --- a/scripts/write-xcuitest-cache-metadata.ts +++ b/scripts/write-xcuitest-cache-metadata.ts @@ -73,6 +73,11 @@ async function writeXcuitestCacheMetadata( if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { const host = captureHostEnvironment(process); + // Runner configuration reads take the runtime's host (#3794); this script is the whole + // process here, so it seeds the runner state with the same host it enters as a command host. + const { setAppleRunnerHostEnvironment } = + await import('@agent-device/platform-apple/runner-owner'); + setAppleRunnerHostEnvironment(host); await withCommandHost( { host, requestEnv: undefined }, async () => await writeXcuitestCacheMetadata(process.argv.slice(2), host.cwd), diff --git a/src/__tests__/cli-record-flag-delivery.test.ts b/src/__tests__/cli-record-flag-delivery.test.ts index 9b36979896..e037a49192 100644 --- a/src/__tests__/cli-record-flag-delivery.test.ts +++ b/src/__tests__/cli-record-flag-delivery.test.ts @@ -46,6 +46,7 @@ async function runArgv(argv: string[]): Promise { client, command: argv[0] as CommandName, positionals: parsed.positionals, + env: process.env, flags: parsed.flags, }); } catch { diff --git a/src/__tests__/command-descriptor-post-action-observation.test.ts b/src/__tests__/command-descriptor-post-action-observation.test.ts index fde338f035..1c9e5eb7a8 100644 --- a/src/__tests__/command-descriptor-post-action-observation.test.ts +++ b/src/__tests__/command-descriptor-post-action-observation.test.ts @@ -266,7 +266,9 @@ test('post-action observation CLI output formatters follow descriptor traits', a for (const [command, format] of Object.entries(formatters)) { let text: string; try { - text = String((await format({ input: {}, result: settledResult })).text ?? ''); + text = String( + (await format({ env: process.env, input: {}, result: settledResult })).text ?? '', + ); } catch { // Formatters expecting a differently-shaped result (device lists) are // orthogonal to settle rendering — but a settle-capable formatter must diff --git a/src/__tests__/daemon-composition/platform-resource-cleanup-snapshot-helper.test.ts b/src/__tests__/daemon-composition/platform-resource-cleanup-snapshot-helper.test.ts index 2e8040c49a..3804c9864c 100644 --- a/src/__tests__/daemon-composition/platform-resource-cleanup-snapshot-helper.test.ts +++ b/src/__tests__/daemon-composition/platform-resource-cleanup-snapshot-helper.test.ts @@ -12,8 +12,14 @@ vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { }; }); -import { ANDROID_EMULATOR, IOS_SIMULATOR } from '@agent-device/test-kit'; -import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; +import { + ANDROID_EMULATOR, + IOS_SIMULATOR, + createLiveTestHostEnvironment, +} from '@agent-device/test-kit'; +import { createPlatformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; + +const platformResourceCleanup = createPlatformResourceCleanup(createLiveTestHostEnvironment()); beforeEach(() => { vi.clearAllMocks(); diff --git a/src/__tests__/daemon-composition/platform-services-fixture.ts b/src/__tests__/daemon-composition/platform-services-fixture.ts deleted file mode 100644 index 7a3a4fc51d..0000000000 --- a/src/__tests__/daemon-composition/platform-services-fixture.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type ClaimRecoveryGatewayFactory } from '@agent-device/daemon-contracts/device-claim-recovery'; -import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts'; -import { createClaimRecoveryGatewayFor } from '../../platform-runtime-claim-recovery.ts'; -import type { DaemonPlatformServices } from '@agent-device/daemon'; - -/** - * The port the production composition root builds, handed to a test the same way the shared router - * fixture hands the real resource-cleanup capability: tests that exercise command behaviour rather - * than readiness or observation policy keep seeing the real adapters, and a test that cares about - * one ask replaces just that member — a readiness spy overrides `ensureLocalDeviceReady` rather - * than mocking a platform module behind the port. - */ -export function composedPlatformServicesFixture( - overrides: Partial = {}, -): DaemonPlatformServices { - return Object.freeze({ ...createDaemonPlatformServices(), ...overrides }); -} - -/** - * The recovery-gateway half of root composition, same rule as the services above: tests that - * route a stale claim see the real composed gateway, and a test that cares about composition - * passes its own factory. - */ -export function composedClaimRecoveryGatewayFixture(): ClaimRecoveryGatewayFactory { - return createClaimRecoveryGatewayFor(process.env); -} diff --git a/src/__tests__/platform-runtime-apple-application-tools.test.ts b/src/__tests__/platform-runtime-apple-application-tools.test.ts index cde2397032..fb76905088 100644 --- a/src/__tests__/platform-runtime-apple-application-tools.test.ts +++ b/src/__tests__/platform-runtime-apple-application-tools.test.ts @@ -19,7 +19,7 @@ vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) // loses the mock this way starts the real local XCTest runner — whose stale-process cleanup // `pkill`s xcodebuild on the developer's host, failing whichever test is running when it lands. test('concurrent runner ports share one module resolution, so the mock always applies', async () => { - const tools = createAppleApplicationTools(); + const tools = createAppleApplicationTools(process.env); await Promise.all([ tools.detachRunnerSessionsForShutdown(), diff --git a/src/__tests__/platform-runtime-claim-recovery.test.ts b/src/__tests__/platform-runtime-claim-recovery.test.ts index 37bee72acd..cc0385d2bd 100644 --- a/src/__tests__/platform-runtime-claim-recovery.test.ts +++ b/src/__tests__/platform-runtime-claim-recovery.test.ts @@ -10,7 +10,11 @@ import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import { runStaleDeviceClaimRelease } from '../platform-runtime-claim-recovery.ts'; import { runCliCapture } from './cli-capture.ts'; -import { mkdtempForTestSync, pinOwnProcessStartTime } from '@agent-device/test-kit'; +import { + createLiveTestHostEnvironment, + mkdtempForTestSync, + pinOwnProcessStartTime, +} from '@agent-device/test-kit'; pinOwnProcessStartTime(hostProcess); @@ -283,16 +287,20 @@ test('recovery is composed per claim from the stale owner state dir, never the c process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; const composedFor: string[] = []; - const outcomes = await runStaleDeviceClaimRelease({}, process.env, (stateDir) => { - composedFor.push(stateDir); - return { - reconcile: async (claim) => { - assert.equal(claim.stateDir, stateDir); - return { status: 'reconciled' }; - }, - dispose: async () => {}, - }; - }); + const outcomes = await runStaleDeviceClaimRelease( + {}, + createLiveTestHostEnvironment(), + (stateDir) => { + composedFor.push(stateDir); + return { + reconcile: async (claim) => { + assert.equal(claim.stateDir, stateDir); + return { status: 'reconciled' }; + }, + dispose: async () => {}, + }; + }, + ); assert.deepEqual([...composedFor].sort(), [ownerDirA, ownerDirB].sort()); assert.equal(outcomes.filter((outcome) => outcome.status === 'released').length, 2); diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index dfdbaddbcc..005b5b7b60 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -486,7 +486,10 @@ export function createAgentDeviceClient( symbols: async (options) => { const { symbolicateCrashArtifact } = await import('@agent-device/platform-apple/debug-symbols'); - return symbolicateCrashArtifact({ cwd: options.cwd ?? config.cwd, ...options }); + return symbolicateCrashArtifact({ + ...options, + cwd: options.cwd ?? config.cwd ?? host.cwd, + }); }, }, recording: { diff --git a/src/cli.ts b/src/cli.ts index aaecb8c0b5..c7572260ac 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -190,6 +190,7 @@ async function runCliOnHost(argv: string[], deps: CliDeps, host: HostEnvironment await runWebCommand(positionals, { flags: ctx.effectiveFlags, stateDir: ctx.daemonPaths.baseDir, + host, }), ); return; diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 8d3499452f..f14624b46f 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -19,9 +19,8 @@ import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; import { createDaemonOwnerCleanup } from '../../platform-runtime-daemon-owner-cleanup.ts'; -const daemonOwnerCleanup = createDaemonOwnerCleanup(); - export const daemonCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { + const daemonOwnerCleanup = createDaemonOwnerCleanup(host); const subcommand = positionals[0]; if ((subcommand !== 'stop' && subcommand !== 'status') || positionals.length !== 1) { throw new AppError('INVALID_ARGS', 'daemon accepts only: stop, status'); diff --git a/src/cli/commands/device.ts b/src/cli/commands/device.ts index 2e2a40ea95..b3d1932f02 100644 --- a/src/cli/commands/device.ts +++ b/src/cli/commands/device.ts @@ -77,7 +77,7 @@ async function runDeviceRelease(flags: CliFlags, host: HostEnvironment): Promise udid: flags.udid, serial: flags.serial, }, - host.env, + host, ); const data = { released: outcomes.filter((outcome) => outcome.status === 'released').map(serializeOutcome), diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts index ac96d94782..655339a478 100644 --- a/src/cli/commands/generic.ts +++ b/src/cli/commands/generic.ts @@ -16,6 +16,7 @@ export async function runGenericClientBackedCommand({ positionals, flags, client, + host, debug, replayTestReporterRuntime, commandProgress, @@ -26,6 +27,7 @@ export async function runGenericClientBackedCommand({ positionals, flags, commandProgress, + env: host.env, }); // A non-default responseLevel returns a leveled payload (e.g. the snapshot // digest { nodeCount, refs }) that the per-command CLI formatters assume away — diff --git a/src/cli/commands/recording.ts b/src/cli/commands/recording.ts index 868ce38b24..3b54ece046 100644 --- a/src/cli/commands/recording.ts +++ b/src/cli/commands/recording.ts @@ -39,6 +39,7 @@ export const recordingCommand: ClientCommandHandler = async ({ positionals, flag }); const result = await runtime.recording.contactSheet({ + host, video: { kind: 'path', path: recordingPath }, ...(outputPath ? { out: { kind: 'path', path: outputPath } } : {}), }); diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts index f12fe61f20..d7df8b9300 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -12,12 +12,18 @@ import type { CliFlags } from '@agent-device/contracts/command'; import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; -export const screenshotCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { +export const screenshotCommand: ClientCommandHandler = async ({ + positionals, + flags, + client, + host, +}) => { const result = (await runCliCommand({ client, command: 'screenshot', positionals, flags, + env: host.env, })) as CaptureScreenshotResult; // A non-default responseLevel returns a leveled (digest) payload — overlayCount, // artifacts, leveled overlayRefs. Rebuilding the default { path, overlayRefs } @@ -39,7 +45,13 @@ export const screenshotCommand: ClientCommandHandler = async ({ positionals, fla export const diffCommand: ClientCommandHandler = async ({ positionals, flags, client, host }) => { if (positionals[0] === 'snapshot') { - const result = await runCliCommand({ client, command: 'diff', positionals, flags }); + const result = await runCliCommand({ + client, + command: 'diff', + positionals, + flags, + env: host.env, + }); await writeCommandOutput(flags, result, () => formatSnapshotDiffText(result)); return true; } diff --git a/src/cli/commands/web.ts b/src/cli/commands/web.ts index c5bb7116e8..675f02c960 100644 --- a/src/cli/commands/web.ts +++ b/src/cli/commands/web.ts @@ -1,17 +1,19 @@ import type { ManagedWebBackendStatus } from '@agent-device/contracts/managed-web-backend'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { AppError } from '@agent-device/kernel/errors'; import type { CliFlags } from '@agent-device/contracts/command'; import { printJson } from '../../commands/output/json.ts'; import { createManagedWebBackend } from '../../platform-runtime-managed-web-backend.ts'; -const managedWebBackend = createManagedWebBackend(); +const managedWebBackendFor = (host: HostEnvironment) => createManagedWebBackend(host); type PublicAgentBrowserToolStatus = Omit; export async function runWebCommand( positionals: string[], - options: { flags: CliFlags; stateDir: string }, + options: { flags: CliFlags; stateDir: string; host: HostEnvironment }, ): Promise { + const managedWebBackend = managedWebBackendFor(options.host); const action = positionals[0]; switch (action) { case 'setup': { diff --git a/src/commands/capture/settings.test.ts b/src/commands/capture/settings.test.ts index f5ab1c5f3a..d0f322ebab 100644 --- a/src/commands/capture/settings.test.ts +++ b/src/commands/capture/settings.test.ts @@ -39,11 +39,20 @@ describe('settings CLI', () => { warnings: [warning], }; - const output = await formatCliOutput({ name: 'settings', input: {}, result: response }); + const output = await formatCliOutput({ + env: process.env, + name: 'settings', + input: {}, + result: response, + }); expect(output?.text).toBe(`Updated setting: permission\nWarning: ${warning}`); expect(output?.data).toMatchObject({ warnings: [warning] }); - const rendered = settingsCommandFacet.cliOutputFormatter!({ input: {}, result: response }); + const rendered = settingsCommandFacet.cliOutputFormatter!({ + env: process.env, + input: {}, + result: response, + }); expect(rendered.text).toBe('Updated setting: permission'); }); diff --git a/src/commands/cli-output.test.ts b/src/commands/cli-output.test.ts index d0317fafe3..bb3330d56a 100644 --- a/src/commands/cli-output.test.ts +++ b/src/commands/cli-output.test.ts @@ -32,7 +32,7 @@ const FORMATTER_COMMANDS = Object.keys( ).sort() as CommandName[]; async function render(name: CommandName, result: unknown, input = {}) { - const output = await formatCliOutput({ name, input, result }); + const output = await formatCliOutput({ env: process.env, name, input, result }); expect(output, `${name} renders no CLI output`).toBeDefined(); return output!; } diff --git a/src/commands/cli-output.ts b/src/commands/cli-output.ts index dafdd7f04d..9689a637d1 100644 --- a/src/commands/cli-output.ts +++ b/src/commands/cli-output.ts @@ -6,6 +6,7 @@ import type { CliOutput } from './command-contract.ts'; import type { CommandProgressState } from './command-progress.ts'; import { routeResponseWarnings, type CliOutputFormatter } from './output-common.ts'; import type { CommandName } from './command-metadata.ts'; +import type { EnvMap } from '@agent-device/kernel/source-value'; const cliOutputFormatters = listCommandFamilyCliOutputFormatters() as Partial< Record @@ -29,11 +30,14 @@ export async function formatCliOutput(params: { input: unknown; result: unknown; progress?: CommandProgressState; + /** The caller's host environment; a formatter that styles output reads color from it. */ + env: EnvMap; }): Promise { const output = await cliOutputFormatters[params.name]?.({ input: (params.input ?? {}) as Record, result: params.result, progress: params.progress, + env: params.env, }); if (output === undefined) return undefined; return routeResponseWarnings( diff --git a/src/commands/cli-runner.test.ts b/src/commands/cli-runner.test.ts index fd40a39fc3..98d96a355b 100644 --- a/src/commands/cli-runner.test.ts +++ b/src/commands/cli-runner.test.ts @@ -29,6 +29,7 @@ async function runFillWithStdin(argv: string[], text: string): Promise { @@ -38,6 +41,7 @@ export async function runCliCommandWithOutput(options: CliRunOptions): Promise<{ input, result, progress: options.commandProgress, + env: options.env, }), }; } diff --git a/src/commands/interaction/output.test.ts b/src/commands/interaction/output.test.ts index 7198002931..336b6a3d4d 100644 --- a/src/commands/interaction/output.test.ts +++ b/src/commands/interaction/output.test.ts @@ -2,16 +2,16 @@ import { describe, expect, test } from 'vitest'; import { interactionCliOutputFormatters } from './output.ts'; const formatFind = (result: Record) => - interactionCliOutputFormatters.find({ input: {}, result }); + interactionCliOutputFormatters.find({ env: process.env, input: {}, result }); const formatPress = (result: Record) => - interactionCliOutputFormatters.press({ input: {}, result }); + interactionCliOutputFormatters.press({ env: process.env, input: {}, result }); const formatFill = (result: Record) => - interactionCliOutputFormatters.fill({ input: {}, result }); + interactionCliOutputFormatters.fill({ env: process.env, input: {}, result }); const formatLongPress = (result: Record) => - interactionCliOutputFormatters.longpress({ input: {}, result }); + interactionCliOutputFormatters.longpress({ env: process.env, input: {}, result }); describe('find CLI output', () => { test('click prints the same success line as a direct press', () => { @@ -346,6 +346,7 @@ const REPAIR_WARNING = describe('formatter text stays free of warnings', () => { test('get text renders the value with no format, no stderr', async () => { const output = await interactionCliOutputFormatters.get({ + env: process.env, input: { format: 'text' }, result: { text: 'General', warnings: [REPAIR_WARNING] }, }); @@ -355,6 +356,7 @@ describe('formatter text stays free of warnings', () => { test('get --format attrs keeps its JSON parseable', async () => { const output = await interactionCliOutputFormatters.get({ + env: process.env, input: { format: 'attrs' }, result: { node: { type: 'Cell', label: 'General' }, warnings: [REPAIR_WARNING] }, }); @@ -364,6 +366,7 @@ describe('formatter text stays free of warnings', () => { test('a settle-capable command renders its tap line and settled diff only', async () => { const output = await interactionCliOutputFormatters.press({ + env: process.env, input: {}, result: { message: 'Tapped (10, 20)', diff --git a/src/commands/management/output.test.ts b/src/commands/management/output.test.ts index 19d7a6fd67..210bce03f9 100644 --- a/src/commands/management/output.test.ts +++ b/src/commands/management/output.test.ts @@ -89,10 +89,11 @@ describe('openCliOutput', () => { }; const openOutput = await withNoColorAsync(() => - formatCliOutput({ name: 'open', input: {}, result: openResult }), + formatCliOutput({ env: process.env, name: 'open', input: {}, result: openResult }), ); const snapshotOutput = await withNoColorAsync(() => formatCliOutput({ + env: process.env, name: 'snapshot', input: { interactiveOnly: true }, result: { nodes, truncated: false, backend: 'xctest', refsGeneration: 1 }, @@ -153,6 +154,7 @@ describe('openCliOutput', () => { describe('artifactsCliOutput', () => { test('prints ready artifact URLs and preserves JSON data', () => { const output = managementCliOutputFormatters.artifacts({ + env: process.env, input: {}, result: { provider: 'browserstack', @@ -179,6 +181,7 @@ describe('artifactsCliOutput', () => { test('prints exact retry command for pending provider sessions', () => { const output = managementCliOutputFormatters.artifacts({ + env: process.env, input: {}, result: { provider: 'aws-device-farm', @@ -199,6 +202,7 @@ describe('artifactsCliOutput', () => { test('prints daemon artifact inventory and preserves JSON data', () => { const output = managementCliOutputFormatters.artifacts({ + env: process.env, input: {}, result: { source: 'daemon', @@ -230,27 +234,30 @@ describe('artifactsCliOutput', () => { describe('doctorCliOutput', () => { test('prints passing checks by default using test-style status markers', async () => { const output = await withNoColorAsync(() => - doctorCliOutput({ - status: 'pass', - summary: 'No blockers found.', - checks: [ - { - id: 'agent-device', - status: 'pass', - summary: 'agent-device 0.17.9 using /tmp/agent-device', - }, - { - id: 'device', - status: 'pass', - summary: 'Selected Pixel (android)', - }, - { - id: 'session', - status: 'info', - summary: 'No active session named default. Doctor will use the selected device.', - }, - ], - }), + doctorCliOutput( + { + status: 'pass', + summary: 'No blockers found.', + checks: [ + { + id: 'agent-device', + status: 'pass', + summary: 'agent-device 0.17.9 using /tmp/agent-device', + }, + { + id: 'device', + status: 'pass', + summary: 'Selected Pixel (android)', + }, + { + id: 'session', + status: 'info', + summary: 'No active session named default. Doctor will use the selected device.', + }, + ], + }, + process.env, + ), ); expect(output.text).toBe( @@ -265,23 +272,26 @@ describe('doctorCliOutput', () => { test('keeps warning and failure recovery details under the relevant row', async () => { const output = await withNoColorAsync(() => - doctorCliOutput({ - status: 'fail', - checks: [ - { - id: 'device', - status: 'fail', - summary: 'No devices found.', - command: 'agent-device devices', - }, - { - id: 'android-reverse', - status: 'warn', - summary: 'Android adb reverse is missing for Metro port 8081.', - command: 'adb -s emulator-5554 reverse tcp:8081 tcp:8081', - }, - ], - }), + doctorCliOutput( + { + status: 'fail', + checks: [ + { + id: 'device', + status: 'fail', + summary: 'No devices found.', + command: 'agent-device devices', + }, + { + id: 'android-reverse', + status: 'warn', + summary: 'Android adb reverse is missing for Metro port 8081.', + command: 'adb -s emulator-5554 reverse tcp:8081 tcp:8081', + }, + ], + }, + process.env, + ), ); expect(output.text).toBe( @@ -308,7 +318,7 @@ describe('doctorCliOutput', () => { }, ], }; - return await doctorCliOutput(result, { renderedToStderr: true }); + return await doctorCliOutput(result, process.env, { renderedToStderr: true }); }); expect(output.text).toBe(['Doctor: pass', 'No blockers found.'].join('\n')); @@ -330,7 +340,7 @@ describe('doctorCliOutput', () => { }, ], }; - return await doctorCliOutput(result, { renderedToStderr: false }); + return await doctorCliOutput(result, process.env, { renderedToStderr: false }); }); expect(output.text).toBe(['Doctor: pass', '✓ device: Selected Pixel (android)'].join('\n')); @@ -340,6 +350,7 @@ describe('doctorCliOutput', () => { describe('devices output', () => { test('carries the projected claim owner through JSON data and the text line', async () => { const output = await managementCliOutputFormatters.devices({ + env: process.env, input: {}, result: [ { @@ -380,6 +391,7 @@ describe('devices output', () => { test('carries the discovered model and OS version through JSON data and the text line', async () => { const output = await managementCliOutputFormatters.devices({ + env: process.env, input: {}, result: [ { diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index 84c6146f58..a69e5cc526 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { AgentDeviceCapabilitiesResult, AgentDeviceDevice, @@ -185,15 +185,13 @@ function shutdownCliOutput(result: CommandRequestResult): CliOutput { export async function doctorCliOutput( result: CommandRequestResult, + env: EnvMap, progress?: CommandProgressState, ): Promise { const { formatDoctorCheckDetailLines, formatDoctorCheckSummaryLine } = await import('@agent-device/host-kit/doctor-lines'); const { shouldColorStatusMarkers } = await import('@agent-device/host-kit/status-markers'); - const useColor = shouldColorStatusMarkers( - commandHostEnvironment().env, - Boolean(process.stderr.isTTY), - ); + const useColor = shouldColorStatusMarkers(env, Boolean(process.stderr.isTTY)); const data = result as Record; const status = typeof data.status === 'string' ? data.status : 'unknown'; const lines = [`Doctor: ${status}`]; @@ -221,7 +219,8 @@ export const managementCliOutputFormatters = { shutdown: resultOutput(shutdownCliOutput), devices: resultOutput(devicesCliOutput), capabilities: resultOutput(capabilitiesCliOutput), - doctor: ({ result, progress }) => doctorCliOutput(result as CommandRequestResult, progress), + doctor: ({ result, env, progress }) => + doctorCliOutput(result as CommandRequestResult, env, progress), apps: ({ input, result }) => appsCliOutput({ result: result as Parameters[0]['result'], diff --git a/src/commands/observability/index.test.ts b/src/commands/observability/index.test.ts index cd35fe131b..61e327660d 100644 --- a/src/commands/observability/index.test.ts +++ b/src/commands/observability/index.test.ts @@ -96,6 +96,7 @@ describe('observability command interface', () => { test('formats events as a compact human timeline', () => { const output = observabilityCliOutputFormatters.events({ + env: process.env, input: {}, result: { path: '/tmp/session/events.ndjson', @@ -142,6 +143,7 @@ describe('observability command interface', () => { test('formats empty events page with a readable message', () => { const output = observabilityCliOutputFormatters.events({ + env: process.env, input: {}, result: { path: '/tmp/session/events.ndjson', cursor: '0', limit: 100, events: [] }, }); diff --git a/src/commands/output-common.ts b/src/commands/output-common.ts index 906c57c019..ceee02791f 100644 --- a/src/commands/output-common.ts +++ b/src/commands/output-common.ts @@ -1,4 +1,5 @@ import { readCommandMessage, readResponseWarnings } from '@agent-device/kernel/success-text'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { CommandProgressState } from './command-progress.ts'; import type { CliOutput } from './command-contract.ts'; @@ -12,6 +13,8 @@ export type CliOutputFormatterParams = { * asked for no progress at all. */ progress?: CommandProgressState; + /** The caller's host environment; a formatter that styles output reads color from it. */ + env: EnvMap; }; export type CliOutputFormatter = ( diff --git a/src/commands/recording/runtime/contact-sheet.test.ts b/src/commands/recording/runtime/contact-sheet.test.ts index c3bb39dbc7..1c773e2362 100644 --- a/src/commands/recording/runtime/contact-sheet.test.ts +++ b/src/commands/recording/runtime/contact-sheet.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createLiveTestHostEnvironment } from '@agent-device/test-kit'; import { beforeEach, test, vi } from 'vitest'; import { buildRecordingContactSheet, @@ -102,6 +102,7 @@ beforeEach(() => { test('record contact-sheet defaults to the sheet beside the recording it was given', async () => { const result = await device().recording.contactSheet({ + host: createLiveTestHostEnvironment(), video: { kind: 'path', path: '/tmp/recording.mp4' }, }); @@ -114,6 +115,7 @@ test('record contact-sheet defaults to the sheet beside the recording it was giv test('record contact-sheet passes the pixel budget and honours an explicit output', async () => { await device().recording.contactSheet({ + host: createLiveTestHostEnvironment(), video: { kind: 'path', path: '/tmp/recording.mp4' }, out: { kind: 'path', path: '/tmp/named/sheet.png' }, changedPixelThreshold: 0.2, @@ -127,6 +129,7 @@ test('record contact-sheet passes the pixel budget and honours an explicit outpu test('record contact-sheet reports the caller-visible path of a downloaded artifact', async () => { const result = await device().recording.contactSheet({ + host: createLiveTestHostEnvironment(), video: { kind: 'uploadedArtifact', id: 'rec-1' }, out: { kind: 'downloadableArtifact' }, }); @@ -139,6 +142,7 @@ test('record contact-sheet requires an output when the recording has no caller-s await assert.rejects( () => device().recording.contactSheet({ + host: createLiveTestHostEnvironment(), video: { kind: 'uploadedArtifact', id: 'rec-1' }, }), /needs an output path/, @@ -154,6 +158,7 @@ test('record contact-sheet releases a downloaded recording once the sheet fails' await assert.rejects( () => device().recording.contactSheet({ + host: createLiveTestHostEnvironment(), video: { kind: 'uploadedArtifact', id: 'rec-1' }, out: { kind: 'downloadableArtifact' }, }), @@ -168,6 +173,7 @@ test('record contact-sheet refuses a change threshold that is not a share of the await assert.rejects( () => device().recording.contactSheet({ + host: createLiveTestHostEnvironment(), video: { kind: 'path', path: '/tmp/recording.mp4' }, changedPixelThreshold, }), @@ -184,6 +190,7 @@ test('record contact-sheet file paths are policy-gated on both sides', async () await assert.rejects( () => device(restrictedCommandPolicy()).recording.contactSheet({ + host: createLiveTestHostEnvironment(), video: { kind: 'path', path: '/tmp/recording.mp4' }, }), /Local video paths are not allowed by command policy/, @@ -191,6 +198,7 @@ test('record contact-sheet file paths are policy-gated on both sides', async () await assert.rejects( () => device(localCommandPolicy({ allowLocalOutputPaths: false })).recording.contactSheet({ + host: createLiveTestHostEnvironment(), video: { kind: 'path', path: '/tmp/recording.mp4' }, out: { kind: 'path', path: '/tmp/sheet.png' }, }), @@ -204,6 +212,7 @@ test('a failed sheet releases the output it reserved', async () => { await assert.rejects( () => device().recording.contactSheet({ + host: createLiveTestHostEnvironment(), video: { kind: 'path', path: '/tmp/recording.mp4' }, }), /decoder refused the clip/, @@ -215,7 +224,11 @@ test('a failed sheet releases the output it reserved', async () => { test('record contact-sheet needs a recording to read', async () => { await assert.rejects( - () => device().recording.contactSheet({ video: undefined as never }), + () => + device().recording.contactSheet({ + host: createLiveTestHostEnvironment(), + video: undefined as never, + }), /requires a recording/, ); }); diff --git a/src/commands/recording/runtime/contact-sheet.ts b/src/commands/recording/runtime/contact-sheet.ts index 826036fc72..b01fe57015 100644 --- a/src/commands/recording/runtime/contact-sheet.ts +++ b/src/commands/recording/runtime/contact-sheet.ts @@ -5,8 +5,8 @@ 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 { HostEnvironment } from '@agent-device/kernel/source-value'; import type { ArtifactDescriptor, FileInputRef, @@ -18,6 +18,8 @@ import type { RuntimeCommand } from '../../runtime-types.ts'; import { reserveCommandOutput, resolveCommandInput } from '../../io-policy.ts'; export type RecordingContactSheetCommandOptions = CommandContext & { + /** The caller's host; the ffmpeg probe and sheet draw spawn through it. */ + host: HostEnvironment; /** The exported recording to read. A file the caller already holds, not a live capture. */ video: FileInputRef; out?: FileOutputRef; @@ -60,7 +62,7 @@ export const contactSheetCommand: RuntimeCommand< artifactType: 'screen-recording-contact-sheet', }); const sheet = await buildRecordingContactSheet({ - host: commandHostEnvironment(), + host: options.host, videoPath: video.path, outputPath: output.path, maxPixels: runtime.policy.maxImagePixels, diff --git a/src/commands/settle-output.ts b/src/commands/settle-output.ts index 0bee80056c..8dc06fe0c0 100644 --- a/src/commands/settle-output.ts +++ b/src/commands/settle-output.ts @@ -28,8 +28,8 @@ type SettleTextView = { /** A formatter's own success line plus the response's settled diff. */ function messageWithSettledNotes(formatter: CliOutputFormatter): CliOutputFormatter { - return ({ input, result }) => { - const output = formatter({ input, result }); + return (params) => { + const output = formatter(params); return output instanceof Promise ? output.then(withSettledNotes) : withSettledNotes(output); }; } diff --git a/src/commands/system/index.test.ts b/src/commands/system/index.test.ts index acb6a3c78f..96b568d555 100644 --- a/src/commands/system/index.test.ts +++ b/src/commands/system/index.test.ts @@ -126,6 +126,7 @@ describe('system command interface', () => { test('back CLI output renders the settled observation', () => { const output = systemCliOutputFormatters.back({ + env: process.env, input: {}, result: { action: 'back', @@ -157,6 +158,7 @@ describe('system command interface', () => { test('appstate CLI output renders the Apple session app from the runner read', () => { const output = systemCliOutputFormatters.appstate({ + env: process.env, input: {}, result: { platform: 'ios', @@ -179,6 +181,7 @@ describe('system command interface', () => { test('appstate CLI output falls back to the bundle id and omits absent lines', () => { const output = systemCliOutputFormatters.appstate({ + env: process.env, input: {}, result: { platform: 'ios', @@ -196,6 +199,7 @@ describe('system command interface', () => { test('appstate CLI output renders unknown when the Apple session carries no app identity', () => { const output = systemCliOutputFormatters.appstate({ + env: process.env, input: {}, result: { platform: 'ios', surface: 'app' }, }); @@ -204,6 +208,7 @@ describe('system command interface', () => { test('appstate CLI output renders the Android foreground app and activity', () => { const output = systemCliOutputFormatters.appstate({ + env: process.env, input: {}, result: { platform: 'android', @@ -218,6 +223,7 @@ describe('system command interface', () => { // macOS appState is never admitted by the Apple runtime facts, so the live // daemon always answers from the session record: no State line. const output = systemCliOutputFormatters.appstate({ + env: process.env, input: {}, result: { platform: 'macos', @@ -238,6 +244,7 @@ describe('system command interface', () => { test('appstate CLI output renders the HarmonyOS foreground app and activity', () => { const output = systemCliOutputFormatters.appstate({ + env: process.env, input: {}, result: { platform: 'harmonyos', diff --git a/src/core/__tests__/interactors.test.ts b/src/core/__tests__/interactors.test.ts index e259dcf246..56e730b6bf 100644 --- a/src/core/__tests__/interactors.test.ts +++ b/src/core/__tests__/interactors.test.ts @@ -8,6 +8,7 @@ import { setActiveProviderDeviceRuntimes } from '../../provider-device-runtime.t import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import { getInteractor, getLocalInteractor } from '../interactors.ts'; +import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; const iosSimulator: DeviceInfo = { platform: 'apple', @@ -73,7 +74,10 @@ test('provider device interactor receives runner context from core resolution', }; setActiveProviderDeviceRuntimes([runtime]); - assert.equal(await getInteractor(device, runnerContext), interactor); + assert.equal( + await getInteractor(device, runnerContext, createLiveTestHostEnvironment()), + interactor, + ); }); test('local interactor resolution ignores an ambient provider owner', async () => { @@ -88,9 +92,13 @@ test('local interactor resolution ignores an ambient provider owner', async () = }; setActiveProviderDeviceRuntimes([provider]); - const localInteractor = await getLocalInteractor(iosSimulator, { - appBundleId: 'com.example.app', - }); + const localInteractor = await getLocalInteractor( + iosSimulator, + { + appBundleId: 'com.example.app', + }, + createLiveTestHostEnvironment(), + ); assert.notEqual(localInteractor, providerInteractor); }); @@ -114,7 +122,11 @@ test('ios scroll sends a single fused scroll command and reports planned pixels' } throw new Error(`Unexpected runner command: ${command.command}`); }); - const interactor = await getInteractor(iosSimulator, { appBundleId: 'com.example.app' }); + const interactor = await getInteractor( + iosSimulator, + { appBundleId: 'com.example.app' }, + createLiveTestHostEnvironment(), + ); const result = await withRunner(() => interactor.scroll('down', { pixels: 120 })); // The common iOS scroll path issues exactly one lifecycle command and NO 'interactionFrame'. @@ -156,7 +168,11 @@ test('ios amount-based scroll recomputes pixels from the runner reference frame' } throw new Error(`Unexpected runner command: ${command.command}`); }); - const interactor = await getInteractor(iosSimulator, { appBundleId: 'com.example.app' }); + const interactor = await getInteractor( + iosSimulator, + { appBundleId: 'com.example.app' }, + createLiveTestHostEnvironment(), + ); const result = await withRunner(() => interactor.scroll('down', { amount: 0.5 })); assert.deepEqual(commands, [ @@ -184,7 +200,11 @@ test('tvOS scroll sends only a remotePress command (behavior unchanged)', async commands.push(withoutRunnerCommandId(command)); return {}; }); - const interactor = await getInteractor(tvOsSimulator, { appBundleId: 'com.example.app' }); + const interactor = await getInteractor( + tvOsSimulator, + { appBundleId: 'com.example.app' }, + createLiveTestHostEnvironment(), + ); await withRunner(() => interactor.scroll('down'), tvOsSimulator); @@ -199,7 +219,11 @@ test('tvOS back navigates focus via the remote Menu button, not a coordinate tap commands.push(withoutRunnerCommandId(command)); return {}; }); - const interactor = await getInteractor(tvOsSimulator, { appBundleId: 'com.example.app' }); + const interactor = await getInteractor( + tvOsSimulator, + { appBundleId: 'com.example.app' }, + createLiveTestHostEnvironment(), + ); await withRunner(() => interactor.back(), tvOsSimulator); @@ -215,7 +239,11 @@ test('tvOS home navigates focus via the remote Home button', async () => { commands.push(withoutRunnerCommandId(command)); return {}; }); - const interactor = await getInteractor(tvOsSimulator, { appBundleId: 'com.example.app' }); + const interactor = await getInteractor( + tvOsSimulator, + { appBundleId: 'com.example.app' }, + createLiveTestHostEnvironment(), + ); await withRunner(() => interactor.home!(), tvOsSimulator); @@ -230,7 +258,11 @@ test('iOS back keeps the in-app navigation path, not the tvOS remote', async () commands.push(withoutRunnerCommandId(command)); return {}; }); - const interactor = await getInteractor(iosSimulator, { appBundleId: 'com.example.app' }); + const interactor = await getInteractor( + iosSimulator, + { appBundleId: 'com.example.app' }, + createLiveTestHostEnvironment(), + ); await withRunner(() => interactor.back()); @@ -240,7 +272,11 @@ test('iOS back keeps the in-app navigation path, not the tvOS remote', async () test('ios scroll rejects non-positive amount before sending any runner command', async () => { mockRunAppleRunnerCommand.mockImplementation(async () => ({})); - const interactor = await getInteractor(iosSimulator, { appBundleId: 'com.example.app' }); + const interactor = await getInteractor( + iosSimulator, + { appBundleId: 'com.example.app' }, + createLiveTestHostEnvironment(), + ); await assert.rejects( () => withRunner(() => interactor.scroll('down', { amount: 0 })), @@ -259,7 +295,11 @@ test('ios scroll without reference dims derives pixels from endpoint travel', as } throw new Error(`Unexpected runner command: ${command.command}`); }); - const interactor = await getInteractor(iosSimulator, { appBundleId: 'com.example.app' }); + const interactor = await getInteractor( + iosSimulator, + { appBundleId: 'com.example.app' }, + createLiveTestHostEnvironment(), + ); const result = await withRunner(() => interactor.scroll('down', { pixels: 120 })); const pixels = @@ -274,7 +314,11 @@ test('ios coordinate fill uses replacement text-entry mode', async () => { commands.push(withoutRunnerCommandId(command)); return {}; }); - const interactor = await getInteractor(iosSimulator, { appBundleId: 'com.example.app' }); + const interactor = await getInteractor( + iosSimulator, + { appBundleId: 'com.example.app' }, + createLiveTestHostEnvironment(), + ); await withRunner(() => interactor.fill(120, 240, 'hunter2')); @@ -297,7 +341,11 @@ test('ios type uses verified append text-entry mode', async () => { commands.push(withoutRunnerCommandId(command)); return { textEntryRoute: 'synthesized-first-responder' }; }); - const interactor = await getInteractor(iosSimulator, { appBundleId: 'com.example.app' }); + const interactor = await getInteractor( + iosSimulator, + { appBundleId: 'com.example.app' }, + createLiveTestHostEnvironment(), + ); const result = await withRunner(() => interactor.type('hello', 25)); @@ -312,3 +360,48 @@ test('ios type uses verified append text-entry mode', async () => { ]); assert.deepEqual(result, { textEntryRoute: 'synthesized-first-responder' }); }); + +test('two runtimes create interactors that route the same macOS device by their own host (#3794)', async () => { + const macOsDevice: DeviceInfo = { + platform: 'apple', + appleOs: 'macos', + id: 'host-mac', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, + }; + // Runtime A's host opted the host into the native app backend; runtime B's host did not. + // One process, one ambient environment: only the per-call host may decide the backend. + const nativeRuntimeHost = { + env: { ...process.env, AGENT_DEVICE_MACOS_APP_BACKEND: 'native' }, + cwd: process.cwd(), + }; + const xctestRuntimeHost = { + env: { ...process.env, AGENT_DEVICE_MACOS_APP_BACKEND: '' }, + cwd: process.cwd(), + }; + const nativeInteractor = await getLocalInteractor(macOsDevice, {}, nativeRuntimeHost); + const xctestInteractor = await getLocalInteractor(macOsDevice, {}, xctestRuntimeHost); + + const commands: RunnerCommand[] = []; + mockRunAppleRunnerCommand.mockImplementation(async (_device, command) => { + commands.push(withoutRunnerCommandId(command)); + return {}; + }); + + await withRunner(async () => { + // Runtime A: the native backend refuses a runner-only back at admission and never dispatches. + await assert.rejects( + nativeInteractor.back(), + (error: unknown) => + error instanceof AppError && + error.code === 'UNSUPPORTED_OPERATION' && + error.details?.reason === 'unsupported-device-backend', + ); + // Runtime B: the same call on the same device routes through the XCTest runner. + await xctestInteractor.back(); + }, macOsDevice); + + assert.deepEqual(commands, [{ command: 'backInApp', appBundleId: undefined }]); +}); diff --git a/src/core/interactors-app-events.test.ts b/src/core/interactors-app-events.test.ts index 51fe7f78ab..0e7fa0a111 100644 --- a/src/core/interactors-app-events.test.ts +++ b/src/core/interactors-app-events.test.ts @@ -8,8 +8,9 @@ import { ANDROID_EMULATOR, IOS_DEVICE, MACOS_DEVICE, - mkdtempForTest, bindTestCommandHost, + createLiveTestHostEnvironment, + mkdtempForTest, } from '@agent-device/test-kit'; import { getInteractor } from './interactors.ts'; @@ -19,7 +20,8 @@ async function triggerEvent(device: DeviceInfo, eventUrl: string, appBundleId?: const operations = bindAdmittedLocalInteractorOperations({ device, signal: new AbortController().signal, - resolveInteractor: async (owned, runner) => await getInteractor(owned, runner), + resolveInteractor: async (owned, runner) => + await getInteractor(owned, runner, createLiveTestHostEnvironment()), facts: { triggerAppEvent: { available: true } }, }); await operations.triggerAppEvent?.({ diff --git a/src/core/interactors.ts b/src/core/interactors.ts index 673707d9ae..f36e068f14 100644 --- a/src/core/interactors.ts +++ b/src/core/interactors.ts @@ -1,5 +1,6 @@ import { publicPlatformString, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { getProviderDeviceInteractor, isActiveProviderDevice } from '../provider-device-runtime.ts'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; import { getPlugin } from './platform-plugin-registry.ts'; @@ -12,6 +13,7 @@ registerBuiltinPlatformPlugins(); export async function getInteractor( device: DeviceInfo, runnerContext: RunnerContext, + host: HostEnvironment, ): Promise { if (isActiveProviderDevice(device)) { const providerInteractor = getProviderDeviceInteractor(device, runnerContext); @@ -23,22 +25,24 @@ export async function getInteractor( ); } - return await getLocalInteractor(device, runnerContext); + return await getLocalInteractor(device, runnerContext, host); } /** * Resolves a local platform interactor after its runtime owner has already been selected. * Lifecycle bindings use this exact-owner path so an ambient provider request scope cannot - * redirect a local binding to a provider interactor. + * redirect a local binding to a provider interactor. `host` is the calling runtime's host; + * plugins are shared across runtimes, so it arrives per call. */ export async function getLocalInteractor( device: DeviceInfo, runnerContext: RunnerContext, + host: HostEnvironment, ): Promise { // Byte-identical replacement for the former per-platform switch: each plugin's // `createInteractor` is the SAME lazy dynamic import + factory call the switch // arm performed, and `getPlugin` throws the SAME `UNSUPPORTED_PLATFORM` AppError // the switch default threw. Registry exhaustiveness (BuiltinPluginsCoverAllPlatforms) // guarantees every leaf `Platform` resolves. - return await getPlugin(device.platform).createInteractor(device, runnerContext); + return await getPlugin(device.platform).createInteractor(device, runnerContext, host); } diff --git a/src/core/interactors/register-builtins.ts b/src/core/interactors/register-builtins.ts index 7d53d2ab4d..0451de7591 100644 --- a/src/core/interactors/register-builtins.ts +++ b/src/core/interactors/register-builtins.ts @@ -32,9 +32,9 @@ const linuxPlugin = { // Declares the platform-gated request provider resolver the linux family owns (the // linux tool provider, formerly gated by `device.platform === 'linux'`). providers: { platformGatedResolvers: ['linuxToolProvider'] }, - createInteractor: async () => { + createInteractor: async (_device, _runner, host) => { const { createLinuxInteractor } = await import('@agent-device/platform-linux/interactor'); - return createLinuxInteractor(); + return createLinuxInteractor(host); }, } as const satisfies PlatformPlugin; @@ -44,9 +44,9 @@ const webPlugin = { // Declares the platform-gated request provider resolver the web family owns (the web // provider, formerly gated by `device.platform === 'web'`). providers: { platformGatedResolvers: ['webProvider'] }, - createInteractor: async () => { + createInteractor: async (_device, _runner, host) => { const { createWebInteractor } = await import('@agent-device/platform-web/interactor'); - return await createWebInteractor(); + return await createWebInteractor(host); }, } as const satisfies PlatformPlugin; diff --git a/src/daemon-composition-web-cleanup.test.ts b/src/daemon-composition-web-cleanup.test.ts index bb0c9955a7..aef948fda9 100644 --- a/src/daemon-composition-web-cleanup.test.ts +++ b/src/daemon-composition-web-cleanup.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { beforeEach, test, vi } from 'vitest'; -import { installFakeManagedAgentBrowser, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + createLiveTestHostEnvironment, + installFakeManagedAgentBrowser, + mkdtempForTestSync, +} from '@agent-device/test-kit'; const { cleanupManagedAgentBrowserOrphansMock } = vi.hoisted(() => ({ cleanupManagedAgentBrowserOrphansMock: vi.fn(), @@ -15,7 +19,11 @@ vi.mock('@agent-device/platform-web', async (importOriginal) => { }; }); -import { cleanupWebBrowserOrphansForDaemonStartup } from './daemon-composition.ts'; +import { createDaemonStartupWebOrphanCleanup } from './daemon-composition.ts'; + +const cleanupWebBrowserOrphansForDaemonStartup = createDaemonStartupWebOrphanCleanup( + createLiveTestHostEnvironment(), +); const mockCleanupManagedAgentBrowserOrphans = vi.mocked(cleanupManagedAgentBrowserOrphansMock); @@ -35,6 +43,7 @@ test('daemon-startup web cleanup passes open web sessions to the reaper', async assert.equal(mockCleanupManagedAgentBrowserOrphans.mock.calls.length, 1); assert.equal(mockCleanupManagedAgentBrowserOrphans.mock.calls[0]?.[1], 'daemon-startup'); assert.deepEqual(mockCleanupManagedAgentBrowserOrphans.mock.calls[0]?.[2], { + env: process.env, openWebSessionNames: ['web-session'], }); } finally { diff --git a/src/daemon-composition.test.ts b/src/daemon-composition.test.ts index 629a3f2679..4890bb61d6 100644 --- a/src/daemon-composition.test.ts +++ b/src/daemon-composition.test.ts @@ -1,6 +1,9 @@ import { afterEach, expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import type { Interactor } from '@agent-device/contracts/interactor-types'; +import { AppError } from '@agent-device/kernel/errors'; +import { withAppleRunnerProvider } from '@agent-device/platform-apple/runner'; import { IOS_SIMULATOR, mkdtempForTestSync, @@ -155,3 +158,75 @@ test('a composed daemon registers the Apple runner and Android helper state, one expect(inFirst).not.toBe(inSecond); expect(firstAndroid!.run(() => helperRuntimeSlot(slotKey, () => ({})))).toBe(inFirst); }); + +test('the composed daemon route configures interactors from its host, not from the process (#3794)', async () => { + const macOsDevice: DeviceInfo = { + platform: 'apple', + appleOs: 'macos', + id: 'host-mac', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, + }; + const compose = async (name: string, backend: string) => { + const baseDir = mkdtempForTestSync(`agent-device-daemon-composition-host-config-${name}-`); + // The snapshot disagrees with the process on purpose: it is written before the stub below + // lands in some orders and after in others, so pin the host value explicitly after the spread. + return await createDaemonRuntimeDeps({ + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: baseDir, + AGENT_DEVICE_MACOS_APP_BACKEND: backend, + }, + }), + baseDir, + sessionsDir: `${baseDir}/sessions`, + assertShutdownAllowed: undefined, + resolveSessionDir: (sessionId) => `${baseDir}/sessions/${sessionId}`, + resolveSessionArtifacts: () => { + throw new Error('unused'); + }, + openWebSessionNames: () => [], + }); + }; + + // The process claims the native backend for everyone. The runtime host says otherwise; only an + // interactor configured from the ambient process would honour the lie. + vi.stubEnv('AGENT_DEVICE_MACOS_APP_BACKEND', 'native'); + const xctestDeps = await compose('xctest', ''); + const xctestInteractor = await xctestDeps.interactorResolution.resolve(macOsDevice, {}); + const commands: string[] = []; + await withAppleRunnerProvider( + async (_device, command) => { + commands.push(String(command.command)); + return {}; + }, + { deviceId: macOsDevice.id }, + async () => { + await xctestInteractor.back(); + }, + ); + expect(commands).toEqual(['backInApp']); + + // And the opposite: the process says plain XCTest while this runtime's host opted into the + // native backend — the daemon route must refuse a runner-only back before dispatching it. + vi.stubEnv('AGENT_DEVICE_MACOS_APP_BACKEND', ''); + const nativeDeps = await compose('native', 'native'); + const nativeInteractor = await nativeDeps.interactorResolution.resolve(macOsDevice, {}); + await withAppleRunnerProvider( + async () => { + throw new Error('the native backend must not dispatch a runner-only back'); + }, + { deviceId: macOsDevice.id }, + async () => { + await expect(nativeInteractor.back()).rejects.toSatisfy( + (error: unknown) => + error instanceof AppError && + error.code === 'UNSUPPORTED_OPERATION' && + error.details?.reason === 'unsupported-device-backend', + ); + }, + ); +}); diff --git a/src/daemon-composition.ts b/src/daemon-composition.ts index 94f1b5aa39..c2367e27bf 100644 --- a/src/daemon-composition.ts +++ b/src/daemon-composition.ts @@ -27,8 +27,8 @@ import { createRequestPlatformProviders, } from './platform-runtime.ts'; import { createHostDiagnostics } from './platform-runtime-host-diagnostics.ts'; -import { platformResourceCleanup } from './platform-runtime-resource-cleanup.ts'; -import { platformDaemonLifecycleOwners } from './platform-runtime-daemon-lifecycle.ts'; +import { createPlatformResourceCleanup } from './platform-runtime-resource-cleanup.ts'; +import { createPlatformDaemonLifecycleOwners } from './platform-runtime-daemon-lifecycle.ts'; import { createOwnedProcessRecordStore, type OwnedProcessRecordStore, @@ -36,6 +36,8 @@ import { import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { AgentDeviceRuntimeConfig } from '@agent-device/contracts/runtime-contract'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import type { RunnerContext } from '@agent-device/contracts/interactor-types'; import type { ComposeDaemonRuntimeDeps, DaemonRuntimeDeps } from '@agent-device/daemon'; /** @@ -43,20 +45,24 @@ import type { ComposeDaemonRuntimeDeps, DaemonRuntimeDeps } from '@agent-device/ * only after this process holds the daemon lock, and it never fails startup: a cleanup the host * refuses is a diagnostic, not a reason to keep the next daemon from serving. */ -export async function cleanupWebBrowserOrphansForDaemonStartup(params: { +export function createDaemonStartupWebOrphanCleanup( + host: HostEnvironment, +): (params: { stateDir: string; openWebSessionNames: readonly string[]; ownedProcessRecords?: OwnedProcessRecordStore; -}): Promise { - try { - await platformDaemonLifecycleOwners.cleanupManagedWebOrphans(params); - } catch (error) { - emitDiagnostic({ - level: 'warn', - phase: 'web_agent_browser_orphan_cleanup_failed', - data: { error: error instanceof Error ? error.message : String(error) }, - }); - } +}) => Promise { + return async (params) => { + try { + await createPlatformDaemonLifecycleOwners(host).cleanupManagedWebOrphans(params); + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'web_agent_browser_orphan_cleanup_failed', + data: { error: error instanceof Error ? error.message : String(error) }, + }); + } + }; } /** @@ -72,7 +78,7 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( input, ): Promise => { const { - host: { env }, + host, baseDir, sessionsDir, assertShutdownAllowed, @@ -80,19 +86,20 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( resolveSessionArtifacts, openWebSessionNames, } = input; + const { env } = host; const ownedProcessRecords = createOwnedProcessRecordStore({ stateDir: baseDir, sessionsDir, resolveSessionDir, }); - const hostDiagnostics = createHostDiagnostics(env); + const hostDiagnostics = createHostDiagnostics(host); const providerComposition = await createDaemonProviderRuntimeComposition(env); // The registry has set up every plugin. Until this function returns, nothing else owns its // cleanup, so any later failure closes it here; success transfers ownership to the daemon. try { const providerDeviceRuntimes = [...providerComposition.runtimes]; const deviceRuntimeGateway = createPlatformRuntimeGateway({ - env, + host, assertShutdownAllowed, providerRuntimes: providerDeviceRuntimes, providerModules: providerComposition.platformModules, @@ -109,13 +116,14 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( providerRuntimeProviders.appleRunnerScreenRecordingTransport, }, defaultWebProvider: { + host, stateDir: baseDir, openWebSessionNames, ownedProcessRecords, }, }); const deviceInventoryGateways = await createPlatformDeviceInventoryGateways( - env, + host, providerRuntimeProviders.deviceInventorySource, ); // Root composition of the platform-services port and the owner-scoped claim-recovery gateway. @@ -131,12 +139,12 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( await import('@agent-device/platform-android/adb-host'); return Object.freeze({ runtimeStates: Object.freeze([ - createAppleRunnerRuntimeState(), + createAppleRunnerRuntimeState(host), createAndroidHelperRuntimeState(), ]), ownedProcessRecords, hostDiagnostics, - ownerLifecycle: platformDaemonLifecycleOwners, + ownerLifecycle: createPlatformDaemonLifecycleOwners(host), shutdownProviderRuntimes: providerComposition.shutdown, pluginInventory: providerComposition.inventory, leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, @@ -144,13 +152,16 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( recoverExpiredLease: providerRuntimeProviders.recoverExpiredLease, recoverableProviderIds: providerRuntimeProviders.recoverableProviderIds, deviceRuntimeGateway, - platformResourceCleanup, - claimRecoveryGateway: createClaimRecoveryGatewayFor(env), - interactorResolution: Object.freeze({ resolve: getInteractor }), + platformResourceCleanup: createPlatformResourceCleanup(host), + claimRecoveryGateway: createClaimRecoveryGatewayFor(host), + interactorResolution: Object.freeze({ + resolve: async (device: DeviceInfo, runnerContext: RunnerContext) => + await getInteractor(device, runnerContext, host), + }), providerDeviceAdmission: Object.freeze({ isActive: (device: DeviceInfo) => isActiveProviderDevice(device), }), - cleanupManagedWebOrphans: cleanupWebBrowserOrphansForDaemonStartup, + cleanupManagedWebOrphans: createDaemonStartupWebOrphanCleanup(host), requestRouter: Object.freeze({ requestPlatformProviders, deviceInventoryGateways, diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts index 1850b324cb..c2beb39749 100644 --- a/src/mcp/command-tools.ts +++ b/src/mcp/command-tools.ts @@ -3,6 +3,7 @@ import type { AgentDeviceClient } from '../client/client-types.ts'; import type { CliOutput, CommandMetadata, JsonSchema } from '../commands/command-contract.ts'; import type { CommandExecutionResult } from '../commands/command-surface.ts'; import { RESPONSE_LEVELS, type ResponseLevel } from '@agent-device/kernel/contracts'; +import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; import { formatCliOutput, isParseableOutputCommand } from '../commands/cli-output.ts'; import { findCommandMetadata, @@ -185,7 +186,11 @@ export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): C const config = readMcpToolConfig(resolvedInput); const commandInput = stripMcpConfigFields(resolvedInput); const pinnedInput = refPins.pinInput(name, commandInput, config.client.stateDir); - const client = await createClient(deps, config.client); + // One host per tool call: the client's ensuring transport and the output formatter resolve + // the daemon and the styling from the same host the executor captured (#3794). + const { captureHostEnvironment } = await import('@agent-device/host-kit/process'); + const host = captureHostEnvironment(process); + const client = await createClient(deps, config.client, host); try { const result = await (deps.runCommand ?? runCommand)(client, name, pinnedInput); refPins.mergeCommandResult(name, result, config.client.stateDir, commandInput.session); @@ -201,6 +206,7 @@ export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): C name, input: commandInput, result, + env: host.env, outputFormat: config.outputFormat, responseLevel: config.client.responseLevel, }), @@ -245,13 +251,11 @@ export const commandToolExecutor = createCommandToolExecutor(); async function createClient( deps: CommandToolExecutorDeps, config: AgentDeviceClientConfig, + host: HostEnvironment, ): Promise { if (deps.createClient) return await deps.createClient(config); const { createAgentDeviceClient } = await import('../agent-device-client.ts'); const { createEnsuredDaemonTransport } = await import('../daemon-client/daemon-client.ts'); - const { captureHostEnvironment } = await import('@agent-device/host-kit/process'); - // One host per tool call: the ensuring transport and the client resolve the same daemon from it. - const host = captureHostEnvironment(process); // One client per tool call, so each call ensures the daemon it is about to use. const ensuring = createEnsuredDaemonTransport({ host }); return createAgentDeviceClient(config, { @@ -503,6 +507,7 @@ async function renderToolText(params: { name: CommandName; input: Record; result: CommandExecutionResult; + env: EnvMap; outputFormat: McpOutputFormat; responseLevel?: ResponseLevel; }): Promise { @@ -521,6 +526,7 @@ async function renderToolText(params: { name: params.name, input: params.input, result: params.result, + env: params.env, }); if (typeof cliOutput?.text !== 'string') return renderJsonText(cliOutput?.data ?? params.result); return toolTextWithWarnings(params.name, cliOutput); diff --git a/src/platform-runtime-android-adb-binding.test.ts b/src/platform-runtime-android-adb-binding.test.ts index 6f1fd2c787..8a872b8b9c 100644 --- a/src/platform-runtime-android-adb-binding.test.ts +++ b/src/platform-runtime-android-adb-binding.test.ts @@ -26,6 +26,7 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => { }); import { createPlatformRuntimeGateway } from './platform-runtime.ts'; +import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; const sessionArtifacts = { sessionsDir: '/sessions', @@ -51,7 +52,10 @@ const device: DeviceInfo = { }; test('the composed gateway lists Android apps from inside the platform package', async () => { - const gateway = createPlatformRuntimeGateway({ env: process.env, ...sessionArtifacts }); + const gateway = createPlatformRuntimeGateway({ + host: createLiveTestHostEnvironment(), + ...sessionArtifacts, + }); const binding = await gateway.bind({ device, intent: { kind: 'ordinary' }, scope }); await expect(binding.operations.listApps?.({ device, filter: 'all' })).resolves.toEqual([ @@ -76,7 +80,10 @@ test('the composed gateway lists Android apps from inside the platform package', }); test('the composed gateway reads Android app state from inside the platform package', async () => { - const gateway = createPlatformRuntimeGateway({ env: process.env, ...sessionArtifacts }); + const gateway = createPlatformRuntimeGateway({ + host: createLiveTestHostEnvironment(), + ...sessionArtifacts, + }); const binding = await gateway.bind({ device, intent: { kind: 'ordinary' }, scope }); await expect(binding.operations.appState?.()).resolves.toEqual({ diff --git a/src/platform-runtime-android-deployment-executor.ts b/src/platform-runtime-android-deployment-executor.ts index 6e9eb128f8..7ccee6f987 100644 --- a/src/platform-runtime-android-deployment-executor.ts +++ b/src/platform-runtime-android-deployment-executor.ts @@ -3,7 +3,6 @@ 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. */ @@ -20,7 +19,7 @@ export function createAndroidAppDeploymentExecutor( const { prepareAndroidInstallArtifact } = await loadAndroidMechanics(); return await prepareAndroidInstallArtifact(input.source, { ...options, - environment: commandHostEnvironment().env, + environment: env, }); }, resolveAppPackage: async (device, app, options) => { diff --git a/src/platform-runtime-app-log-output.test.ts b/src/platform-runtime-app-log-output.test.ts index d700a0a432..dc5b1d6f87 100644 --- a/src/platform-runtime-app-log-output.test.ts +++ b/src/platform-runtime-app-log-output.test.ts @@ -4,7 +4,11 @@ import path from 'node:path'; import { withCommandHost } from '@agent-device/host-kit/command'; import { afterEach, expect, test } from 'vitest'; import { openAppLogOutput, readAppLogOutputTail } from './platform-runtime-app-log-output.ts'; -import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + bindTestCommandHost, + createLiveTestHostEnvironment, + mkdtempForTestSync, +} from '@agent-device/test-kit'; bindTestCommandHost(); @@ -27,9 +31,13 @@ test('reads a bounded trusted app.log suffix and rejects paths outside sessions' await expect( readAppLogOutputTail(sessionsDir, path.join(root, 'outside', 'app.log'), 10), ).rejects.toThrow('outside the daemon-owned sessions directory'); - await expect(openAppLogOutput(sessionsDir, path.join(sessionDir, 'other.log'))).rejects.toThrow( - 'outside the daemon-owned sessions directory', - ); + await expect( + openAppLogOutput( + sessionsDir, + path.join(sessionDir, 'other.log'), + createLiveTestHostEnvironment(), + ), + ).rejects.toThrow('outside the daemon-owned sessions directory'); }); test('rejects a symlinked session directory that escapes the sessions root', async () => { @@ -41,15 +49,19 @@ test('rejects a symlinked session directory that escapes the sessions root', asy fs.mkdirSync(outside); fs.symlinkSync(outside, path.join(sessionsDir, 'linked')); await expect( - openAppLogOutput(sessionsDir, path.join(sessionsDir, 'linked', 'app.log')), + openAppLogOutput( + sessionsDir, + path.join(sessionsDir, 'linked', 'app.log'), + createLiveTestHostEnvironment(), + ), ).rejects.toThrow('resolves outside'); }); test('rejects a final app.log symlink before append and preserves the outside file', async () => { const fixture = finalSymlinkFixture('append'); - await expect(openAppLogOutput(fixture.sessionsDir, fixture.outputPath)).rejects.toThrow( - 'symbolic link', - ); + await expect( + openAppLogOutput(fixture.sessionsDir, fixture.outputPath, createLiveTestHostEnvironment()), + ).rejects.toThrow('symbolic link'); expect(fs.readFileSync(fixture.outsidePath, 'utf8')).toBe('outside-append'); }); @@ -80,7 +92,9 @@ test('rejects an asynchronous stream-open failure without an unhandled error', a const outputPath = path.join(sessionsDir, 'one', 'app.log'); fs.mkdirSync(outputPath, { recursive: true }); - await expect(openAppLogOutput(sessionsDir, outputPath)).rejects.toThrow('regular file'); + await expect( + openAppLogOutput(sessionsDir, outputPath, createLiveTestHostEnvironment()), + ).rejects.toThrow('regular file'); }); function finalSymlinkFixture(label: string) { @@ -109,7 +123,10 @@ test('redacts with the patterns of the command host, not the process', async () requestEnv: undefined, }, async () => { - const sink = await openAppLogOutput(sessionsDir, outputPath); + const sink = await openAppLogOutput(sessionsDir, outputPath, { + env: { AGENT_DEVICE_APP_LOG_REDACT_PATTERNS: 'secret-\\d+' }, + cwd: root, + }); await sink.write('token secret-42 leaked\n'); await sink[Symbol.asyncDispose](); }, diff --git a/src/platform-runtime-app-log-output.ts b/src/platform-runtime-app-log-output.ts index 70c4b3fe59..8d8e34ea5a 100644 --- a/src/platform-runtime-app-log-output.ts +++ b/src/platform-runtime-app-log-output.ts @@ -1,16 +1,17 @@ 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 type { HostEnvironment } from '@agent-device/kernel/source-value'; import { openVerifiedFileForAppend, openVerifiedFileForRead } from '@agent-device/host-kit/file'; import { requireManagedSessionArtifactPath } from '@agent-device/daemon-contracts/session-artifact-paths'; export async function openAppLogOutput( sessionsDir: string, pathname: string, + host: HostEnvironment, ): Promise { pathname = requireManagedOutputPath(sessionsDir, pathname); - ensureAppLogPath(pathname, commandHostEnvironment().env); + ensureAppLogPath(pathname, host.env); const descriptor = openVerifiedFileForAppend(pathname); let stream: fs.WriteStream; try { @@ -23,7 +24,7 @@ export async function openAppLogOutput( stream.on('error', (error) => { streamError ??= error; }); - const { env } = commandHostEnvironment(); + const { env } = host; const patterns = redactionPatterns(env); let pending = ''; let writes = Promise.resolve(); diff --git a/src/platform-runtime-apple-application-tools-close-alerts.test.ts b/src/platform-runtime-apple-application-tools-close-alerts.test.ts index 6dcbce5673..7142a8ddd1 100644 --- a/src/platform-runtime-apple-application-tools-close-alerts.test.ts +++ b/src/platform-runtime-apple-application-tools-close-alerts.test.ts @@ -28,7 +28,7 @@ beforeEach(() => { const finalization = { retainRunner: false, stateDir: '/tmp' } as const; test('close dismisses the automation alert of the closed macOS app', async () => { - await createAppleApplicationTools().dismissCloseAlerts(MACOS_DEVICE, { + await createAppleApplicationTools(process.env).dismissCloseAlerts(MACOS_DEVICE, { ...finalization, surface: 'app', appBundleId: 'com.apple.systempreferences', @@ -36,30 +36,34 @@ test('close dismisses the automation alert of the closed macOS app', async () => expect(runMacOsAlertAction).toHaveBeenCalledWith('dismiss', { bundleId: 'com.apple.systempreferences', + env: process.env, }); }); test('close of the frontmost-app surface dismisses the frontmost automation alert', async () => { - await createAppleApplicationTools().dismissCloseAlerts(MACOS_DEVICE, { + await createAppleApplicationTools(process.env).dismissCloseAlerts(MACOS_DEVICE, { ...finalization, surface: 'frontmost-app', appBundleId: 'com.apple.systempreferences', }); - expect(runMacOsAlertAction).toHaveBeenCalledWith('dismiss', { surface: 'frontmost-app' }); + expect(runMacOsAlertAction).toHaveBeenCalledWith('dismiss', { + surface: 'frontmost-app', + env: process.env, + }); }); test('close without a macOS app bundle dismisses with no target', async () => { - await createAppleApplicationTools().dismissCloseAlerts(MACOS_DEVICE, { + await createAppleApplicationTools(process.env).dismissCloseAlerts(MACOS_DEVICE, { ...finalization, surface: 'app', }); - expect(runMacOsAlertAction).toHaveBeenCalledWith('dismiss', {}); + expect(runMacOsAlertAction).toHaveBeenCalledWith('dismiss', { env: process.env }); }); test('close on a non-macOS device dismisses nothing', async () => { - await createAppleApplicationTools().dismissCloseAlerts(IOS_SIMULATOR, { + await createAppleApplicationTools(process.env).dismissCloseAlerts(IOS_SIMULATOR, { ...finalization, surface: 'app', appBundleId: 'com.example.app', diff --git a/src/platform-runtime-apple-application-tools.test.ts b/src/platform-runtime-apple-application-tools.test.ts index dd52966e66..414732110c 100644 --- a/src/platform-runtime-apple-application-tools.test.ts +++ b/src/platform-runtime-apple-application-tools.test.ts @@ -46,7 +46,7 @@ const macOsDevice: DeviceInfo = { test('the runner session prewarm forwards the request execution, signal, and error policy to the runner module', async () => { const signal = new AbortController().signal; - await createAppleApplicationTools().prewarmRunnerSession( + await createAppleApplicationTools(process.env).prewarmRunnerSession( iosSimulator, { requestId: 'req-1', logPath: '/tmp/daemon.log', verbose: true }, signal, @@ -68,7 +68,7 @@ test('the runner session prewarm forwards the request execution, signal, and err test('preparing the runner spends the requested budget on stale-bundle cleanup, build, health, and startup', async () => { const signal = new AbortController().signal; - const result = await createAppleApplicationTools().prepareRunner( + const result = await createAppleApplicationTools(process.env).prepareRunner( iosSimulator, { timeoutMs: 240_000, @@ -105,7 +105,7 @@ test('a macOS frontmost-app open resolves the identity of the frontmost applicat }); await expect( - createAppleApplicationTools().resolveOpenTarget(macOsDevice, { + createAppleApplicationTools(process.env).resolveOpenTarget(macOsDevice, { surface: 'frontmost-app', currentAppBundleId: 'com.apple.TextEdit', }), diff --git a/src/platform-runtime-apple-application-tools.ts b/src/platform-runtime-apple-application-tools.ts index b546344a86..75a22d237f 100644 --- a/src/platform-runtime-apple-application-tools.ts +++ b/src/platform-runtime-apple-application-tools.ts @@ -7,6 +7,7 @@ import type { PrepareAppleRunnerInput, } from '@agent-device/contracts/application-lifecycle-runtime'; import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { AppError } from '@agent-device/kernel/errors'; /** @@ -47,9 +48,9 @@ let retryModule: Promise | undefi const loadRetry = () => (retryModule ??= import('@agent-device/host-kit/retry')); /** Lazy Apple tools; the Apple package owns lifecycle sequencing around these primitives. */ -export function createAppleApplicationTools(): AppleApplicationTools { +export function createAppleApplicationTools(env: Readonly): AppleApplicationTools { return Object.freeze({ - resolveOpenTarget: async (device, input) => await resolveAppleOpenTarget(device, input), + resolveOpenTarget: async (device, input) => await resolveAppleOpenTarget(device, input, env), prewarmRunnerCache: async (device, execution, signal) => { const { prewarmAppleRunnerCache } = await loadRunnerOperations(); await prewarmAppleRunnerCache(device, appleRunnerOptions(execution, signal)); @@ -111,7 +112,7 @@ export function createAppleApplicationTools(): AppleApplicationTools { const { clearRuntimeHintValues } = await loadRuntimeHints(); await clearRuntimeHintValues({ device, ...input }); }, - dismissCloseAlerts: async (device, input) => await dismissMacOsCloseAlerts(device, input), + dismissCloseAlerts: async (device, input) => await dismissMacOsCloseAlerts(device, input, env), detachRunnerSessionsForShutdown: async () => { const { detachIosRunnerSessionsForShutdown } = await loadRunnerOperations(); return await detachIosRunnerSessionsForShutdown(); @@ -126,6 +127,7 @@ export function createAppleApplicationTools(): AppleApplicationTools { async function resolveAppleOpenTarget( device: DeviceInfo, input: OpenTargetResolutionInput, + env: Readonly, ): Promise { if (input.foreground) { const foreground = await resolveAppleForegroundTarget(device); @@ -139,7 +141,7 @@ async function resolveAppleOpenTarget( }, ); } - const macOsSurface = await resolveMacOsSurface(device, input.surface); + const macOsSurface = await resolveMacOsSurface(device, input.surface, env); const { resolveSessionAppBundleIdForTarget } = await loadOpenTarget(); return { appBundleId: @@ -161,12 +163,13 @@ async function resolveAppleForegroundTarget( async function resolveMacOsSurface( device: DeviceInfo, surface: OpenTargetResolutionInput['surface'], + env: Readonly, ): Promise { if (!isMacOs(device) || surface === 'app' || surface === 'desktop' || surface === 'menubar') { return {}; } const { resolveFrontmostMacOsApp } = await loadMacOs(); - const frontmost = await resolveFrontmostMacOsApp(); + const frontmost = await resolveFrontmostMacOsApp(env); return { appBundleId: frontmost.bundleId, appName: frontmost.appName }; } @@ -187,6 +190,7 @@ function appleRunnerOptions(execution: PrepareAppleRunnerInput['execution'], sig async function dismissMacOsCloseAlerts( device: DeviceInfo, input: CloseApplicationFinalizationInput, + env: Readonly, ): Promise { if (!isMacOs(device)) return; const { runMacOsAlertAction } = await loadMacOs(); @@ -196,5 +200,5 @@ async function dismissMacOsCloseAlerts( : input.appBundleId ? { bundleId: input.appBundleId } : {}; - await runMacOsAlertAction('dismiss', dismissOptions); + await runMacOsAlertAction('dismiss', { ...dismissOptions, env }); } diff --git a/src/platform-runtime-apple-deployment-executor.test.ts b/src/platform-runtime-apple-deployment-executor.test.ts index 14ba67d9e3..aecd81a6a4 100644 --- a/src/platform-runtime-apple-deployment-executor.test.ts +++ b/src/platform-runtime-apple-deployment-executor.test.ts @@ -25,7 +25,7 @@ const device: DeviceInfo = { test('keeps only artifact and identity capabilities in the root adapter', async () => { resolveIosApp.mockResolvedValue('com.example.app'); - const executor = createAppleAppDeploymentExecutor(); + const executor = createAppleAppDeploymentExecutor(process.env); await expect(executor.resolveAppBundleId(device, 'Example')).resolves.toBe('com.example.app'); @@ -42,7 +42,7 @@ test('delegates the full Apple reinstall cache transaction to the lazy native ex invalidateIosAppResolutionCache.mockImplementation(async (_device, scopedOperation) => scopedOperation(), ); - const executor = createAppleAppDeploymentExecutor(); + const executor = createAppleAppDeploymentExecutor(process.env); await expect(executor.withInvalidatedAppResolutionCache(device, operation)).resolves.toBe( 'completed', diff --git a/src/platform-runtime-apple-deployment-executor.ts b/src/platform-runtime-apple-deployment-executor.ts index c32d8a10d2..5d36d7895d 100644 --- a/src/platform-runtime-apple-deployment-executor.ts +++ b/src/platform-runtime-apple-deployment-executor.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { AppleAppDeploymentExecutor, MaterializeAppSourceInput, @@ -8,7 +8,9 @@ import type { * Native Apple executor port. The platform package owns deployment sequencing; this composition * adapter loads native mechanics only once an admitted Apple binding invokes an operation. */ -export function createAppleAppDeploymentExecutor(): AppleAppDeploymentExecutor { +export function createAppleAppDeploymentExecutor( + env: Readonly, +): AppleAppDeploymentExecutor { return Object.freeze({ withInvalidatedAppResolutionCache: async (device, operation) => { const { invalidateIosAppResolutionCache } = @@ -20,7 +22,7 @@ export function createAppleAppDeploymentExecutor(): AppleAppDeploymentExecutor { await import('@agent-device/platform-apple/install-artifact'); return await prepareIosInstallArtifact(input.source, { ...options, - environment: commandHostEnvironment().env, + environment: env, }); }, resolveAppBundleId: async (device, app, options) => { diff --git a/src/platform-runtime-audio-probe-host.ts b/src/platform-runtime-audio-probe-host.ts index c9a2e11857..ec50e21dec 100644 --- a/src/platform-runtime-audio-probe-host.ts +++ b/src/platform-runtime-audio-probe-host.ts @@ -1,4 +1,5 @@ import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { AudioProbeRuntimeHost, HostAudioCaptureProcess, @@ -19,11 +20,11 @@ import { * ScreenCaptureKit sampler taps host system audio whether the session is a macOS app, an iOS * simulator, or an Android emulator. The owner packages state which of their cells reach it. */ -export function createAudioProbeRuntimeHost( - options: { - ownedProcesses?: OwnedProcessRecordWriter; - } = {}, -): AudioProbeRuntimeHost { +export function createAudioProbeRuntimeHost(options: { + ownedProcesses?: OwnedProcessRecordWriter; + host: HostEnvironment; +}): AudioProbeRuntimeHost { + const host = options.host; const ownedProcesses: OwnedProcessRecordWriter = options.ownedProcesses ?? Object.freeze({ @@ -44,7 +45,7 @@ export function createAudioProbeRuntimeHost( statusPath: string; }): Promise => { const { startMacOsAudioProbeProcess } = await import('@agent-device/platform-apple/macos'); - const probe = await startMacOsAudioProbeProcess(input); + const probe = await startMacOsAudioProbeProcess({ ...input, env: host.env }); const marker = await resolveManagedProcessIdentity(probe.child.pid ?? undefined); return Object.freeze({ ...(marker === undefined ? {} : { marker }), @@ -62,8 +63,10 @@ export function createAudioProbeRuntimeHost( web: Object.freeze({ resolve: async (device: DeviceInfo): Promise => { if (device.platform !== 'web') return undefined; - const { resolveWebProvider } = await import('@agent-device/platform-web'); - const provider = await resolveWebProvider(); + const { resolveWebProviderForHost } = await import('@agent-device/platform-web'); + // Every reader of one web request resolves the same provider through the one policy + // (#3794). + const provider = await resolveWebProviderForHost(host); const probeAudio = provider.probeAudio?.bind(provider); if (!probeAudio) return undefined; return Object.freeze({ diff --git a/src/platform-runtime-claim-recovery.ts b/src/platform-runtime-claim-recovery.ts index 6be124a168..ef02d69be9 100644 --- a/src/platform-runtime-claim-recovery.ts +++ b/src/platform-runtime-claim-recovery.ts @@ -1,4 +1,4 @@ -import type { EnvMap } from '@agent-device/kernel/source-value'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { createOwnerScopedDeviceClaimReconciler, createRecoveryPlatformScope, @@ -21,10 +21,10 @@ import { createPlatformRuntimeGateway } from './platform-runtime.ts'; * so disposal shuts down only this transaction's handles. */ export const createClaimRecoveryGatewayFor = - (env: EnvMap): ClaimRecoveryGatewayFactory => + (host: HostEnvironment): ClaimRecoveryGatewayFactory => (input) => createPlatformRuntimeGateway({ - env, + host, sessionsDir: input.sessionsDir, ownedProcesses: input.ownedProcesses, resolveSessionArtifacts: input.resolveSessionArtifacts, @@ -39,15 +39,20 @@ export const createClaimRecoveryGatewayFor = */ export async function runStaleDeviceClaimRelease( selectors: DeviceClaimSelectors, - env: EnvMap, + host: HostEnvironment, composeRecovery?: OwnerScopedClaimRecoveryComposer, ): Promise { + // Settling a transaction can reach runner code; this CLI process runs one host, and the + // runner reads its configuration from it, entered as the runtime state the daemon route binds. + const { setAppleRunnerHostEnvironment } = + await import('@agent-device/platform-apple/runner-owner'); + setAppleRunnerHostEnvironment(host); return await releaseProvenStaleDeviceClaims({ selectors, - env, + env: host.env, reconcile: createOwnerScopedDeviceClaimReconciler({ scope: createRecoveryPlatformScope(), - composeGateway: createClaimRecoveryGatewayFor(env), + composeGateway: createClaimRecoveryGatewayFor(host), composeRecovery, }), }); diff --git a/src/platform-runtime-daemon-lifecycle.test.ts b/src/platform-runtime-daemon-lifecycle.test.ts index 9de96bb92d..c79564be15 100644 --- a/src/platform-runtime-daemon-lifecycle.test.ts +++ b/src/platform-runtime-daemon-lifecycle.test.ts @@ -2,7 +2,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; import type { ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { mkdtempForTestSync, withMockedPlatform } from '@agent-device/test-kit'; +import { + createLiveTestHostEnvironment, + mkdtempForTestSync, + withMockedPlatform, +} from '@agent-device/test-kit'; const legacyRedirect = vi.hoisted(() => ({ xctestDeviceSetPath: '' })); @@ -20,7 +24,11 @@ vi.mock('@agent-device/platform-apple/runner-owner', async (importOriginal) => { }; }); -import { platformDaemonLifecycleOwners } from './platform-runtime-daemon-lifecycle.ts'; +import { createPlatformDaemonLifecycleOwners } from './platform-runtime-daemon-lifecycle.ts'; + +const platformDaemonLifecycleOwners = createPlatformDaemonLifecycleOwners( + createLiveTestHostEnvironment(), +); const roots: string[] = []; diff --git a/src/platform-runtime-daemon-lifecycle.ts b/src/platform-runtime-daemon-lifecycle.ts index 8f8d6e792c..acd18ecc1b 100644 --- a/src/platform-runtime-daemon-lifecycle.ts +++ b/src/platform-runtime-daemon-lifecycle.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { PlatformOwnerLifecycle } from '@agent-device/daemon'; import { configureAppleRunnerDeviceClaimAuthorityProbe, @@ -15,25 +16,27 @@ import { * snapshot-helper and Web orphan cleanups, legacy app-log marker recovery, and the legacy * `XCTestDevices` restore); the daemon holds only the typed `PlatformOwnerLifecycle` contract. */ -export const platformDaemonLifecycleOwners: PlatformOwnerLifecycle = Object.freeze({ - configureForDaemonLock: async (input) => { - await configureAppleRunnerLeaseOwnerStateDir(input.stateDir); - await configureAppleRunnerDeviceClaimAuthorityProbe(input.hasDeviceClaimAuthority); - await restoreLegacyXctestDeviceSetRedirectRuntime(input.onDiagnostic); - }, - clearDaemonLockConfiguration: async () => { - await configureAppleRunnerLeaseOwnerStateDir(undefined); - await configureAppleRunnerDeviceClaimAuthorityProbe(undefined); - }, - recoverLegacyAppLogMarkers: async (sessionsDir) => { - const { recoverLegacyAppLogMarkersAfterDaemonLock } = - await import('./platform-runtime-operation-host.ts'); - return await recoverLegacyAppLogMarkersAfterDaemonLock(sessionsDir); - }, - cleanupManagedWebOrphans: async (params) => { - await cleanupManagedWebRuntimeOrphans(params); - }, - resetAndroidSnapshotHelper: async () => { - await resetAndroidSnapshotHelperRuntime(); - }, -}); +export function createPlatformDaemonLifecycleOwners(host: HostEnvironment): PlatformOwnerLifecycle { + return Object.freeze({ + configureForDaemonLock: async (input) => { + await configureAppleRunnerLeaseOwnerStateDir(input.stateDir); + await configureAppleRunnerDeviceClaimAuthorityProbe(input.hasDeviceClaimAuthority); + await restoreLegacyXctestDeviceSetRedirectRuntime(input.onDiagnostic); + }, + clearDaemonLockConfiguration: async () => { + await configureAppleRunnerLeaseOwnerStateDir(undefined); + await configureAppleRunnerDeviceClaimAuthorityProbe(undefined); + }, + recoverLegacyAppLogMarkers: async (sessionsDir) => { + const { recoverLegacyAppLogMarkersAfterDaemonLock } = + await import('./platform-runtime-operation-host.ts'); + return await recoverLegacyAppLogMarkersAfterDaemonLock(sessionsDir); + }, + cleanupManagedWebOrphans: async (params) => { + await cleanupManagedWebRuntimeOrphans(params, host); + }, + resetAndroidSnapshotHelper: async () => { + await resetAndroidSnapshotHelperRuntime(); + }, + }); +} diff --git a/src/platform-runtime-daemon-owner-cleanup.test.ts b/src/platform-runtime-daemon-owner-cleanup.test.ts index 303e890adf..b1fa02c891 100644 --- a/src/platform-runtime-daemon-owner-cleanup.test.ts +++ b/src/platform-runtime-daemon-owner-cleanup.test.ts @@ -13,7 +13,7 @@ import { createDaemonOwnerCleanup } from './platform-runtime-daemon-owner-cleanu test('composes owner cleanup with the Apple runner lease facade', async () => { const owner = { pid: 42, startTime: 'process-start' }; - await createDaemonOwnerCleanup().cleanup(owner); + await createDaemonOwnerCleanup({ env: process.env, cwd: process.cwd() }).cleanup(owner); expect(mocks.cleanupRunnerLeasesForOwner).toHaveBeenCalledWith(owner); }); diff --git a/src/platform-runtime-daemon-owner-cleanup.ts b/src/platform-runtime-daemon-owner-cleanup.ts index a09e727ab6..ff41acd407 100644 --- a/src/platform-runtime-daemon-owner-cleanup.ts +++ b/src/platform-runtime-daemon-owner-cleanup.ts @@ -1,12 +1,18 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonOwnerCleanup } from '@agent-device/daemon-contracts/daemon-owner-cleanup'; /** * Root composition for owner-scoped host cleanup. The CLI names only the neutral service; Apple * runner lease discovery and process disposal stay behind the platform composition boundary. + * `host` is the calling CLI's host; lease discovery reads runner configuration from it, entered + * as the runtime state the daemon route binds (#3794). */ -export function createDaemonOwnerCleanup(): DaemonOwnerCleanup { +export function createDaemonOwnerCleanup(host: HostEnvironment): DaemonOwnerCleanup { return Object.freeze({ cleanup: async (owner) => { + const { setAppleRunnerHostEnvironment } = + await import('@agent-device/platform-apple/runner-owner'); + setAppleRunnerHostEnvironment(host); const { cleanupRunnerLeasesForOwner } = await import('@agent-device/platform-apple/runner/operations'); await cleanupRunnerLeasesForOwner(owner); diff --git a/src/platform-runtime-gateway-gesture-admission.test.ts b/src/platform-runtime-gateway-gesture-admission.test.ts index 2ae0d5212c..05a1e6b0bb 100644 --- a/src/platform-runtime-gateway-gesture-admission.test.ts +++ b/src/platform-runtime-gateway-gesture-admission.test.ts @@ -1,12 +1,12 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { expect, test } from 'vitest'; -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createLiveTestHostEnvironment } from '@agent-device/test-kit'; import { createPlatformRuntimeGateway } from './platform-runtime.ts'; bindTestCommandHost(); const gateway = createPlatformRuntimeGateway({ - env: process.env, + host: createLiveTestHostEnvironment(), resolveSessionArtifacts: () => ({ outputPath: '/sessions/gesture-admission/app.log', pidPath: '/sessions/gesture-admission/app-log.pid', diff --git a/src/platform-runtime-gateway-recording.test.ts b/src/platform-runtime-gateway-recording.test.ts index b36c4a0bf9..3e4f612476 100644 --- a/src/platform-runtime-gateway-recording.test.ts +++ b/src/platform-runtime-gateway-recording.test.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { expect, test } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { createLiveTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; import { createPlatformRuntimeGateway } from './platform-runtime.ts'; const unsupportedRecordingDevices = [ @@ -20,7 +20,7 @@ test.each(unsupportedRecordingDevices)( async (device) => { const sessionsDir = mkdtempForTestSync('platform-runtime-recording-'); const gateway = createPlatformRuntimeGateway({ - env: process.env, + host: createLiveTestHostEnvironment(), sessionsDir, resolveSessionArtifacts: (sessionId) => ({ outputPath: path.join(sessionsDir, sessionId, 'app.log'), diff --git a/src/platform-runtime-host-diagnostics.test.ts b/src/platform-runtime-host-diagnostics.test.ts index 5bd882e3c8..e28fc67246 100644 --- a/src/platform-runtime-host-diagnostics.test.ts +++ b/src/platform-runtime-host-diagnostics.test.ts @@ -1,6 +1,13 @@ import { beforeEach, expect, test, vi } from 'vitest'; +import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; import type { HostDiagnosticsContext } from '@agent-device/contracts/host-diagnostics'; -import { ANDROID_EMULATOR, IOS_SIMULATOR } from '@agent-device/test-kit'; +import { + ANDROID_EMULATOR, + IOS_SIMULATOR, + createLiveTestHostEnvironment, + mkdtempForTestSync, +} from '@agent-device/test-kit'; import { createHostDiagnostics } from './platform-runtime-host-diagnostics.ts'; const { mockAppleRunnerWarmupCheck } = vi.hoisted(() => ({ @@ -35,7 +42,10 @@ test('warmup hands an Apple device to the Apple runner warmup and returns its ch }; mockAppleRunnerWarmupCheck.mockResolvedValue(check); - const result = await createHostDiagnostics(process.env).warmupCheck(IOS_SIMULATOR, context); + const result = await createHostDiagnostics(createLiveTestHostEnvironment()).warmupCheck( + IOS_SIMULATOR, + context, + ); expect(result).toEqual(check); expect(mockAppleRunnerWarmupCheck).toHaveBeenCalledWith(IOS_SIMULATOR, context); @@ -43,7 +53,34 @@ test('warmup hands an Apple device to the Apple runner warmup and returns its ch test('warmup runs no probe for a non-Apple device', async () => { expect( - await createHostDiagnostics(process.env).warmupCheck(ANDROID_EMULATOR, context), + await createHostDiagnostics(createLiveTestHostEnvironment()).warmupCheck( + ANDROID_EMULATOR, + context, + ), ).toBeUndefined(); expect(mockAppleRunnerWarmupCheck).not.toHaveBeenCalled(); }); + +test('vega toolchain check finds the CLI through the host it was composed with', async () => { + // Doctor must answer for the host createHostDiagnostics holds, with no request-scoped + // provider: this host's HOME differs from the process HOME and names a default-install vega + // the process home does not have (#3794). Reverting vegaToolchainCheck to a bare + // resolveVegaToolProvider() hits the `vega-adapter-host-unbound` refusal and goes red. + const scratch = mkdtempForTestSync('vega-doctor-host-home-'); + const hostHome = path.join(scratch, 'host-home'); + mkdirSync(path.join(hostHome, 'vega', 'bin'), { recursive: true }); + const vega = path.join(hostHome, 'vega', 'bin', 'vega'); + writeFileSync( + vega, + '#!/bin/sh\ncase "$*" in\n *--version*) echo "Vega CLI 9.9.9" ;;\n *) echo "VirtualDevice : running" ;;\nesac\n', + ); + chmodSync(vega, 0o755); + + const check = await createHostDiagnostics({ + env: { PATH: path.join(scratch, 'empty-path'), HOME: hostHome }, + cwd: scratch, + }).toolchainCheck('vega', context); + + expect(check?.status).toBe('pass'); + expect(check?.summary).toContain('Vega CLI 9.9.9'); +}); diff --git a/src/platform-runtime-host-diagnostics.ts b/src/platform-runtime-host-diagnostics.ts index 799676cecb..60426029f8 100644 --- a/src/platform-runtime-host-diagnostics.ts +++ b/src/platform-runtime-host-diagnostics.ts @@ -1,4 +1,4 @@ -import type { EnvMap } from '@agent-device/kernel/source-value'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DeviceInfo, PlatformSelector } from '@agent-device/kernel/device'; import type { DoctorCheck } from '@agent-device/contracts/observability'; import type { @@ -12,7 +12,8 @@ import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; * execution). Families load lazily: a probe module is imported only when its family's method * actually runs, matching the platform registry's implementation-laziness. */ -export function createHostDiagnostics(env: Readonly): HostDiagnostics { +export function createHostDiagnostics(host: HostEnvironment): HostDiagnostics { + const { env } = host; return Object.freeze({ toolchainCheck: async ( platform: PlatformSelector | undefined, @@ -24,7 +25,7 @@ export function createHostDiagnostics(env: Readonly): HostDiagnostics { } if (platform === 'vega') { const { vegaToolchainCheck } = await import('@agent-device/platform-vega'); - return await vegaToolchainCheck(context); + return await vegaToolchainCheck(context, host); } if (platform === 'harmonyos') { const { harmonyToolchainCheck } = await import('@agent-device/platform-harmonyos'); @@ -46,7 +47,7 @@ export function createHostDiagnostics(env: Readonly): HostDiagnostics { }, ambientChecks: async (context: HostDiagnosticsContext): Promise => { const { webBrowserLifecycleCheck } = await import('@agent-device/platform-web'); - return [await webBrowserLifecycleCheck(context.stateDir)]; + return [await webBrowserLifecycleCheck(context.stateDir, host)]; }, warmupCheck: async ( device: DeviceInfo, diff --git a/src/platform-runtime-host.test.ts b/src/platform-runtime-host.test.ts index 693e78f07c..24afb43950 100644 --- a/src/platform-runtime-host.test.ts +++ b/src/platform-runtime-host.test.ts @@ -1,4 +1,9 @@ import { expect, test, vi } from 'vitest'; +import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { createLiveTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; +import { inventoryModule } from '@agent-device/platform-vega'; +import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; const toolchains = vi.hoisted(() => ({ appleEvaluations: 0, @@ -25,7 +30,7 @@ vi.mock('@agent-device/platform-apple/tool-provider', () => { import { createDeviceInventoryHost } from './platform-runtime-host.ts'; test('inventory host loads scoped Apple mechanics only on first Apple tool use', async () => { - const host = createDeviceInventoryHost(process.env); + const host = createDeviceInventoryHost(createLiveTestHostEnvironment()); const signal = new AbortController().signal; expect(toolchains.appleEvaluations).toBe(0); @@ -42,3 +47,41 @@ test('inventory host loads scoped Apple mechanics only on first Apple tool use', expect(toolchains.appleEvaluations).toBe(1); expect(toolchains.appleRuns).toEqual([{ args: ['devicectl', 'list', 'devices'], signal }]); }); + +test('vega inventory resolves the CLI from the host home, not the process home', async () => { + // The rule: every Vega executable lookup in one runtime resolves from that runtime host's + // PATH and home. This host's HOME differs from the process HOME and names a default-install + // vega the process home does not have, so `devices --platform vega` must list the device + // instead of TOOL_MISSING (#3794). Reverting `homeDirectory` to `os.homedir()` in + // createDeviceInventoryHost makes the lookup miss and this test go red. + const scratch = mkdtempForTestSync('vega-inventory-host-home-'); + const hostHome = path.join(scratch, 'host-home'); + mkdirSync(path.join(hostHome, 'vega', 'bin'), { recursive: true }); + const vega = path.join(hostHome, 'vega', 'bin', 'vega'); + writeFileSync(vega, '#!/bin/sh\necho "VirtualDevice : running"\n'); + chmodSync(vega, 0o755); + + const inventoryHost = createDeviceInventoryHost({ + env: { PATH: path.join(scratch, 'empty-path'), HOME: hostHome }, + cwd: scratch, + }); + const scope: PlatformRequestScope = Object.freeze({ + signal: new AbortController().signal, + diagnostics: Object.freeze({ emit: () => {} }), + progress: Object.freeze({ report: () => {} }), + }); + + const inventory = await inventoryModule.loadInventory(inventoryHost); + const devices = await inventory.discover({ platform: 'vega', target: 'tv' }, scope); + + expect(devices).toEqual([ + { + platform: 'vega', + id: 'VirtualDevice', + name: 'Vega Virtual Device (VirtualDevice)', + kind: 'emulator', + target: 'tv', + booted: true, + }, + ]); +}); diff --git a/src/platform-runtime-host.ts b/src/platform-runtime-host.ts index 2df43a2d2b..786db0cd68 100644 --- a/src/platform-runtime-host.ts +++ b/src/platform-runtime-host.ts @@ -1,4 +1,4 @@ -import type { EnvMap } from '@agent-device/kernel/source-value'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DeviceInventoryHost, HostOperatingSystem, @@ -6,25 +6,39 @@ import type { HostTemporaryTextFile, } from '@agent-device/contracts/platform-runtime-host'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; import { constants } from 'node:fs'; import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { createAppleToolHost } from './platform-runtime-apple-tool-host.ts'; import { createHostToolchainPreparer } from './platform-runtime-toolchain-host.ts'; -import { runCmd, whichCmd, commandHostEnvironment } from '@agent-device/host-kit/command'; +import { runCmd, whichCmd } from '@agent-device/host-kit/command'; import { guardedHostCommandArgv } from './platform-runtime-host-device-shell.ts'; +import { hostHomeDirectoryFromEnv } from '@agent-device/host-kit/host-file'; -export function createDeviceInventoryHost(env: Readonly): DeviceInventoryHost { +export function createDeviceInventoryHost(host: HostEnvironment): DeviceInventoryHost { + const env = host.env; + // The host names its own home; families derive default-install paths from it (#3794). The + // port requires a home, and guessing the process home would be an ambient read (#3753), so + // a host env that names no home is refused here instead. + const homeDirectory = hostHomeDirectoryFromEnv(env); + if (homeDirectory === undefined) { + throw new AppError( + 'COMMAND_FAILED', + 'The device-inventory host names no home directory (HOME or USERPROFILE).', + { reason: 'host_home_unbound' }, + ); + } return Object.freeze({ commands: Object.freeze({ which: async (executable: string) => - (await whichCmd(executable, commandHostEnvironment().env)) ? executable : undefined, + (await whichCmd(executable, env)) ? executable : undefined, run: async (request: HostCommandRequest, signal?: AbortSignal) => { const result = await runCmd(request.executable, guardedHostCommandArgv(request), { allowFailure: request.allowFailure, cwd: request.cwd, - env: request.env ? { ...commandHostEnvironment().env, ...request.env } : undefined, + env: request.env ? { ...env, ...request.env } : undefined, signal, timeoutMs: request.timeoutMs, }); @@ -44,7 +58,7 @@ export function createDeviceInventoryHost(env: Readonly): DeviceInventor }), hostOs: hostOperatingSystem(process.platform), hostName: os.hostname(), - homeDirectory: os.homedir(), + homeDirectory, observations: Object.freeze({ deviceBooted: async (device: DeviceInfo) => { const { markSimulatorBooted } = await import('@agent-device/platform-apple/simulator'); diff --git a/src/platform-runtime-local-application-interactors.ts b/src/platform-runtime-local-application-interactors.ts index 772ec74696..46c56c718f 100644 --- a/src/platform-runtime-local-application-interactors.ts +++ b/src/platform-runtime-local-application-interactors.ts @@ -1,13 +1,16 @@ import type { LocalApplicationInteractorHost } from '@agent-device/contracts/application-lifecycle-runtime'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { RunnerContext } from '@agent-device/contracts/interactor-types'; /** Lazy local interactor construction; package bindings decide when it is needed. */ -export function createLocalApplicationInteractorHost(): LocalApplicationInteractorHost { +export function createLocalApplicationInteractorHost( + host: HostEnvironment, +): LocalApplicationInteractorHost { return Object.freeze({ resolve: async (device: DeviceInfo, runner: RunnerContext) => { const { getLocalInteractor } = await import('./core/interactors.ts'); - return await getLocalInteractor(device, runner); + return await getLocalInteractor(device, runner, host); }, }); } diff --git a/src/platform-runtime-managed-web-backend.ts b/src/platform-runtime-managed-web-backend.ts index c1a35b2070..ab4bf31af5 100644 --- a/src/platform-runtime-managed-web-backend.ts +++ b/src/platform-runtime-managed-web-backend.ts @@ -1,15 +1,16 @@ import type { ManagedWebBackend } from '@agent-device/contracts/managed-web-backend'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; /** Root composition for the managed browser host tool used by the CLI web command. */ -export function createManagedWebBackend(): ManagedWebBackend { +export function createManagedWebBackend(host: HostEnvironment): ManagedWebBackend { return Object.freeze({ setup: async (options) => { const { setupManagedAgentBrowser } = await import('@agent-device/platform-web'); - return await setupManagedAgentBrowser(options); + return await setupManagedAgentBrowser({ ...options, host }); }, doctor: async (options) => { const { doctorManagedAgentBrowser } = await import('@agent-device/platform-web'); - return await doctorManagedAgentBrowser(options); + return await doctorManagedAgentBrowser({ ...options, host }); }, }); } diff --git a/src/platform-runtime-network-web-transport.ts b/src/platform-runtime-network-web-transport.ts index 3da7f52a8f..f3ef684782 100644 --- a/src/platform-runtime-network-web-transport.ts +++ b/src/platform-runtime-network-web-transport.ts @@ -1,12 +1,19 @@ import type { NetworkTransport } from '@agent-device/contracts/network-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; -export async function resolveWebNetworkTransport(device: DeviceInfo): Promise { +export async function resolveWebNetworkTransport( + device: DeviceInfo, + host: HostEnvironment, +): Promise { if (device.platform !== 'web') return Object.freeze({ mode: 'local' }); - const { hasScopedWebProvider, resolveWebProvider } = await import('@agent-device/platform-web'); - const provider = await resolveWebProvider(); + const { hasScopedWebProvider, resolveWebProviderForHost } = + await import('@agent-device/platform-web'); + // Every reader of one web request resolves the same provider through the one policy (#3794). + const scoped = await hasScopedWebProvider(); + const provider = await resolveWebProviderForHost(host); const dumpNetwork = provider.dumpNetwork; - const mode = (await hasScopedWebProvider()) ? 'transport-composed' : 'local'; + const mode = scoped ? 'transport-composed' : 'local'; if (!dumpNetwork) return Object.freeze({ mode }); return Object.freeze({ mode, diff --git a/src/platform-runtime-operation-host.test.ts b/src/platform-runtime-operation-host.test.ts index 79169e37a6..0cc4abe7bd 100644 --- a/src/platform-runtime-operation-host.test.ts +++ b/src/platform-runtime-operation-host.test.ts @@ -30,6 +30,7 @@ vi.mock('@agent-device/platform-apple/macos', async (importOriginal) => ({ })); import { captureMacOsSurfaceSnapshot } from '@agent-device/platform-apple/macos'; +import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; import { createPlatformRuntimeHost, loadMacOsSurfaceSnapshot, @@ -62,7 +63,7 @@ const shutdownLoaders = { test('operation host composes the shared lazy Apple-tool and toolchain capabilities', () => { const host = createPlatformRuntimeHost({ - env: process.env, + host: createLiveTestHostEnvironment(), sessionsDir: '/tmp/sessions', resolveSessionArtifacts: () => ({ outputPath: '/tmp/sessions/one/app.log', @@ -88,7 +89,7 @@ test('composes focused deployment executors instead of a cross-family deployment test('the macOS surface loader refuses a capture the owner did not route to it', async () => { vi.mocked(captureMacOsSurfaceSnapshot).mockClear(); - const refusal = loadMacOsSurfaceSnapshot({ depth: 2 }); + const refusal = loadMacOsSurfaceSnapshot({ depth: 2 }, undefined, process.env); await expect(refusal).rejects.toBeInstanceOf(TypeError); await expect(refusal).rejects.toThrow( 'Apple surface capture requires a helper-routed macOS surface', @@ -99,9 +100,10 @@ test('the macOS surface loader refuses a capture the owner did not route to it', test('the macOS surface loader forwards a helper-routed surface unchanged', async () => { vi.mocked(captureMacOsSurfaceSnapshot).mockClear(); const signal = new AbortController().signal; - await loadMacOsSurfaceSnapshot({ surface: 'frontmost-app', depth: 2 }, signal); + await loadMacOsSurfaceSnapshot({ surface: 'frontmost-app', depth: 2 }, signal, process.env); expect(captureMacOsSurfaceSnapshot).toHaveBeenCalledWith( { surface: 'frontmost-app', depth: 2 }, signal, + process.env, ); }); diff --git a/src/platform-runtime-operation-host.ts b/src/platform-runtime-operation-host.ts index 59138275c0..f46a6ebe39 100644 --- a/src/platform-runtime-operation-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -1,4 +1,4 @@ -import type { EnvMap } from '@agent-device/kernel/source-value'; +import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; import type { AppLogSessionArtifacts } from '@agent-device/contracts/app-log-runtime'; import type { DeviceShutdownRuntimeLoaders } from '@agent-device/contracts/device-shutdown-runtime'; import type { @@ -16,7 +16,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { setTimeout as sleep } from 'node:timers/promises'; import { createAppleToolHost } from './platform-runtime-apple-tool-host.ts'; import { createHostToolchainPreparer } from './platform-runtime-toolchain-host.ts'; -import { runCmd, whichCmd, commandHostEnvironment } from '@agent-device/host-kit/command'; +import { runCmd, whichCmd } from '@agent-device/host-kit/command'; import { guardedHostCommandArgv } from './platform-runtime-host-device-shell.ts'; import { openAppLogOutput, readAppLogOutputTail } from './platform-runtime-app-log-output.ts'; import { createManagedAppLogProcesses } from './platform-runtime-app-log-process.ts'; @@ -41,7 +41,8 @@ export { createSnapshotRuntimeHost } from '@agent-device/capture-kit/snapshot-de export async function loadMacOsSurfaceSnapshot( options: CaptureSnapshotInput['options'], - signal?: AbortSignal, + signal: AbortSignal | undefined, + env: Readonly, ): Promise { // The Apple owner routes and names the surface; one it did not name was never routed here. const surface = @@ -50,11 +51,11 @@ export async function loadMacOsSurfaceSnapshot( throw new TypeError('Apple surface capture requires a helper-routed macOS surface'); } const { captureMacOsSurfaceSnapshot } = await import('@agent-device/platform-apple/macos'); - return await captureMacOsSurfaceSnapshot({ ...options, surface }, signal); + return await captureMacOsSurfaceSnapshot({ ...options, surface }, signal, env); } export function createPlatformRuntimeHost(options: { - env: Readonly; + host: HostEnvironment; sessionsDir: string; resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; shutdownLoaders: DeviceShutdownRuntimeLoaders; @@ -62,17 +63,19 @@ export function createPlatformRuntimeHost(options: { snapshot: SnapshotRuntimeHost; ownedProcesses?: OwnedProcessRecordWriter; }): PlatformRuntimeHost { + const host = options.host; + const env = host.env; const processes = createManagedAppLogProcesses(options.sessionsDir); const localProcessTransport = Object.freeze({ mode: 'local' as const, start: processes.start }); const appleTools = createAppleToolHost(); const commands = Object.freeze({ which: async (executable: string) => - (await whichCmd(executable, commandHostEnvironment().env)) ? executable : undefined, + (await whichCmd(executable, env)) ? executable : undefined, run: async (request: HostCommandRequest, signal?: AbortSignal) => { const result = await runCmd(request.executable, guardedHostCommandArgv(request), { allowFailure: request.allowFailure, cwd: request.cwd, - env: request.env ? { ...commandHostEnvironment().env, ...request.env } : undefined, + env: request.env ? { ...env, ...request.env } : undefined, signal, timeoutMs: request.timeoutMs, }); @@ -86,18 +89,20 @@ export function createPlatformRuntimeHost(options: { if (device.platform !== 'web') return Object.freeze({ mode: 'local' as const }); const { resolveWebNetworkTransport } = await import('./platform-runtime-network-web-transport.ts'); - return await resolveWebNetworkTransport(device); + return await resolveWebNetworkTransport(device, host); }, }); - const appleApplications = createAppleApplicationTools(); + const appleApplications = createAppleApplicationTools(env); const androidApplications = createAndroidApplicationTools(); return Object.freeze({ + hostEnvironment: host, commands, appleTools, - toolchains: createHostToolchainPreparer(options.env), + toolchains: createHostToolchainPreparer(env), artifacts: Object.freeze({ resolveSession: options.resolveSessionArtifacts }), outputs: Object.freeze({ - openAppend: async (pathname: string) => await openAppLogOutput(options.sessionsDir, pathname), + openAppend: async (pathname: string) => + await openAppLogOutput(options.sessionsDir, pathname, host), readTail: async (pathname: string, maxBytes: number) => await readAppLogOutputTail(options.sessionsDir, pathname, maxBytes), }), @@ -115,8 +120,8 @@ export function createPlatformRuntimeHost(options: { }, }), ...network, - appleDeployment: createAppleAppDeploymentExecutor(), - androidDeployment: createAndroidAppDeploymentExecutor(options.env), + appleDeployment: createAppleAppDeploymentExecutor(env), + androidDeployment: createAndroidAppDeploymentExecutor(env), androidTools: createAndroidToolHost(), temporaryFiles: Object.freeze({ create: async (temporaryFileOptions: { prefix: string; suffix: string }) => @@ -132,11 +137,14 @@ export function createPlatformRuntimeHost(options: { options.shutdownLoaders, options.assertShutdownAllowed, ), - screenRecording: createScreenRecordingRuntimeHost({ ownedProcesses: options.ownedProcesses }), - audioProbe: createAudioProbeRuntimeHost({ ownedProcesses: options.ownedProcesses }), + screenRecording: createScreenRecordingRuntimeHost({ + ownedProcesses: options.ownedProcesses, + host, + }), + audioProbe: createAudioProbeRuntimeHost({ ownedProcesses: options.ownedProcesses, host }), perf: createPerfRuntimeHost(), snapshot: options.snapshot, - localInteractors: createLocalApplicationInteractorHost(), + localInteractors: createLocalApplicationInteractorHost(host), appleApplications, androidApplications, applicationResources: createApplicationResourceLifecycle({ diff --git a/src/platform-runtime-perf-host.test.ts b/src/platform-runtime-perf-host.test.ts index 7f75339e7e..68b0cd072e 100644 --- a/src/platform-runtime-perf-host.test.ts +++ b/src/platform-runtime-perf-host.test.ts @@ -1,5 +1,5 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { createLiveTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; import { expect, test, vi } from 'vitest'; import { createPerfRuntimeHost } from './platform-runtime-perf-host.ts'; import { createPlatformRuntimeGateway } from './platform-runtime.ts'; @@ -47,7 +47,7 @@ test('the composed Android perf operation reports an unavailable ADB memory samp adb.calls.length = 0; const sessionsDir = mkdtempForTestSync('platform-runtime-perf-host-'); const gateway = createPlatformRuntimeGateway({ - env: process.env, + host: createLiveTestHostEnvironment(), sessionsDir, resolveSessionArtifacts: (sessionId) => ({ outputPath: `${sessionsDir}/${sessionId}/app.log`, diff --git a/src/platform-runtime-resource-cleanup-retention.test.ts b/src/platform-runtime-resource-cleanup-retention.test.ts index a238358c09..fe249ba025 100644 --- a/src/platform-runtime-resource-cleanup-retention.test.ts +++ b/src/platform-runtime-resource-cleanup-retention.test.ts @@ -1,7 +1,14 @@ import { expect, test } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { ANDROID_EMULATOR, IOS_DEVICE, IOS_SIMULATOR } from '@agent-device/test-kit'; -import { platformResourceCleanup } from './platform-runtime-resource-cleanup.ts'; +import { + ANDROID_EMULATOR, + IOS_DEVICE, + IOS_SIMULATOR, + createLiveTestHostEnvironment, +} from '@agent-device/test-kit'; +import { createPlatformResourceCleanup } from './platform-runtime-resource-cleanup.ts'; + +const platformResourceCleanup = createPlatformResourceCleanup(createLiveTestHostEnvironment()); const PLAIN_CLOSE = { shutdownRequested: false, hasScreenRecording: false, hasLease: false }; diff --git a/src/platform-runtime-resource-cleanup-sessionless-host.test.ts b/src/platform-runtime-resource-cleanup-sessionless-host.test.ts index 083d4631f3..6be42d86f5 100644 --- a/src/platform-runtime-resource-cleanup-sessionless-host.test.ts +++ b/src/platform-runtime-resource-cleanup-sessionless-host.test.ts @@ -1,6 +1,12 @@ import { beforeEach, expect, test, vi } from 'vitest'; -import { ANDROID_EMULATOR, IOS_SIMULATOR } from '@agent-device/test-kit'; -import { platformResourceCleanup } from './platform-runtime-resource-cleanup.ts'; +import { + ANDROID_EMULATOR, + IOS_SIMULATOR, + createLiveTestHostEnvironment, +} from '@agent-device/test-kit'; +import { createPlatformResourceCleanup } from './platform-runtime-resource-cleanup.ts'; + +const platformResourceCleanup = createPlatformResourceCleanup(createLiveTestHostEnvironment()); const { RUNNER_BUNDLE_ID, closeIosApp, stopIosRunnerSession } = vi.hoisted(() => ({ RUNNER_BUNDLE_ID: 'com.example.test-runner', @@ -27,7 +33,7 @@ test('sessionless iOS runner cleanup stops the runner host app', async () => { await platformResourceCleanup.cleanupSessionlessExecutionHost(IOS_SIMULATOR); expect(stopIosRunnerSession).toHaveBeenCalledWith(IOS_SIMULATOR.id); - expect(closeIosApp).toHaveBeenCalledWith(IOS_SIMULATOR, RUNNER_BUNDLE_ID); + expect(closeIosApp).toHaveBeenCalledWith(IOS_SIMULATOR, RUNNER_BUNDLE_ID, undefined, process.env); expect(stopIosRunnerSession.mock.invocationCallOrder[0]!).toBeLessThan( closeIosApp.mock.invocationCallOrder[0]!, ); @@ -41,7 +47,7 @@ test('sessionless iOS runner host close is best effort', async () => { ).resolves.toBeUndefined(); expect(stopIosRunnerSession).toHaveBeenCalledWith(IOS_SIMULATOR.id); - expect(closeIosApp).toHaveBeenCalledWith(IOS_SIMULATOR, RUNNER_BUNDLE_ID); + expect(closeIosApp).toHaveBeenCalledWith(IOS_SIMULATOR, RUNNER_BUNDLE_ID, undefined, process.env); }); test('sessionless cleanup touches no runner host for a non-Apple device', async () => { diff --git a/src/platform-runtime-resource-cleanup.test.ts b/src/platform-runtime-resource-cleanup.test.ts index 90dfaed962..9c014fb61e 100644 --- a/src/platform-runtime-resource-cleanup.test.ts +++ b/src/platform-runtime-resource-cleanup.test.ts @@ -9,12 +9,15 @@ import { runCmd } from '@agent-device/host-kit/command'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { ANDROID_EMULATOR, - installFakeManagedAgentBrowser, - mkdtempForTestSync, WEB_DESKTOP_DEVICE, bindTestCommandHost, + createLiveTestHostEnvironment, + installFakeManagedAgentBrowser, + mkdtempForTestSync, } from '@agent-device/test-kit'; -import { platformResourceCleanup } from './platform-runtime-resource-cleanup.ts'; +import { createPlatformResourceCleanup } from './platform-runtime-resource-cleanup.ts'; + +const platformResourceCleanup = createPlatformResourceCleanup(createLiveTestHostEnvironment()); bindTestCommandHost(); diff --git a/src/platform-runtime-resource-cleanup.ts b/src/platform-runtime-resource-cleanup.ts index 890bdbc27a..df9c17d17d 100644 --- a/src/platform-runtime-resource-cleanup.ts +++ b/src/platform-runtime-resource-cleanup.ts @@ -1,4 +1,5 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { isIosFamily } from '@agent-device/kernel/device'; import type { PlatformResourceCleanup } from '@agent-device/daemon'; import { emitDiagnostic, type ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; @@ -26,16 +27,20 @@ export async function restoreLegacyXctestDeviceSetRedirectRuntime( await restoreLegacyXctestDeviceSetRedirect(onDiagnostic); } -export async function cleanupManagedWebRuntimeOrphans(params: { - stateDir: string; - openWebSessionNames: readonly string[]; - ownedProcessRecords?: OwnedProcessRecordStore; -}): Promise { +export async function cleanupManagedWebRuntimeOrphans( + params: { + stateDir: string; + openWebSessionNames: readonly string[]; + ownedProcessRecords?: OwnedProcessRecordStore; + }, + host: HostEnvironment, +): Promise { const { getManagedAgentBrowserStatus } = await import('@agent-device/platform-web'); - const status = await getManagedAgentBrowserStatus({ stateDir: params.stateDir }); + const status = await getManagedAgentBrowserStatus({ stateDir: params.stateDir, host }); if (!status.installed) return; const { cleanupManagedAgentBrowserOrphans } = await import('@agent-device/platform-web'); await cleanupManagedAgentBrowserOrphans(status, 'daemon-startup', { + env: host.env, openWebSessionNames: params.openWebSessionNames, ...(params.ownedProcessRecords === undefined ? {} @@ -43,13 +48,17 @@ export async function cleanupManagedWebRuntimeOrphans(params: { }); } -async function closeManagedWebRuntimeSession(params: { - sessionName: string; - stateDir: string; - openWebSessionNames: () => readonly string[]; -}): Promise { +async function closeManagedWebRuntimeSession( + params: { + sessionName: string; + stateDir: string; + openWebSessionNames: () => readonly string[]; + }, + host: HostEnvironment, +): Promise { const { createAgentBrowserWebProvider } = await import('@agent-device/platform-web'); const provider = await createAgentBrowserWebProvider({ + host, session: params.sessionName, stateDir: params.stateDir, openWebSessionNames: params.openWebSessionNames, @@ -57,45 +66,50 @@ async function closeManagedWebRuntimeSession(params: { await provider.close(); } -export const platformResourceCleanup: PlatformResourceCleanup = Object.freeze({ - async stopSnapshotHelper(device) { - if (device.platform !== 'android') return; - await stopAndroidSnapshotHelperRuntimeForDevice(device); - }, - async closeManagedBrowser(params) { - if (params.device.platform !== 'web') return; - await closeManagedWebRuntimeSession({ - sessionName: params.sessionName, - stateDir: params.stateDir, - openWebSessionNames: params.openSessionNames, - }); - }, - async cleanupSessionlessExecutionHost(device) { - if (!isIosFamily(device)) return; - const { resolveRunnerAppBundleId, stopIosRunnerSession } = - await import('@agent-device/platform-apple/runner/operations'); - await stopIosRunnerSession(device.id); - const bundleId = await resolveRunnerAppBundleId(); - const { closeIosApp } = await import('@agent-device/platform-apple/app-lifecycle'); - await closeIosApp(device, bundleId).catch((error) => { - emitDiagnostic({ - level: 'debug', - phase: 'ios_sessionless_runner_host_close_failed', - data: { - deviceId: device.id, - bundleId, - error: error instanceof Error ? error.message : String(error), +export function createPlatformResourceCleanup(host: HostEnvironment): PlatformResourceCleanup { + return Object.freeze({ + async stopSnapshotHelper(device) { + if (device.platform !== 'android') return; + await stopAndroidSnapshotHelperRuntimeForDevice(device); + }, + async closeManagedBrowser(params) { + if (params.device.platform !== 'web') return; + await closeManagedWebRuntimeSession( + { + sessionName: params.sessionName, + stateDir: params.stateDir, + openWebSessionNames: params.openSessionNames, }, + host, + ); + }, + async cleanupSessionlessExecutionHost(device) { + if (!isIosFamily(device)) return; + const { resolveRunnerAppBundleId, stopIosRunnerSession } = + await import('@agent-device/platform-apple/runner/operations'); + await stopIosRunnerSession(device.id); + const bundleId = await resolveRunnerAppBundleId(); + const { closeIosApp } = await import('@agent-device/platform-apple/app-lifecycle'); + await closeIosApp(device, bundleId, undefined, host.env).catch((error) => { + emitDiagnostic({ + level: 'debug', + phase: 'ios_sessionless_runner_host_close_failed', + data: { + deviceId: device.id, + bundleId, + error: error instanceof Error ? error.message : String(error), + }, + }); }); - }); - }, - retainExecutionHostAfterClose(params) { - return ( - isIosFamily(params.device) && - params.device.kind === 'simulator' && - !params.shutdownRequested && - !params.hasScreenRecording && - !params.hasLease - ); - }, -}); + }, + retainExecutionHostAfterClose(params) { + return ( + isIosFamily(params.device) && + params.device.kind === 'simulator' && + !params.shutdownRequested && + !params.hasScreenRecording && + !params.hasLease + ); + }, + }); +} diff --git a/src/platform-runtime-screen-recording-android-host.test.ts b/src/platform-runtime-screen-recording-android-host.test.ts index f8fa2f1f43..a493d2db9d 100644 --- a/src/platform-runtime-screen-recording-android-host.test.ts +++ b/src/platform-runtime-screen-recording-android-host.test.ts @@ -2,6 +2,7 @@ import { expect, test, vi } from 'vitest'; import { withAndroidAdbProvider } from '@agent-device/platform-android/mechanics'; import { createAndroidScreenRecordingTransport } from './platform-runtime-screen-recording-android-host.ts'; import './platform-runtime-android-adb-host.ts'; +import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; const adbExecutor = vi.hoisted(() => ({ override: undefined as @@ -50,7 +51,10 @@ test('uses the request-scoped Android ADB executor rather than a host fallback', }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); expect(transport.mode).toBe('transport-composed'); await expect(transport.start({ remotePath: '/sdcard/capture.mp4' })).resolves.toEqual({ process: { pid: '42', remotePath: '/sdcard/capture.mp4', startTime: '42' }, @@ -96,7 +100,10 @@ test('finds only exact screenrecord processes for the canonical remote path', as }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); await expect(transport.probeRunningWriters(remotePath)).resolves.toEqual({ writers: [ { pid: '41', remotePath, startTime: '41' }, @@ -166,7 +173,10 @@ test.each(inconclusiveWriterScans)( }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); await expect(transport.probeRunningWriters(writerScanRemotePath)).resolves.toEqual( expected, ); @@ -192,7 +202,10 @@ test('revalidates start-time and exact argv before SIGINT', async () => { }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); const process = { pid: '51', remotePath: '/sdcard/capture.mp4', @@ -223,7 +236,10 @@ test('distinguishes a missing proc directory from an unavailable identity probe' }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); const expected = { remotePath: '/sdcard/capture.mp4', startTime: '42' }; await expect(transport.inspect({ ...expected, pid: '42' })).resolves.toBe('missing'); await expect(transport.inspect({ ...expected, pid: '43' })).resolves.toBe('uncertain'); @@ -237,7 +253,10 @@ test('retains an interrupted empty-stderr presence probe as uncertain', async () return result('', '', null); }; try { - const transport = await createAndroidScreenRecordingTransport(android); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); await expect( transport.inspect({ pid: '44', @@ -262,7 +281,10 @@ test('credits a path as absent only from a probe that answered, not from one tha return result(''); }; try { - const transport = await createAndroidScreenRecordingTransport(android); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); await expect(transport.exists('/sdcard/capture.mp4')).resolves.toBe(false); await expect(transport.exists('/sdcard/capture.mp4')).resolves.toBe('uncertain'); await expect(transport.exists('/sdcard/capture.mp4')).resolves.toBe('uncertain'); @@ -279,7 +301,10 @@ test('retains an interrupted empty-stderr manifest probe as unavailable', async return result('', '', null); }; try { - const transport = await createAndroidScreenRecordingTransport(android); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); await expect(transport.readManifest('/sdcard/interrupted.json')).resolves.toEqual({ status: 'unavailable', message: 'Android recording manifest probe failed', @@ -314,7 +339,10 @@ test('proves termination from each ownership-lost producer without signalling', }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); for (const replacement of [reassignedPid, otherArtifact, exitedTask]) { identity = replacement; await expect(transport.inspect(recorded)).resolves.toBe('ownership-lost'); @@ -347,7 +375,10 @@ test('classifies a replacement recorder on the same path as a foreign writer and }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); await expect(transport.inspect(recorded)).resolves.toBe('foreign-writer'); await expect(transport.stop(recorded)).resolves.toBe('ownership-lost'); expect(commands.some((command) => command.startsWith('kill '))).toBe(false); @@ -396,7 +427,10 @@ test('retains unavailable manifest reads and confirms manifest deletion', async }, { serial: android.id }, async () => { - const transport = await createAndroidScreenRecordingTransport(android); + const transport = await createAndroidScreenRecordingTransport( + android, + createLiveTestHostEnvironment(), + ); await expect(transport.readManifest('/sdcard/manifest.json')).resolves.toEqual({ status: 'unavailable', message: 'transport unavailable', diff --git a/src/platform-runtime-screen-recording-android-host.ts b/src/platform-runtime-screen-recording-android-host.ts index e6ae77c57f..0141a07dda 100644 --- a/src/platform-runtime-screen-recording-android-host.ts +++ b/src/platform-runtime-screen-recording-android-host.ts @@ -5,8 +5,8 @@ import type { AndroidScreenRecordingTransport, } from '@agent-device/contracts/screen-recording-runtime-host'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; 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'; @@ -16,6 +16,7 @@ const BIT_RATE = { medium: 8_000_000, high: 20_000_000 } as const; export async function createAndroidScreenRecordingTransport( device: DeviceInfo, + host: HostEnvironment, ): Promise { const { resolveAndroidAdbExecutor, resolveScopedAndroidAdbBackgroundTransport, runAdbShell } = await loadAndroidMechanics(); @@ -108,8 +109,7 @@ export async function createAndroidScreenRecordingTransport( }); return { ...result, - playable: - result.exitCode === 0 && (await isPlayableVideo(outputPath, commandHostEnvironment())), + playable: result.exitCode === 0 && (await isPlayableVideo(outputPath, host)), }; }, remove: async (remotePath, signal) => diff --git a/src/platform-runtime-screen-recording-apple-host.test.ts b/src/platform-runtime-screen-recording-apple-host.test.ts index 4ddd71c315..b0d548b88b 100644 --- a/src/platform-runtime-screen-recording-apple-host.test.ts +++ b/src/platform-runtime-screen-recording-apple-host.test.ts @@ -2,6 +2,7 @@ import { expect, test } from 'vitest'; import { createAppleScreenRecordingHost } from './platform-runtime-screen-recording-apple-host.ts'; import { withAppleRunnerScreenRecordingTransport } from './platform-runtime-screen-recording-apple-runner-transport.ts'; import { withAppleSimulatorScreenRecordingTransport } from './platform-runtime-screen-recording-apple-transport.ts'; +import { createLiveTestHostEnvironment } from '@agent-device/test-kit'; const simulator = { platform: 'apple' as const, @@ -15,7 +16,7 @@ const simulator = { const device = { ...simulator, id: 'device', kind: 'device' as const }; test('reports unavailable facts for generic scoped Apple providers', async () => { - const host = createAppleScreenRecordingHost(); + const host = createAppleScreenRecordingHost(createLiveTestHostEnvironment()); await withAppleRunnerScreenRecordingTransport(undefined, async () => { await expect(host.availability(device)).resolves.toMatchObject({ available: false }); }); @@ -25,7 +26,7 @@ test('reports unavailable facts for generic scoped Apple providers', async () => }); test('reports a focused simulator transport as available', async () => { - const host = createAppleScreenRecordingHost(); + const host = createAppleScreenRecordingHost(createLiveTestHostEnvironment()); await withAppleSimulatorScreenRecordingTransport( { available: true, diff --git a/src/platform-runtime-screen-recording-apple-host.ts b/src/platform-runtime-screen-recording-apple-host.ts index 9b511ac9d0..697fcc7ebc 100644 --- a/src/platform-runtime-screen-recording-apple-host.ts +++ b/src/platform-runtime-screen-recording-apple-host.ts @@ -1,7 +1,10 @@ import type { ScreenRecordingRuntimeHost } from '@agent-device/contracts/screen-recording-runtime-host'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; -export function createAppleScreenRecordingHost(): ScreenRecordingRuntimeHost['apple'] { +export function createAppleScreenRecordingHost( + host: HostEnvironment, +): ScreenRecordingRuntimeHost['apple'] { return Object.freeze({ availability: appleScreenRecordingAvailability, runRunner: async (device, request, signal) => { @@ -32,7 +35,7 @@ export function createAppleScreenRecordingHost(): ScreenRecordingRuntimeHost['ap retrieveRunnerRecording: async (device, remotePath, outputPath, signal) => { const { retrieveAppleRunnerRecording } = await import('./platform-runtime-screen-recording-apple-runner-host.ts'); - await retrieveAppleRunnerRecording(device, remotePath, outputPath, signal); + await retrieveAppleRunnerRecording(device, remotePath, outputPath, host.env, signal); }, captureClockAnchor: async (device, appBundleId, signal) => { const { captureAppleClockAnchor } = @@ -42,7 +45,7 @@ export function createAppleScreenRecordingHost(): ScreenRecordingRuntimeHost['ap isRunnerBundleId: async (bundleId) => { const { isAppleRunnerBundleId } = await import('./platform-runtime-screen-recording-apple-runner-host.ts'); - return await isAppleRunnerBundleId(bundleId); + return await isAppleRunnerBundleId(bundleId, host.env); }, }); } diff --git a/src/platform-runtime-screen-recording-apple-runner-host.ts b/src/platform-runtime-screen-recording-apple-runner-host.ts index 069909c012..62d9ade9d3 100644 --- a/src/platform-runtime-screen-recording-apple-runner-host.ts +++ b/src/platform-runtime-screen-recording-apple-runner-host.ts @@ -1,6 +1,7 @@ import type { AppleScreenRecordingRunnerRequest } from '@agent-device/contracts/screen-recording-runtime-host'; import type { ManagedProcessOwnership } from '@agent-device/contracts/platform-runtime-host'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { EnvMap } from '@agent-device/kernel/source-value'; export async function runAppleRecordingRunner( device: DeviceInfo, @@ -56,13 +57,14 @@ export async function retrieveAppleRunnerRecording( device: DeviceInfo, remotePath: string, outputPath: string, + env: Readonly, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); const { resolveIosPhysicalDeviceControl } = await import('@agent-device/platform-apple/physical-device'); const control = resolveIosPhysicalDeviceControl(device); - await control.copyRunnerFile(device, remotePath, outputPath); + await control.copyRunnerFile(device, remotePath, outputPath, env); signal?.throwIfAborted(); } @@ -105,8 +107,10 @@ export async function captureAppleClockAnchor( } } -export async function isAppleRunnerBundleId(bundleId: string): Promise { +export async function isAppleRunnerBundleId( + bundleId: string, + env: Readonly>, +): Promise { const { resolveRunnerContainerBundleIds } = await import('@agent-device/platform-apple/runner'); - const { commandHostEnvironment } = await import('@agent-device/host-kit/command'); - return resolveRunnerContainerBundleIds(commandHostEnvironment().env).includes(bundleId); + return resolveRunnerContainerBundleIds(env).includes(bundleId); } diff --git a/src/platform-runtime-screen-recording-apple-simulator-host.test.ts b/src/platform-runtime-screen-recording-apple-simulator-host.test.ts index d9ef8ce026..9ddae081a6 100644 --- a/src/platform-runtime-screen-recording-apple-simulator-host.test.ts +++ b/src/platform-runtime-screen-recording-apple-simulator-host.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { beforeEach, expect, onTestFinished, test, vi } from 'vitest'; import { normalizeError } from '@agent-device/kernel/errors'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { createLiveTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; import { createAppleScreenRecordingHost } from './platform-runtime-screen-recording-apple-host.ts'; import { startAppleSimulatorRecording } from './platform-runtime-screen-recording-apple-simulator-host.ts'; import { withAppleSimulatorScreenRecordingTransport } from './platform-runtime-screen-recording-apple-transport.ts'; @@ -75,7 +75,7 @@ test.each([ const error = await withTransport( failed.process, async () => - await createAppleScreenRecordingHost().startSimulator( + await createAppleScreenRecordingHost(createLiveTestHostEnvironment()).startSimulator( simulator, path.join(root, 'failed.mp4'), ), @@ -316,9 +316,9 @@ test('stops a locally launched simulator recorder after xcrun execs the simctl b const marker = process.markers?.[0]; if (!marker) throw new Error('missing simulator recording marker'); - await expect(createAppleScreenRecordingHost().inspectProcess(marker)).resolves.toBe( - 'owned-alive', - ); + await expect( + createAppleScreenRecordingHost(createLiveTestHostEnvironment()).inspectProcess(marker), + ).resolves.toBe('owned-alive'); await expect(process.terminate()).resolves.toBeUndefined(); expect(running.kill).toHaveBeenCalledWith('SIGINT'); }); @@ -352,9 +352,9 @@ test.each([ const marker = process.markers?.[0]; if (!marker) throw new Error('missing simulator recording marker'); - await expect(createAppleScreenRecordingHost().inspectProcess(marker)).resolves.toBe( - 'ownership-lost', - ); + await expect( + createAppleScreenRecordingHost(createLiveTestHostEnvironment()).inspectProcess(marker), + ).resolves.toBe('ownership-lost'); await expect(process.terminate()).rejects.toThrow('process ownership changed'); expect(running.kill).not.toHaveBeenCalled(); running.resolveWait({ stdout: '', stderr: '', exitCode: 0 }); diff --git a/src/platform-runtime-screen-recording-finalizer-host.test.ts b/src/platform-runtime-screen-recording-finalizer-host.test.ts index 47f2656123..c1728b3d53 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.test.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.test.ts @@ -1,5 +1,5 @@ import { beforeEach, expect, test, vi } from 'vitest'; -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createLiveTestHostEnvironment } from '@agent-device/test-kit'; import { OVERLAY_BUDGET_MS, type overlayRecordingTouches, @@ -43,7 +43,7 @@ beforeEach(() => { }); test('requires stable playable media before publishing finalization telemetry', async () => { - const result = await createScreenRecordingFinalizer().complete({ + const result = await createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ outputPath: '/tmp/capture.mp4', showTouches: false, gestureEvents: [], @@ -61,7 +61,7 @@ test('names the retry and the escape for a recording that never became playable' video.isPlayable.mockResolvedValueOnce(false); await expect( - createScreenRecordingFinalizer().complete({ + createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ outputPath: '/tmp/capture.mp4', showTouches: false, gestureEvents: [], @@ -79,7 +79,7 @@ test('names the retry and the escape for a recording that never became playable' }); test('burns touches into a playable recording without a warning', async () => { - const result = await createScreenRecordingFinalizer().complete({ + const result = await createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ outputPath: '/tmp/capture.mp4', showTouches: true, gestureEvents: [{ kind: 'tap', tMs: 500, x: 10, y: 20 }], @@ -91,7 +91,7 @@ test('burns touches into a playable recording without a warning', async () => { }); test('hands the caller fps to the touch overlay', async () => { - await createScreenRecordingFinalizer().complete({ + await createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ outputPath: '/tmp/capture.mp4', showTouches: true, gestureEvents: [{ kind: 'tap', tMs: 500, x: 10, y: 20 }], @@ -112,7 +112,7 @@ test('the overlay budget covers the playability checks that run before the overl vi.setSystemTime(Date.now() + playabilityMs); }); - await createScreenRecordingFinalizer().complete({ + await createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ outputPath: '/tmp/capture.mp4', showTouches: true, gestureEvents: [{ kind: 'tap', tMs: 500, x: 10, y: 20 }], @@ -139,7 +139,7 @@ test('#2707 reports a dropped overlay and keeps the raw when compositing throws' ), ); - const result = await createScreenRecordingFinalizer().complete({ + const result = await createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ outputPath: '/tmp/capture.mp4', showTouches: true, gestureEvents: [{ kind: 'tap', tMs: 500, x: 10, y: 20 }], @@ -161,7 +161,7 @@ test('#2707 reports a warning when a composited recording turns unplayable after video.isPlayable.mockResolvedValueOnce(false); }); - const result = await createScreenRecordingFinalizer().complete({ + const result = await createScreenRecordingFinalizer(createLiveTestHostEnvironment()).complete({ outputPath: '/tmp/capture.mp4', showTouches: true, gestureEvents: [{ kind: 'tap', tMs: 500, x: 10, y: 20 }], @@ -176,12 +176,16 @@ test('#2707 reports a warning when a composited recording turns unplayable after test('sniffs a collected copy without spawning the validator, and refuses one with no container', async () => { const validatorCalls = video.isPlayable.mock.calls.length + video.playable.mock.calls.length; await expect( - createScreenRecordingFinalizer().sniff({ outputPath: '/tmp/capture.collected.mp4' }), + createScreenRecordingFinalizer(createLiveTestHostEnvironment()).sniff({ + outputPath: '/tmp/capture.collected.mp4', + }), ).resolves.toBeUndefined(); video.container.mockResolvedValueOnce(false); await expect( - createScreenRecordingFinalizer().sniff({ outputPath: '/tmp/capture.collected.mp4' }), + createScreenRecordingFinalizer(createLiveTestHostEnvironment()).sniff({ + outputPath: '/tmp/capture.collected.mp4', + }), ).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { reason: 'recording-output-unplayable', retriable: true }, diff --git a/src/platform-runtime-screen-recording-finalizer-host.ts b/src/platform-runtime-screen-recording-finalizer-host.ts index 287fe6e7a6..dee28a5b67 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.ts @@ -1,7 +1,7 @@ import { AppError } from '@agent-device/kernel/errors'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; 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, @@ -16,8 +16,13 @@ import { waitForStableFile, } from '@agent-device/capture-kit/recording-video'; -export function createScreenRecordingFinalizer(): ScreenRecordingRuntimeHost['finalize'] { - return Object.freeze({ sniff: sniffRecording, complete: finalizeScreenRecording }); +export function createScreenRecordingFinalizer( + host: HostEnvironment, +): ScreenRecordingRuntimeHost['finalize'] { + return Object.freeze({ + sniff: async (input) => await sniffRecording(input), + complete: async (input) => await finalizeScreenRecording(host, input), + }); } async function sniffRecording(input: Readonly<{ outputPath: string }>): Promise { @@ -25,21 +30,22 @@ async function sniffRecording(input: Readonly<{ outputPath: string }>): Promise< } async function finalizeScreenRecording( + host: HostEnvironment, input: Parameters[0], ) { const overlayDeadline = startRecordingOverlayBudget(); await waitForStableFile(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] }, }); if (!input.showTouches || input.gestureEvents.length === 0) return { telemetryPath }; - return await overlayTouches(input, telemetryPath, overlayDeadline); + return await overlayTouches(host, input, telemetryPath, overlayDeadline); } async function overlayTouches( + host: HostEnvironment, input: Parameters[0], telemetryPath: string, deadline: Deadline, @@ -47,7 +53,6 @@ async function overlayTouches( const warning = getRecordingOverlaySupportWarning(); if (warning) return { telemetryPath, overlayWarning: warning }; try { - const host = commandHostEnvironment(); await overlayRecordingTouches({ host, videoPath: input.outputPath, diff --git a/src/platform-runtime-screen-recording-host.ts b/src/platform-runtime-screen-recording-host.ts index 7037bd90aa..b629bf9910 100644 --- a/src/platform-runtime-screen-recording-host.ts +++ b/src/platform-runtime-screen-recording-host.ts @@ -1,17 +1,18 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { OwnedProcessRecordWriter } from '@agent-device/contracts/platform-runtime-host'; import type { ScreenRecordingRuntimeHost } from '@agent-device/contracts/screen-recording-runtime-host'; /** Lazy host-only composition; platform semantics stay package-owned. */ -export function createScreenRecordingRuntimeHost( - options: { - ownedProcesses?: OwnedProcessRecordWriter; - } = {}, -): ScreenRecordingRuntimeHost { +export function createScreenRecordingRuntimeHost(options: { + ownedProcesses?: OwnedProcessRecordWriter; + host: HostEnvironment; +}): ScreenRecordingRuntimeHost { + const host = options.host; const android: ScreenRecordingRuntimeHost['android'] = Object.freeze({ resolve: async (device) => { const { createAndroidScreenRecordingTransport } = await import('./platform-runtime-screen-recording-android-host.ts'); - return await createAndroidScreenRecordingTransport(device); + return await createAndroidScreenRecordingTransport(device, host); }, }); const web: ScreenRecordingRuntimeHost['web'] = Object.freeze({ @@ -42,12 +43,12 @@ export function createScreenRecordingRuntimeHost( sniff: async (input) => { const { createScreenRecordingFinalizer } = await import('./platform-runtime-screen-recording-finalizer-host.ts'); - await createScreenRecordingFinalizer().sniff(input); + await createScreenRecordingFinalizer(host).sniff(input); }, complete: async (input, signal) => { const { createScreenRecordingFinalizer } = await import('./platform-runtime-screen-recording-finalizer-host.ts'); - return await createScreenRecordingFinalizer().complete(input, signal); + return await createScreenRecordingFinalizer(host).complete(input, signal); }, }); const ownedProcesses: OwnedProcessRecordWriter = @@ -57,7 +58,7 @@ export function createScreenRecordingRuntimeHost( clear: () => {}, }); return Object.freeze({ - apple: createLazyAppleHost(), + apple: createLazyAppleHost(host), android, harmony: createLazyHarmonyHost(), web, @@ -67,11 +68,11 @@ export function createScreenRecordingRuntimeHost( }); } -function createLazyAppleHost(): ScreenRecordingRuntimeHost['apple'] { +function createLazyAppleHost(host: HostEnvironment): ScreenRecordingRuntimeHost['apple'] { const load = async () => { const { createAppleScreenRecordingHost } = await import('./platform-runtime-screen-recording-apple-host.ts'); - return createAppleScreenRecordingHost(); + return createAppleScreenRecordingHost(host); }; return Object.freeze({ availability: async (device) => (await load()).availability(device), diff --git a/src/platform-runtime-toolchain-host.ts b/src/platform-runtime-toolchain-host.ts index 011b44a646..3f193ae4cc 100644 --- a/src/platform-runtime-toolchain-host.ts +++ b/src/platform-runtime-toolchain-host.ts @@ -13,7 +13,7 @@ export function createHostToolchainPreparer(env: Readonly): HostToolchai if (family === 'harmonyos') { const { ensureHarmonyToolchainPathConfigured } = await import('@agent-device/platform-harmonyos'); - await ensureHarmonyToolchainPathConfigured(); + await ensureHarmonyToolchainPathConfigured(env); } }, }); diff --git a/src/platform-runtime.test.ts b/src/platform-runtime.test.ts index bbeb6075d3..dec948e660 100644 --- a/src/platform-runtime.test.ts +++ b/src/platform-runtime.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createLiveTestHostEnvironment } from '@agent-device/test-kit'; import { PLATFORMS } from '@agent-device/kernel/device'; import { describe, expect, test, vi } from 'vitest'; @@ -66,27 +66,32 @@ describe('platform module metadata composition', () => { diagnostics: { emit: () => {} }, progress: { report: () => {} }, } as const; - const providerGateways = await createPlatformDeviceInventoryGateways(process.env, { - discover: async () => ({ - kind: 'inventory', - devices: [ - { - platform: 'web', - id: 'provider-web', - name: 'Provider Web', - kind: 'device', - target: 'desktop', - booted: true, - }, - ], - }), - }); + const providerGateways = await createPlatformDeviceInventoryGateways( + createLiveTestHostEnvironment(), + { + discover: async () => ({ + kind: 'inventory', + devices: [ + { + platform: 'web', + id: 'provider-web', + name: 'Provider Web', + kind: 'device', + target: 'desktop', + booted: true, + }, + ], + }), + }, + ); expect(await providerGateways.providerFirst.discover({ platform: 'web' }, scope)).toHaveLength( 1, ); expect(hostAdapter.evaluations).toBe(0); - const localGateways = await createPlatformDeviceInventoryGateways(process.env); + const localGateways = await createPlatformDeviceInventoryGateways( + createLiveTestHostEnvironment(), + ); expect(await localGateways.localOnly.discover({ platform: 'web' }, scope)).toHaveLength(1); expect(hostAdapter.evaluations).toBe(0); diff --git a/src/platform-runtime.ts b/src/platform-runtime.ts index 7eb2d03ed0..e405e3972f 100644 --- a/src/platform-runtime.ts +++ b/src/platform-runtime.ts @@ -3,7 +3,10 @@ import type { ProviderDeviceRuntime, } from '@agent-device/contracts/device'; import type { AppLogSessionArtifacts } from '@agent-device/contracts/app-log-runtime'; -import type { OwnedProcessRecordWriter } from '@agent-device/contracts/platform-runtime-host'; +import type { + HostEnvironment, + OwnedProcessRecordWriter, +} from '@agent-device/contracts/platform-runtime-host'; import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; import type { DeviceShutdownRuntimeDependencies } from '@agent-device/contracts/device-shutdown-runtime'; import { @@ -102,14 +105,14 @@ export function createPlatformModuleRegistryForHost( } export async function createPlatformDeviceInventoryGateways( - env: Readonly>, + host: HostEnvironment, provider?: ProviderDeviceInventorySource, ): Promise { return await attachAppleInstalledAppProbe({ - registry: createPlatformModuleRegistryForHost(env), + registry: createPlatformModuleRegistryForHost(host.env), loadHost: async () => { const { createDeviceInventoryHost } = await import('./platform-runtime-host.ts'); - return createDeviceInventoryHost(env); + return createDeviceInventoryHost(host); }, provider, }); @@ -139,7 +142,7 @@ type Platform = PlatformRuntimeModule['family']; export function createPlatformRuntimeGateway( options: Readonly<{ - env: Readonly>; + host: HostEnvironment; providerRuntimes?: readonly ProviderDeviceRuntime[]; providerModules?: readonly PlatformRuntimeProviderRegistration[]; resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; @@ -154,14 +157,15 @@ export function createPlatformRuntimeGateway( const { createPlatformRuntimeHost, createSnapshotRuntimeHost, loadMacOsSurfaceSnapshot } = await import('./platform-runtime-operation-host.ts'); return createPlatformRuntimeHost({ - env: options.env, + host: options.host, sessionsDir: options.sessionsDir, resolveSessionArtifacts: options.resolveSessionArtifacts, shutdownLoaders, assertShutdownAllowed: options.assertShutdownAllowed, snapshot: createSnapshotRuntimeHost({ - linux: captureLinuxSurfaceSnapshot, - macos: loadMacOsSurfaceSnapshot, + linux: (snapshotOptions, signal) => captureLinuxSurfaceSnapshot(snapshotOptions, signal), + macos: (snapshotOptions, signal) => + loadMacOsSurfaceSnapshot(snapshotOptions, signal, options.host.env), }), ownedProcesses: options.ownedProcesses, }); diff --git a/src/platform-runtime/request-providers.ts b/src/platform-runtime/request-providers.ts index 9567159d18..41e5938da6 100644 --- a/src/platform-runtime/request-providers.ts +++ b/src/platform-runtime/request-providers.ts @@ -17,6 +17,7 @@ import type { AppleToolProvider } from '@agent-device/platform-apple/tool-provid import type { LinuxToolProvider } from '@agent-device/platform-linux'; import type { VegaToolProvider } from '@agent-device/platform-vega'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { AppleSimulatorScreenRecordingTransport } from '../platform-runtime-screen-recording-apple-transport.ts'; import type { AppleRunnerScreenRecordingTransport } from '../platform-runtime-screen-recording-apple-runner-transport.ts'; import { type OwnedProcessRecordStore } from '@agent-device/host-kit/process'; @@ -64,6 +65,8 @@ export type PlatformProviderResolvers = { }; export type DefaultWebProviderOptions = Readonly<{ + /** The composing runtime's host; the default web provider spawns managed backends with it. */ + host: HostEnvironment; stateDir?: string; openWebSessionNames: () => readonly string[]; ownedProcessRecords?: OwnedProcessRecordStore; @@ -148,6 +151,7 @@ async function providersForContext( } const { createAgentBrowserWebProvider } = await import('@agent-device/platform-web'); const defaultProvider = await createAgentBrowserWebProvider({ + host: defaultWebProvider.host, session: context.session?.name ?? context.requestedSession, stateDir: defaultWebProvider.stateDir, openWebSessionNames: defaultWebProvider.openWebSessionNames, diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index a0c9d400a6..f7d5ec03e2 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -149,7 +149,7 @@ export async function createProviderScenarioHarness( platformRuntime?.providerModules ?? createProviderPlatformRuntimeRegistrations(configuredRuntimes); const defaultDeviceRuntimeGateway = createPlatformRuntimeGateway({ - env: input.host.env, + host: input.host, assertShutdownAllowed: input.assertShutdownAllowed, providerRuntimes: configuredRuntimes, providerModules, @@ -189,6 +189,7 @@ export async function createProviderScenarioHarness( appleSimulatorScreenRecordingTransport, }, defaultWebProvider: { + host: input.host, stateDir: input.baseDir, openWebSessionNames: input.openWebSessionNames, ownedProcessRecords: rootDeps.ownedProcessRecords, diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index b56efec39e..f0532010da 100644 --- a/test/integration/provider-scenarios/ios-alert-settings.test.ts +++ b/test/integration/provider-scenarios/ios-alert-settings.test.ts @@ -205,7 +205,11 @@ function createRecordingPlatformRuntimeGateway(params: { throw new TypeError('The iOS provider scenario requires an explicit Apple leaf'); } const appleOs = device.appleOs; - const interactor = await applePlugin.createInteractor(device, {}); + const interactor = await applePlugin.createInteractor( + device, + {}, + { env: process.env, cwd: process.cwd() }, + ); // Each alert leg resolves its interactor per call from the input's execution metadata, // exactly like the production alert binding. The request-scoped runner provider is keyed by // the request id that metadata carries, so a bind-time interactor with an empty runner @@ -214,10 +218,14 @@ function createRecordingPlatformRuntimeGateway(params: { leg: 'readAlert' | 'awaitAlert' | 'acceptAlert' | 'dismissAlert', input: AlertRuntimeInput, ): Promise> => { - const alertInteractor = await applePlugin.createInteractor(device, { - ...input.execution, - appBundleId: input.appBundleId, - }); + const alertInteractor = await applePlugin.createInteractor( + device, + { + ...input.execution, + appBundleId: input.appBundleId, + }, + { env: process.env, cwd: process.cwd() }, + ); return await alertInteractor[leg]!(alertOptions(input)); }; return { diff --git a/test/integration/provider-scenarios/linux-world.ts b/test/integration/provider-scenarios/linux-world.ts index f6ccd15ee3..bf6f30b172 100644 --- a/test/integration/provider-scenarios/linux-world.ts +++ b/test/integration/provider-scenarios/linux-world.ts @@ -45,6 +45,9 @@ export async function createLinuxDesktopWorld(): Promise { let clipboardText = ''; const linuxToolProvider = await createLocalLinuxToolProvider({ + // The harness names the display env on the process before this line; snapshot it as this + // scenario's host (#3794). + host: { env: { ...process.env }, cwd: process.cwd() }, whichCommand: async (cmd) => cmd === 'gnome-calculator' || cmd === 'xdotool' || cmd === 'wmctrl', runCommand: async (cmd, args) => { @@ -161,7 +164,9 @@ const inventoryScope: PlatformRequestScope = Object.freeze({ }); async function discoverLocalLinuxInventory(): Promise { - const source = await linuxInventoryModule.loadInventory(createDeviceInventoryHost(process.env)); + const source = await linuxInventoryModule.loadInventory( + createDeviceInventoryHost({ env: process.env, cwd: process.cwd() }), + ); return [...(await source.discover({ platform: 'linux' }, inventoryScope))]; } diff --git a/test/integration/provider-scenarios/no-record-recorder-routes.test.ts b/test/integration/provider-scenarios/no-record-recorder-routes.test.ts index e6bd456612..824f733bda 100644 --- a/test/integration/provider-scenarios/no-record-recorder-routes.test.ts +++ b/test/integration/provider-scenarios/no-record-recorder-routes.test.ts @@ -43,6 +43,7 @@ test('--no-record keeps gesture/back/home out of a recorded session script', asy command: argv[0] as CommandName, positionals: parseArgs(argv, { strictFlags: true }).positionals, flags: { ...parseArgs(argv, { strictFlags: true }).flags, ...selection }, + env: process.env, }); // Control: a recorded action, no flag — must land in the script, proving diff --git a/test/integration/provider-scenarios/provider-ios-runner-transport.test.ts b/test/integration/provider-scenarios/provider-ios-runner-transport.test.ts index 6895cb665b..81fd9d4fc4 100644 --- a/test/integration/provider-scenarios/provider-ios-runner-transport.test.ts +++ b/test/integration/provider-scenarios/provider-ios-runner-transport.test.ts @@ -296,7 +296,10 @@ function createRunnerTransportInteractor( runnerContext: RunnerContext | undefined, ): Interactor { const runner = runnerContext ?? {}; - const implementation = applePlugin.createInteractor(DEVICE, runner); + const implementation = applePlugin.createInteractor(DEVICE, runner, { + env: process.env, + cwd: process.cwd(), + }); return new Proxy({} as Interactor, { get(_target, property) { if (property === 'then') return undefined; diff --git a/test/integration/provider-scenarios/web-desktop.test.ts b/test/integration/provider-scenarios/web-desktop.test.ts index 2090c5d958..eb9329cefd 100644 --- a/test/integration/provider-scenarios/web-desktop.test.ts +++ b/test/integration/provider-scenarios/web-desktop.test.ts @@ -104,6 +104,7 @@ test('Provider-backed integration web desktop flow uses semantic web provider ca assert.equal(matches?.length, 1, JSON.stringify(response.json?.result?.data)); assert.equal('ref' in (matches?.[0] ?? {}), false, JSON.stringify(matches?.[0])); const cli = await interactionCliOutputFormatters.find({ + env: process.env, input: {}, result: response.json?.result?.data, }); diff --git a/test/integration/provider-scenarios/web-provider.test.ts b/test/integration/provider-scenarios/web-provider.test.ts index 477b4ffa35..953462e119 100644 --- a/test/integration/provider-scenarios/web-provider.test.ts +++ b/test/integration/provider-scenarios/web-provider.test.ts @@ -218,7 +218,10 @@ test('non-dense browser refs survive snapshot storage and routed fill and click' const stateDir = mkdtempForTestSync('web-ref-route-'); installFakeManagedAgentBrowser(stateDir); await withNodeRuntime({ version: '24.0.0' }, async () => { - const provider = await createAgentBrowserWebProvider({ stateDir }); + const provider = await createAgentBrowserWebProvider({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); await withCommandExecutorOverride( async (_command, args) => { const [command, ref, text] = args.slice(1); diff --git a/test/integration/smoke-tvos-platform-coverage.test.ts b/test/integration/smoke-tvos-platform-coverage.test.ts index c268c20cad..18d4d18489 100644 --- a/test/integration/smoke-tvos-platform-coverage.test.ts +++ b/test/integration/smoke-tvos-platform-coverage.test.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; -import { TVOS_SIMULATOR } from '@agent-device/test-kit'; +import { TVOS_SIMULATOR, createLiveTestHostEnvironment } from '@agent-device/test-kit'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { assertCoverageClassificationSummaryWiredToManifest, @@ -83,7 +83,7 @@ test('tvOS gesture contract preserves the typed multi-touch denial', async () => // R42: the denial is now the Apple owner's own fact for the tvOS leaf, and the refusal a // gesture reports is that fact's hint under the shared wording. const facts = await createPlatformRuntimeGateway({ - env: process.env, + host: createLiveTestHostEnvironment(), resolveSessionArtifacts: () => ({ outputPath: '/sessions/tvos/app.log', pidPath: '/sessions/tvos/app-log.pid', diff --git a/test/integration/smoke-web-platform.test.ts b/test/integration/smoke-web-platform.test.ts index e0243f7206..5420f6b02c 100644 --- a/test/integration/smoke-web-platform.test.ts +++ b/test/integration/smoke-web-platform.test.ts @@ -182,7 +182,10 @@ async function runWebShutdownSmoke(context: WebSmokeContext): Promise { const stateDir = context.env.AGENT_DEVICE_STATE_DIR; assert.ok(stateDir, 'expected the smoke context to configure a state dir'); - status = await getManagedAgentBrowserStatus({ stateDir }); + status = await getManagedAgentBrowserStatus({ + stateDir, + host: { env: process.env, cwd: process.cwd() }, + }); const before = await inspectManagedAgentBrowserProcesses(status); assert.ok( diff --git a/test/output-economy/render-fixtures.ts b/test/output-economy/render-fixtures.ts index ae8e79748a..08fe93812f 100644 --- a/test/output-economy/render-fixtures.ts +++ b/test/output-economy/render-fixtures.ts @@ -24,7 +24,7 @@ import { } from './selection-fixtures.ts'; function interactionText(result: Record): string { - return interactionCliOutputFormatters.press({ input: {}, result }).text ?? ''; + return interactionCliOutputFormatters.press({ env: process.env, input: {}, result }).text ?? ''; } export async function renderOutputFixtures() { @@ -35,7 +35,8 @@ export async function renderOutputFixtures() { const error = normalizeError(ACTIONABLE_ERROR); const errorPolicyNormalized = normalizeError(POLICY_NORMALIZED_ERROR); const mcpSnapshot = await createCommandToolExecutor({ - createClient: () => ({}) as AgentDeviceClient, + createClient: () => + ({ host: { env: process.env, cwd: process.cwd() } }) as unknown as AgentDeviceClient, runCommand: async () => SNAPSHOT_DAEMON_RESULT, }).execute('snapshot', {}); const workflow = await renderRoutineWorkflow(); diff --git a/test/output-economy/routine-workflow.ts b/test/output-economy/routine-workflow.ts index d273ab2e9f..415f19a0df 100644 --- a/test/output-economy/routine-workflow.ts +++ b/test/output-economy/routine-workflow.ts @@ -158,12 +158,13 @@ function readCliText(output: { text?: string | null }): string { } function interactionText(result: CommandRequestResult): string { - return readCliText(interactionCliOutputFormatters.press({ input: {}, result })); + return readCliText(interactionCliOutputFormatters.press({ env: process.env, input: {}, result })); } async function renderMcpSnapshot(): Promise { return await createCommandToolExecutor({ - createClient: () => ({}) as AgentDeviceClient, + createClient: () => + ({ host: { env: process.env, cwd: process.cwd() } }) as unknown as AgentDeviceClient, runCommand: async () => SNAPSHOT_DAEMON_RESULT, }).execute('snapshot', {}); } @@ -222,7 +223,11 @@ export async function renderRoutineWorkflow(): Promise<{ samples: { cli: { text: readCliText( - interactionCliOutputFormatters.get({ input: { format: 'text' }, result: READ_RESULT }), + interactionCliOutputFormatters.get({ + env: process.env, + input: { format: 'text' }, + result: READ_RESULT, + }), ), }, },