diff --git a/packages/ad-script/src/internal/vars.ts b/packages/ad-script/src/internal/vars.ts index 5c97c315dd..8df0846476 100644 --- a/packages/ad-script/src/internal/vars.ts +++ b/packages/ad-script/src/internal/vars.ts @@ -102,7 +102,10 @@ export function readReplayCliEnvEntries(raw: unknown): string[] { : []; } -export function readReplayShellEnvSource(raw: unknown): NodeJS.ProcessEnv { +export function readReplayShellEnvSource( + raw: unknown, + fallbackEnv: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { if (raw && typeof raw === 'object' && !Array.isArray(raw)) { const result: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(raw)) { @@ -110,7 +113,7 @@ export function readReplayShellEnvSource(raw: unknown): NodeJS.ProcessEnv { } return result; } - return process.env; + return fallbackEnv; } // `${NAME}` / `${NAME:-fallback}` / `\${` interpolation, as a single-pass diff --git a/packages/contracts/src/replay-backend.ts b/packages/contracts/src/replay-backend.ts index a87999f5c9..86857c4517 100644 --- a/packages/contracts/src/replay-backend.ts +++ b/packages/contracts/src/replay-backend.ts @@ -1,6 +1,7 @@ import type { DaemonError } from '@agent-device/kernel/errors'; import type { DaemonResponse, SessionRuntimeHints } from '@agent-device/kernel/contracts'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; import type { CommandFlags } from './command-flags.ts'; import type { DaemonWireRequest } from './daemon-wire-request.ts'; @@ -119,6 +120,8 @@ export type ReplayBackendSession = Readonly<{ /** One replay run as a backend sees it: the prepared request, its session, and the dispatch port. */ export type ReplayBackendCommand = Readonly<{ request: DaemonWireRequest; + /** The daemon's host facts (env and working directory); a backend never reads the process itself. */ + host: HostEnvironment; /** * True when the request reached the daemon over its public HTTP surface only: flow scripts are * then untrusted and `runScript` HTTP calls may not reach private addresses. Absent means a @@ -233,6 +236,8 @@ export type ReplayBackendHost = Readonly<{ req: DaemonWireRequest; sessionName: string; resolvedPath: string; + /** The command's host working directory, which relative flow paths resolve against. */ + hostCwd: string; }): Record; /** Reads one file out of the request's script source bundle; throws when it was not sent. */ readScriptSourceFile(bundle: ReplayScriptSourceBundle, resolvedPath: string): string; @@ -246,6 +251,7 @@ export type ReplayBackendHost = Readonly<{ resolveTargetDevice( flags: CommandFlags, binding: ReplayBackendDeviceBinding, + env: EnvMap, ): Promise; /** The abort signal of the in-flight daemon request. */ requestSignal(requestId: string | undefined): AbortSignal | undefined; diff --git a/packages/daemon/src/core/snapshot-session.ts b/packages/daemon/src/core/snapshot-session.ts index e65439e2bd..8caf39a234 100644 --- a/packages/daemon/src/core/snapshot-session.ts +++ b/packages/daemon/src/core/snapshot-session.ts @@ -20,7 +20,8 @@ export async function resolveSessionDevice( const { requireNamedSessionHoldsDevice } = await import('./explicit-session-resolution.ts'); requireNamedSessionHoldsDevice({ req, sessionAddress: sessionName, session }); } - const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {})); + const device = + session?.device ?? (await resolveTargetDevice(req.flags ?? {}, { env: sessionStore.host.env })); return { ref, session, device }; } diff --git a/packages/daemon/src/execution/handlers/session-clipboard.ts b/packages/daemon/src/execution/handlers/session-clipboard.ts index 72e9794cc2..abb925fbda 100644 --- a/packages/daemon/src/execution/handlers/session-clipboard.ts +++ b/packages/daemon/src/execution/handlers/session-clipboard.ts @@ -167,7 +167,12 @@ export async function handleSessionClipboardCommand(params: { return errorResponse('INVALID_ARGS', 'clipboard requires a subcommand: read or write'); } - const device = await resolveCommandDevice({ session, flags, platformServices }); + const device = await resolveCommandDevice({ + session, + flags, + env: sessionStore.host.env, + platformServices, + }); if (ref) sessionStore.requireCurrent(ref); const bound = await resolveBoundClipboardRuntime({ device, diff --git a/packages/daemon/src/execution/handlers/session-selector-dispatch.ts b/packages/daemon/src/execution/handlers/session-selector-dispatch.ts index ec9872c927..d1c1b553d3 100644 --- a/packages/daemon/src/execution/handlers/session-selector-dispatch.ts +++ b/packages/daemon/src/execution/handlers/session-selector-dispatch.ts @@ -77,6 +77,7 @@ async function runSessionOrSelectorDispatch(params: { const device = await resolveCommandDevice({ session, flags, + env: sessionStore.host.env, platformServices: params.platformServices, }); if (ref) sessionStore.requireCurrent(ref); diff --git a/packages/daemon/src/execution/selector-runtime-backend.ts b/packages/daemon/src/execution/selector-runtime-backend.ts index aa329d7900..a58b76d82e 100644 --- a/packages/daemon/src/execution/selector-runtime-backend.ts +++ b/packages/daemon/src/execution/selector-runtime-backend.ts @@ -127,7 +127,9 @@ async function resolveSelectorRuntimeDevice( }); } if (!session && requireSession) return { ok: false, response: noActiveSessionError() }; - const device = session?.device ?? (await resolveTargetDevice(params.req.flags ?? {})); + const device = + session?.device ?? + (await resolveTargetDevice(params.req.flags ?? {}, { env: params.sessionStore.host.env })); return { ok: true, ref, session, device }; } diff --git a/packages/daemon/src/resources/__tests__/session-device-resolution.test.ts b/packages/daemon/src/resources/__tests__/session-device-resolution.test.ts index 8a9cd97b65..2ff4e5bdf9 100644 --- a/packages/daemon/src/resources/__tests__/session-device-resolution.test.ts +++ b/packages/daemon/src/resources/__tests__/session-device-resolution.test.ts @@ -71,7 +71,7 @@ async function withMockedPlatform(platform: NodeJS.Platform, fn: () => Promis test('refreshSessionDeviceIfNeeded keeps iOS simulator session device on non-mac hosts', async () => { const device = await withMockedPlatform('linux', async () => - refreshSessionDeviceIfNeeded(iosSimulatorSession.device, platformServices), + refreshSessionDeviceIfNeeded(iosSimulatorSession.device, platformServices, process.env), ); expect(device).toBe(iosSimulatorSession.device); @@ -85,6 +85,7 @@ test('resolveCommandDevice keeps an existing session for a platform-only filter' platformServices: platformServices, session: iosSimulatorSession, flags: { platform: 'ios' }, + env: process.env, }), ); @@ -99,6 +100,7 @@ test('resolveCommandDevice does not prepare a sessionless device', async () => { await resolveCommandDevice({ session: undefined, flags: { platform: 'ios' }, + env: process.env, platformServices: platformServices, }); @@ -113,6 +115,7 @@ test('refreshSessionDeviceIfNeeded keeps provider-owned iOS simulators out of lo refreshSessionDeviceIfNeeded( { ...iosSimulatorSession.device, id: 'limrun:ios:lease-1' }, platformServices, + process.env, ), ); @@ -127,7 +130,7 @@ test('refreshSessionDeviceIfNeeded skips re-resolve while the iOS runner session }); const device = await withMockedPlatform('darwin', async () => - refreshSessionDeviceIfNeeded(iosSimulatorSession.device, platformServices), + refreshSessionDeviceIfNeeded(iosSimulatorSession.device, platformServices, process.env), ); expect(device).toEqual({ ...iosSimulatorSession.device, booted: true }); @@ -143,7 +146,7 @@ test('refreshSessionDeviceIfNeeded re-resolves when the iOS runner session is go mockResolveTargetDevice.mockResolvedValue(resolved); const device = await withMockedPlatform('darwin', async () => - refreshSessionDeviceIfNeeded(iosSimulatorSession.device, platformServices), + refreshSessionDeviceIfNeeded(iosSimulatorSession.device, platformServices, process.env), ); expect(device).toBe(resolved); diff --git a/packages/daemon/src/resources/session-device-resolution.ts b/packages/daemon/src/resources/session-device-resolution.ts index 5aad910c4b..4eb8453e45 100644 --- a/packages/daemon/src/resources/session-device-resolution.ts +++ b/packages/daemon/src/resources/session-device-resolution.ts @@ -1,4 +1,5 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError, sessionOrDeviceSelectorRequiredDetails } from '@agent-device/kernel/errors'; import { isActiveProviderDevice } from '../core/provider-device-admission.ts'; @@ -31,6 +32,7 @@ export function requireSessionOrExplicitSelector( export async function resolveCommandDevice(params: { session: SessionState | undefined; flags: DaemonRequest['flags'] | undefined; + env: EnvMap; androidAvdSelection?: 'running-only' | 'include-stopped'; platformServices: DaemonPlatformServices; }): Promise { @@ -38,15 +40,21 @@ export async function resolveCommandDevice(params: { const device = shouldUseExplicitIdentity || !params.session ? await resolveTargetDevice(params.flags ?? {}, { + env: params.env, androidAvdSelection: params.androidAvdSelection, }) - : await refreshSessionDeviceIfNeeded(params.session.device, params.platformServices); + : await refreshSessionDeviceIfNeeded( + params.session.device, + params.platformServices, + params.env, + ); return device; } export async function refreshSessionDeviceIfNeeded( device: DeviceInfo, platformServices: DaemonPlatformServices, + env: EnvMap, ): Promise { if (isActiveProviderDevice(device)) { return device; @@ -71,19 +79,22 @@ export async function refreshSessionDeviceIfNeeded( ...(device.simulatorSetPath ? { iosSimulatorDeviceSet: device.simulatorSetPath } : {}), }; try { - return await resolveTargetDevice(exactSelector); + return await resolveTargetDevice(exactSelector, { env }); } catch (error) { if (!(error instanceof AppError) || error.code !== 'DEVICE_NOT_FOUND') { throw error; } } - return await resolveTargetDevice({ - platform: 'ios', - target: device.target, - device: device.name, - ...(device.simulatorSetPath ? { iosSimulatorDeviceSet: device.simulatorSetPath } : {}), - }); + return await resolveTargetDevice( + { + platform: 'ios', + target: device.target, + device: device.name, + ...(device.simulatorSetPath ? { iosSimulatorDeviceSet: device.simulatorSetPath } : {}), + }, + { env }, + ); } export function selectorTargetsSessionDevice( diff --git a/packages/daemon/src/server/__tests__/replay-device-selection.test.ts b/packages/daemon/src/server/__tests__/replay-device-selection.test.ts index 1f7e889e26..b9cf7ec583 100644 --- a/packages/daemon/src/server/__tests__/replay-device-selection.test.ts +++ b/packages/daemon/src/server/__tests__/replay-device-selection.test.ts @@ -20,14 +20,17 @@ test('replay leaves deep-link opens to normal device resolution', async () => { fs.writeFileSync(replayPath, 'open demo://checkout\n'); expect( - await buildReplayTargetDeviceResolution({ - token: 'test-token', - session: 'default', - command: 'replay', - positionals: [replayPath], - flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) }, - meta: { cwd: root }, - }), + await buildReplayTargetDeviceResolution( + { + token: 'test-token', + session: 'default', + command: 'replay', + positionals: [replayPath], + flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) }, + meta: { cwd: root }, + }, + process.env, + ), ).toBeUndefined(); }); @@ -51,14 +54,17 @@ test('native replay uses its authored Android runtime setting without an iOS app 'runtime set --platform android --metro-port 8081\nopen com.example.demo\n', ); - const resolution = await buildReplayTargetDeviceResolution({ - token: 'test-token', - session: 'default', - command: 'replay', - positionals: [replayPath], - flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) }, - meta: { cwd: root }, - }); + const resolution = await buildReplayTargetDeviceResolution( + { + token: 'test-token', + session: 'default', + command: 'replay', + positionals: [replayPath], + flags: { replayScriptSource: replayScriptSourceBundleFor(replayPath) }, + meta: { cwd: root }, + }, + process.env, + ); // The request's own flags ride through untouched, so compare the decision this function makes: // the authored platform, and no iOS app-probe options. @@ -81,17 +87,20 @@ test('a replay whose wire bundle has a malformed entry stays advisory', async () for (const bundle of malformedBundles) { await expect( - buildReplayTargetDeviceResolution({ - token: 'test-token', - session: 'default', - command: 'replay', - positionals: [], - flags: { - replayBackend: 'maestro', - replayScriptSource: bundle, + buildReplayTargetDeviceResolution( + { + token: 'test-token', + session: 'default', + command: 'replay', + positionals: [], + flags: { + replayBackend: 'maestro', + replayScriptSource: bundle, + }, + meta: { cwd: mkdtempForTestSync('agent-device-replay-device-selection-') }, }, - meta: { cwd: mkdtempForTestSync('agent-device-replay-device-selection-') }, - }), + process.env, + ), ).resolves.toBeUndefined(); } }); @@ -101,19 +110,22 @@ test('a Maestro flow pre-binds an iOS replay to its static appId', async () => { const flowPath = path.join(root, 'checkout.yaml'); fs.writeFileSync(flowPath, 'appId: com.example.demo\n---\n- launchApp\n'); - const resolution = await buildReplayTargetDeviceResolution({ - token: 'test-token', - session: 'default', - command: 'replay', - positionals: [flowPath], - flags: { - platform: 'ios', - replayBackend: 'maestro', - replayScriptSource: await maestroScriptSourceBundleFor(flowPath), + const resolution = await buildReplayTargetDeviceResolution( + { + token: 'test-token', + session: 'default', + command: 'replay', + positionals: [flowPath], + flags: { + platform: 'ios', + replayBackend: 'maestro', + replayScriptSource: await maestroScriptSourceBundleFor(flowPath), + }, + meta: { cwd: root }, }, - meta: { cwd: root }, - }); + process.env, + ); - expect(resolution?.options).toEqual(appTargetResolutionOptions('com.example.demo')); + expect(resolution?.options).toEqual(appTargetResolutionOptions('com.example.demo', process.env)); expect(resolution?.options).toBeDefined(); }); diff --git a/packages/daemon/src/server/__tests__/request-router-open.test.ts b/packages/daemon/src/server/__tests__/request-router-open.test.ts index 57c6141f18..e4b9d19667 100644 --- a/packages/daemon/src/server/__tests__/request-router-open.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-open.test.ts @@ -172,8 +172,8 @@ test('fresh open uses app-aware device selection for advisory locking and dispat expect(response.ok).toBe(true); expect(mockResolveTargetDevice.mock.calls).toEqual([ - [{ platform: 'ios' }, { appleSimulatorAppTarget: 'com.example.demo' }], - [{ platform: 'ios' }, { appleSimulatorAppTarget: 'com.example.demo' }], + [{ platform: 'ios' }, { appleSimulatorAppTarget: 'com.example.demo', env: process.env }], + [{ platform: 'ios' }, { appleSimulatorAppTarget: 'com.example.demo', env: process.env }], ]); expect(sessionStore.get('session-app-aware')?.device.id).toBe(appDevice.id); }); @@ -208,7 +208,7 @@ test('fresh replay reserves its authored app simulator before any replay step', expect(keys).toEqual(['session:fresh-replay', 'device:SIM-WITH-APP']); expect(mockResolveTargetDevice).toHaveBeenCalledWith( expect.objectContaining({ platform: 'ios' }), - { appleSimulatorAppTarget: 'com.example.demo' }, + { appleSimulatorAppTarget: 'com.example.demo', env: process.env }, ); }); @@ -265,7 +265,7 @@ test('fresh replay preserves an authored Android platform before advisory lockin expect(keys).toEqual(['session:fresh-replay-android', 'device:ANDROID-EMULATOR']); expect(mockResolveTargetDevice).toHaveBeenCalledWith( expect.objectContaining({ platform: 'android' }), - undefined, + expect.objectContaining({ env: process.env }), ); }); diff --git a/packages/daemon/src/server/replay-device-selection.ts b/packages/daemon/src/server/replay-device-selection.ts index 86ea2d5edc..c997c35571 100644 --- a/packages/daemon/src/server/replay-device-selection.ts +++ b/packages/daemon/src/server/replay-device-selection.ts @@ -4,6 +4,7 @@ import { type ReplayBackendId, } from '@agent-device/ad-script'; import type { ResolveTargetDeviceOptions } from '@agent-device/device-selection/dispatch-resolve'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { CommandFlags } from '@agent-device/contracts/command'; import { readReplayScriptSourceFile } from '@agent-device/replay-port/script-source'; import type { ReplayBackend } from '@agent-device/contracts/replay-backend'; @@ -34,6 +35,7 @@ export type ReplayTargetDeviceResolution = { */ export async function buildReplayTargetDeviceResolution( req: DaemonRequest, + env: EnvMap, ): Promise { if (req.command !== 'replay' || req.flags?.replayFrom !== undefined) return undefined; const bundle = req.flags?.replayScriptSource; @@ -44,9 +46,9 @@ export async function buildReplayTargetDeviceResolution( const source = readReplayScriptSourceFile(bundle, resolved); const backendId = resolveReplayFormat(resolved, req.flags?.replayBackend); if (backendId !== 'ad') { - return await readBackendReplayResolution(backendId, source, resolved, req.flags); + return await readBackendReplayResolution(backendId, source, resolved, req.flags, env); } - return readAdScriptResolution(source, req.flags); + return readAdScriptResolution(source, req.flags, env); }); } @@ -55,6 +57,7 @@ async function readBackendReplayResolution( source: string, resolvedPath: string, flags: DaemonRequest['flags'], + env: EnvMap, ): Promise { // Function-scoped through the #3377 registry: request binding sits in the daemon's startup // closure, and the engine sits one thunk behind the backend. @@ -66,6 +69,7 @@ async function readBackendReplayResolution( options: buildMaestroReplayTargetDeviceResolutionOptions( backend.inspectSource(source, resolvedPath).appTarget, flags?.platform, + env, ), }; } @@ -73,6 +77,7 @@ async function readBackendReplayResolution( function readAdScriptResolution( source: string, flags: DaemonRequest['flags'], + env: EnvMap, ): ReplayTargetDeviceResolution | undefined { const parsed = parseReplayInput(source, flags); const selection = readScriptReplaySelection(parsed.actions); @@ -82,7 +87,7 @@ function readAdScriptResolution( return { flags: platform && scriptFlags.platform === undefined ? { ...scriptFlags, platform } : scriptFlags, - options: platform === 'ios' ? appTargetResolutionOptions(selection.appTarget) : undefined, + options: platform === 'ios' ? appTargetResolutionOptions(selection.appTarget, env) : undefined, }; } diff --git a/packages/daemon/src/server/request-binding.ts b/packages/daemon/src/server/request-binding.ts index 43b719b4f0..c561298d2e 100644 --- a/packages/daemon/src/server/request-binding.ts +++ b/packages/daemon/src/server/request-binding.ts @@ -1,4 +1,5 @@ import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { hasDeviceSelectionInput } from '../core/device/device-selector-intent.ts'; import { applyRequestLockPolicy } from './request-lock-policy.ts'; import { buildOpenTargetDeviceResolutionOptions } from '@agent-device/device-selection/open-target'; @@ -57,7 +58,7 @@ export async function resolveRequestExecutionLockPlan(params: { } const keys = new Set([sessionExecutionLockKey(sessionName)]); - const device = await resolveFreshSessionDevice(req); + const device = await resolveFreshSessionDevice(req, sessionStore.host.env); if (device) keys.add(deviceExecutionLockKey(device.id)); return { keys: orderRequestExecutionLockKeys(keys), deviceId: device?.id }; } @@ -84,16 +85,19 @@ function resolveFreshSessionBindingRequest(req: DaemonRequest): DaemonRequest { } } -async function resolveFreshSessionDeviceLock(req: DaemonRequest): Promise< +async function resolveFreshSessionDeviceLock( + req: DaemonRequest, + env: EnvMap, +): Promise< | { flags: NonNullable; options: ReturnType | undefined; } | undefined > { - if (req.command === 'open') return resolveOpenDeviceLock(req); - if (req.command === 'replay') return await resolveReplayDeviceLock(req); - return resolveExplicitDeviceLock(req); + if (req.command === 'open') return resolveOpenDeviceLock(req, env); + if (req.command === 'replay') return await resolveReplayDeviceLock(req, env); + return resolveExplicitDeviceLock(req, env); } /** @@ -101,28 +105,36 @@ async function resolveFreshSessionDeviceLock(req: DaemonRequest): Promise< * lock is chosen. This runs before the request holds that lock and is never an authority on what * happens after: the locked request resolves and binds its device on its own. */ -async function resolveFreshSessionDevice(req: DaemonRequest): Promise { - const resolution = await resolveFreshSessionDeviceLock(resolveFreshSessionBindingRequest(req)); +async function resolveFreshSessionDevice( + req: DaemonRequest, + env: EnvMap, +): Promise { + const resolution = await resolveFreshSessionDeviceLock( + resolveFreshSessionBindingRequest(req), + env, + ); if (!resolution) return undefined; try { - return await resolveTargetDevice(resolution.flags, resolution.options); + return await resolveTargetDevice(resolution.flags, { ...resolution.options, env }); } catch { // Unresolvable here means unresolvable for lock selection too, which is already tolerated. return undefined; } } -function resolveOpenDeviceLock(req: DaemonRequest) { - const options = buildOpenTargetDeviceResolutionOptions(req.positionals?.[0]); +function resolveOpenDeviceLock(req: DaemonRequest, env: EnvMap) { + const options = buildOpenTargetDeviceResolutionOptions(req.positionals?.[0], env); return options ? { flags: req.flags ?? {}, options } : undefined; } -async function resolveReplayDeviceLock(req: DaemonRequest) { - return (await buildReplayTargetDeviceResolution(req)) ?? resolveExplicitDeviceLock(req); +async function resolveReplayDeviceLock(req: DaemonRequest, env: EnvMap) { + return (await buildReplayTargetDeviceResolution(req, env)) ?? resolveExplicitDeviceLock(req, env); } -function resolveExplicitDeviceLock(req: DaemonRequest) { - return hasDeviceSelectionInput(req.flags) ? { flags: req.flags ?? {}, options: {} } : undefined; +function resolveExplicitDeviceLock(req: DaemonRequest, env: EnvMap) { + return hasDeviceSelectionInput(req.flags) + ? { flags: req.flags ?? {}, options: { env } } + : undefined; } function sessionExecutionLockKey(sessionName: string): RequestExecutionLockKey { diff --git a/packages/daemon/src/server/request-platform-provider-context.test.ts b/packages/daemon/src/server/request-platform-provider-context.test.ts index decbfc98f9..80becbc9e5 100644 --- a/packages/daemon/src/server/request-platform-provider-context.test.ts +++ b/packages/daemon/src/server/request-platform-provider-context.test.ts @@ -25,6 +25,7 @@ test('request provider context follows an explicit device selector for an existi flags: { platform: 'ios', device: 'iPhone 17' }, }, existingSession: makeIosSession('default'), + env: process.env, }), ); @@ -41,6 +42,7 @@ test('request provider context skips sharded test orchestration requests', async await resolvePlatformProviderRequestContext({ req: { ...request('test'), flags: { platform: 'ios', shardAll: 2 } }, existingSession: undefined, + env: process.env, }), ); @@ -54,6 +56,7 @@ test('request provider context follows an explicit sessionless web selector', as await resolvePlatformProviderRequestContext({ req: { ...request('snapshot'), flags: { platform: 'web' } }, existingSession: undefined, + env: process.env, }), ); @@ -65,6 +68,7 @@ test('request provider context preserves an explicitly empty request id', async const context = await resolvePlatformProviderRequestContext({ req: { ...request('snapshot'), meta: { requestId: '' } }, existingSession: makeIosSession('ios-session'), + env: process.env, }); assert.equal(context?.requestId, ''); diff --git a/packages/daemon/src/server/request-platform-provider-context.ts b/packages/daemon/src/server/request-platform-provider-context.ts index e2c6cc9ba4..7b9ba019ab 100644 --- a/packages/daemon/src/server/request-platform-provider-context.ts +++ b/packages/daemon/src/server/request-platform-provider-context.ts @@ -1,4 +1,5 @@ import type { PlatformProviderRequestContext } from '@agent-device/contracts/platform-providers'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import { hasDeviceSelectionInput, @@ -20,9 +21,10 @@ import type { SessionState } from '../core/session-state.ts'; export async function resolvePlatformProviderRequestContext(params: { req: DaemonRequest; existingSession: SessionState | undefined; + env: EnvMap; useDefaultWebProvider?: boolean; }): Promise { - const device = await resolveScopedProviderDevice(params.req, params.existingSession); + const device = await resolveScopedProviderDevice(params.req, params.existingSession, params.env); if (!device) return undefined; return { @@ -49,6 +51,7 @@ export async function resolvePlatformProviderRequestContext(params: { async function resolveScopedProviderDevice( req: DaemonRequest, existingSession: SessionState | undefined, + env: EnvMap, ): Promise { const intent = resolveProviderDeviceResolutionIntent(req, { hasExistingSession: Boolean(existingSession), @@ -63,7 +66,7 @@ async function resolveScopedProviderDevice( // Provider-scope plumbing only: an unresolvable device means "no provider scope for this // request", never a failed request. The command's own device resolution reports errors. try { - return await resolveProviderTargetDevice(req); + return await resolveProviderTargetDevice(req, env); } catch { return undefined; } @@ -72,10 +75,13 @@ async function resolveScopedProviderDevice( } } -async function resolveProviderTargetDevice(req: DaemonRequest): Promise { +async function resolveProviderTargetDevice( + req: DaemonRequest, + env: EnvMap, +): Promise { const options = req.command === 'open' - ? buildOpenTargetDeviceResolutionOptions(req.positionals?.[0]) + ? buildOpenTargetDeviceResolutionOptions(req.positionals?.[0], env) : undefined; - return await resolveTargetDevice(req.flags ?? {}, options); + return await resolveTargetDevice(req.flags ?? {}, { ...options, env }); } diff --git a/packages/daemon/src/server/request-router.ts b/packages/daemon/src/server/request-router.ts index e3cbd0051d..f1da1f387c 100644 --- a/packages/daemon/src/server/request-router.ts +++ b/packages/daemon/src/server/request-router.ts @@ -318,6 +318,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { const context = await resolvePlatformProviderRequestContext({ req: lockedScope.req, existingSession: lockedScope.existingSession, + env: host.env, useDefaultWebProvider, }); return context diff --git a/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts b/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts index 3a2507465c..7cec9ffc24 100644 --- a/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts @@ -133,6 +133,7 @@ async function runWithTestNetworkFlag(publicNetworkOnly: boolean | undefined) { // Drive the real `replay test` handler and suite scheduler so the handler mapping and the // per-case forwarding of `publicNetworkOnly` are both on the asserted engine path. const response = await handleReplayTestCommand({ + host: createTestHostEnvironment(), req, sessionName: 'default', logPath: path.join(root, 'daemon.log'), diff --git a/packages/daemon/src/sessions/__tests__/replay-runtime/application.test.ts b/packages/daemon/src/sessions/__tests__/replay-runtime/application.test.ts index 7e449ed460..54250b1e4f 100644 --- a/packages/daemon/src/sessions/__tests__/replay-runtime/application.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-runtime/application.test.ts @@ -50,6 +50,7 @@ function replayCommand( ): ReplayCommand { return { ...splitReplayCommandRequest(req), + host: sessionStore.host, session: createReplaySession(req.session, path.join(root, 'daemon.log'), sessionStore), invoke: replayInvokeOverDispatch(invoke, req), dependencies: replayDaemonDependencies, diff --git a/packages/daemon/src/sessions/__tests__/replay-runtime/replay-command-fixture.ts b/packages/daemon/src/sessions/__tests__/replay-runtime/replay-command-fixture.ts index 22bd5ad86c..ed4b2c94c8 100644 --- a/packages/daemon/src/sessions/__tests__/replay-runtime/replay-command-fixture.ts +++ b/packages/daemon/src/sessions/__tests__/replay-runtime/replay-command-fixture.ts @@ -32,6 +32,7 @@ export function replayCommandForTest(params: ReplayCommandTestInput): ReplayComm const { req, sessionName, logPath, sessionStore, invoke, tracePath, onStep } = params; return { ...splitReplayCommandRequest(req), + host: sessionStore.host, session: createReplaySession(sessionName, logPath, sessionStore), invoke: replayInvokeOverDispatch(invoke, req), dependencies: replayDaemonDependencies, diff --git a/packages/daemon/src/sessions/__tests__/replay-runtime/session-replay-runtime-plan.test.ts b/packages/daemon/src/sessions/__tests__/replay-runtime/session-replay-runtime-plan.test.ts index 9f5bb95001..1dac764116 100644 --- a/packages/daemon/src/sessions/__tests__/replay-runtime/session-replay-runtime-plan.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-runtime/session-replay-runtime-plan.test.ts @@ -525,7 +525,7 @@ test('fresh typed Maestro replay resolves its configured app before runtime defa expect(response.ok).toBe(true); expect(mockResolveTargetDevice).toHaveBeenCalledWith( expect.objectContaining({ replayBackend: 'maestro', platform: 'ios' }), - { appleSimulatorAppTarget: 'com.example.demo' }, + { appleSimulatorAppTarget: 'com.example.demo', env: process.env }, ); expect(invoke).toHaveBeenCalledWith( expect.objectContaining({ @@ -593,7 +593,7 @@ test('platform-less typed Maestro replay preserves a resolved Android device', a expect(response.ok).toBe(true); expect(mockResolveTargetDevice).toHaveBeenCalledWith( expect.objectContaining({ replayBackend: 'maestro' }), - {}, + { env: process.env }, ); expect(invoke).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/daemon/src/sessions/__tests__/replay-suite/session-test-command-trust.test.ts b/packages/daemon/src/sessions/__tests__/replay-suite/session-test-command-trust.test.ts index 0c514be787..731703e6d8 100644 --- a/packages/daemon/src/sessions/__tests__/replay-suite/session-test-command-trust.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-suite/session-test-command-trust.test.ts @@ -50,6 +50,7 @@ async function forwardedCommands(publicNetworkOnly?: true): Promise createReplaySession(sessionName, logPath, sessionStore), diff --git a/packages/daemon/src/sessions/__tests__/replay-suite/session-test-suite-command-video.test.ts b/packages/daemon/src/sessions/__tests__/replay-suite/session-test-suite-command-video.test.ts index 364859ae54..45822f8a6f 100644 --- a/packages/daemon/src/sessions/__tests__/replay-suite/session-test-suite-command-video.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-suite/session-test-suite-command-video.test.ts @@ -281,6 +281,7 @@ test('test finalizes replay video exactly once when cancellation arrives after s const responsePromise = runReplayTestCommand({ ...splitReplayCommandRequest(request), + host: sessionStore.host, session: createReplaySession('default', path.join(root, 'daemon.log'), sessionStore), createSession: (sessionName, logPath) => createReplaySession(sessionName, logPath, sessionStore), diff --git a/packages/daemon/src/sessions/handlers/__tests__/record-runtime-stop-recovery.test.ts b/packages/daemon/src/sessions/handlers/__tests__/record-runtime-stop-recovery.test.ts index 190353c9ba..5ccbd36a98 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/record-runtime-stop-recovery.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/record-runtime-stop-recovery.test.ts @@ -75,6 +75,7 @@ test('record stop after a daemon restart replays the completed export for the na // The recovery re-resolves the manifest's exact device, never an unconstrained sweep. expect(resolveTargetDeviceMock).toHaveBeenCalledWith( expect.objectContaining({ serial: 'emulator-5554' }), + expect.objectContaining({ env: process.env }), ); }); @@ -114,6 +115,7 @@ test('record stop recovery after a daemon restart keeps the request host-scoping serial: 'emulator-5554', androidDeviceAllowlist: 'emulator-5554', }), + expect.objectContaining({ env: process.env }), ); }); @@ -153,6 +155,7 @@ test('record stop recovery on an Apple manifest keeps the request simulator-devi udid: 'IOS-UDID-1', iosSimulatorDeviceSet: '/sets/qa.set', }), + expect.objectContaining({ env: process.env }), ); }); @@ -184,7 +187,10 @@ test('record stop recovery carries the request lease and discovery context', asy const recovered = await harness.run(['stop'], { sessionExplicit: true }, { ...leaseFlags }); expect(recovered).toMatchObject({ ok: true, data: { recording: 'stopped', outPath } }); - expect(resolveTargetDeviceMock).toHaveBeenCalledWith(expect.objectContaining(leaseFlags)); + expect(resolveTargetDeviceMock).toHaveBeenCalledWith( + expect.objectContaining(leaseFlags), + expect.objectContaining({ env: process.env }), + ); }); test('record stop recovery for a family without identity flags resolves under the manifest family', async () => { @@ -212,6 +218,7 @@ test('record stop recovery for a family without identity flags resolves under th expect(recovered).toMatchObject({ ok: true, data: { recording: 'stopped', outPath } }); expect(resolveTargetDeviceMock).toHaveBeenCalledWith( expect.objectContaining({ platform: 'linux' }), + expect.objectContaining({ env: process.env }), ); }); diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-doctor-device.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-doctor-device.test.ts index ce85f76b16..f4453465c0 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-doctor-device.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-doctor-device.test.ts @@ -48,7 +48,7 @@ test('doctor android inventory timeout surfaces the wedged-adb-server hint', asy return []; }, async () => { - await appendDeviceInventoryCheck(checks, req, undefined, false); + await appendDeviceInventoryCheck(checks, req, undefined, process.env, false); }, ); @@ -82,7 +82,7 @@ test('doctor inventory names as host devices only the platforms this host discov }, local: async () => [androidEmulator], }, - async () => await appendDeviceInventoryCheck([], req, undefined, false), + async () => await appendDeviceInventoryCheck([], req, undefined, process.env, false), ); assert.deepEqual( diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-runtime-port-reverse.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-runtime-port-reverse.test.ts index 238e600a6b..0e85a345f4 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-runtime-port-reverse.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-runtime-port-reverse.test.ts @@ -20,6 +20,7 @@ test('port reverse rejects a missing resolved lease before runtime admission', a flags: { platform: 'android', leaseProvider: 'limrun', devicePort: 8097 }, }, session: undefined, + env: process.env, inspectFacts, bindDevice, }); @@ -49,6 +50,7 @@ test('sessionless port reverse requires an explicit device selector', async () = }, }, session: undefined, + env: process.env, inspectFacts, bindDevice, }); diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-shutdown.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-shutdown.test.ts index 6b70c6e326..a45c6d69fd 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-shutdown.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-shutdown.test.ts @@ -143,7 +143,7 @@ test('shutdown turns off selected Android emulator', async () => { expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); expect(mockResolveTargetDevice).toHaveBeenCalledWith( { platform: 'android', device: 'Pixel_9_Pro_XL' }, - { androidAvdSelection: 'include-stopped' }, + { androidAvdSelection: 'include-stopped', env: process.env }, ); expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledOnce(); expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); diff --git a/packages/daemon/src/sessions/handlers/lease.ts b/packages/daemon/src/sessions/handlers/lease.ts index 2b7cc4f8fd..681c843d1b 100644 --- a/packages/daemon/src/sessions/handlers/lease.ts +++ b/packages/daemon/src/sessions/handlers/lease.ts @@ -1,4 +1,4 @@ -import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; import type { PluginInventoryEntry } from '@agent-device/contracts/plugin-inventory'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { @@ -96,7 +96,12 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { let providerData: Record | undefined; @@ -191,11 +196,12 @@ async function allocateOrRenewLease( req: DaemonRequest, scope: LeaseScope, leaseRegistry: LeaseRegistry, + host: HostEnvironment, ): Promise<{ lease: DeviceLease; reused: boolean }> { const request = leaseScopeToAllocateRequest(scope); const renewed = leaseRegistry.renewRequestedLease(request); if (renewed) return { lease: renewed, reused: true }; - const pickedDeviceKey = await pickProxyLeaseDeviceKey(req, scope); + const pickedDeviceKey = await pickProxyLeaseDeviceKey(req, scope, host.env); const activeLeaseIds = new Set(leaseRegistry.listActiveLeases().map((entry) => entry.leaseId)); const lease = leaseRegistry.allocateLease( pickedDeviceKey ? { ...request, deviceKey: pickedDeviceKey } : request, @@ -210,23 +216,27 @@ async function allocateOrRenewLease( async function pickProxyLeaseDeviceKey( req: DaemonRequest, scope: LeaseScope, + env: EnvMap, ): Promise { if (!isProxyLeaseScope(scope) || scope.deviceKey) return undefined; const flags = req.flags ?? {}; const { hasDeviceSelectionInput } = await import('../../core/device/device-selector-intent.ts'); if (!hasDeviceSelectionInput(flags)) return undefined; const { resolveTargetDevice } = await import('@agent-device/device-selection/dispatch-resolve'); - const device = await resolveTargetDevice({ - platform: - flags.platform ?? - (scope.leaseBackend ? platformForLeaseBackend(scope.leaseBackend) : undefined), - target: flags.target, - device: flags.device, - udid: flags.udid, - serial: flags.serial, - iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, - androidDeviceAllowlist: flags.androidDeviceAllowlist, - }); + const device = await resolveTargetDevice( + { + platform: + flags.platform ?? + (scope.leaseBackend ? platformForLeaseBackend(scope.leaseBackend) : undefined), + target: flags.target, + device: flags.device, + udid: flags.udid, + serial: flags.serial, + iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, + androidDeviceAllowlist: flags.androidDeviceAllowlist, + }, + { env }, + ); return leaseDeviceKey({ platform: publicPlatformString(device), target: device.target, diff --git a/packages/daemon/src/sessions/handlers/record-runtime.ts b/packages/daemon/src/sessions/handlers/record-runtime.ts index 63b0fcb16f..28ef97ecb0 100644 --- a/packages/daemon/src/sessions/handlers/record-runtime.ts +++ b/packages/daemon/src/sessions/handlers/record-runtime.ts @@ -188,7 +188,8 @@ async function resolveUnheldRecordingDevice( plan: ReturnType['plan'], ): Promise { const { req, sessionName } = params; - if (!namesSessionWithoutTarget(req)) return await resolveTargetDevice(req.flags ?? {}); + if (!namesSessionWithoutTarget(req)) + return await resolveTargetDevice(req.flags ?? {}, { env: params.sessionStore.host.env }); if (plan.kind === 'stop-recovery') { const recorded = screenRecordingManifestDevice({ sessionName, @@ -225,7 +226,7 @@ async function resolveManifestDevice( }; const identityFlag = deviceIdentityFlag(recorded.family); if (identityFlag) selector[identityFlag] = recorded.id; - return await resolveTargetDevice(selector); + return await resolveTargetDevice(selector, { env: params.sessionStore.host.env }); } async function startRecording( diff --git a/packages/daemon/src/sessions/handlers/session-app-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-deployment.ts index 393e08f256..5f23cdfee5 100644 --- a/packages/daemon/src/sessions/handlers/session-app-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-deployment.ts @@ -84,6 +84,7 @@ export async function handleAppDeploymentCommand(params: { const device = await resolveCommandDevice({ session, flags, + env: sessionStore.host.env, platformServices: params.platformServices, }); const facts = await requireRuntimeFacts(params.inspectFacts)(device); @@ -136,6 +137,7 @@ export async function handlePushNotificationCommand( const device = await resolveCommandDevice({ session, flags, + env: sessionStore.host.env, platformServices: params.platformServices, }); const facts = await requireRuntimeFacts(params.inspectFacts)(device); @@ -186,6 +188,7 @@ export async function handleUninstallCommand( const device = await resolveCommandDevice({ session, flags, + env: sessionStore.host.env, platformServices: params.platformServices, }); const facts = await requireRuntimeFacts(params.inspectFacts)(device); diff --git a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts index a7e5f6717f..c6de980bb6 100644 --- a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts @@ -1,4 +1,5 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { DaemonPlatformServices } from '../../core/platform-services.ts'; import type { AppDeploymentResult, @@ -65,7 +66,12 @@ export async function handleInstallFromSourceDeploymentCommand(params: { try { resolvedSource = resolveInstallSource(req); const retention = resolveRetention(req); - const device = await resolveInstallDevice(session, req.flags, params.platformServices); + const device = await resolveInstallDevice( + session, + req.flags, + sessionStore.host.env, + params.platformServices, + ); const facts = await requireRuntimeFacts(params.inspectFacts)(device); const unsupported = unavailableRuntimeOperationResponse('install_from_source', facts.operations.ensureReady) ?? @@ -136,6 +142,7 @@ export async function handleReleaseMaterializedPathsCommand(params: { async function resolveInstallDevice( session: SessionState | undefined, flags: DaemonRequest['flags'] | undefined, + env: EnvMap, platformServices: DaemonPlatformServices, ): Promise { const requestedPlatform = normalizePlatform(flags?.platform); @@ -157,7 +164,7 @@ async function resolveInstallDevice( if (session) { return session.device; } - return await resolveCommandDevice({ session, flags, platformServices }); + return await resolveCommandDevice({ session, flags, env, platformServices }); } function normalizePlatform( diff --git a/packages/daemon/src/sessions/handlers/session-appstate.ts b/packages/daemon/src/sessions/handlers/session-appstate.ts index 272a301395..d83f3b9b2a 100644 --- a/packages/daemon/src/sessions/handlers/session-appstate.ts +++ b/packages/daemon/src/sessions/handlers/session-appstate.ts @@ -94,6 +94,7 @@ export async function handleAppStateCommand( const device = await resolveCommandDevice({ session: target.session, flags: params.req.flags ?? {}, + env: params.sessionStore.host.env, platformServices: params.platformServices, }); return await queryDeviceRuntimeAppState(params, device, target.namedSessionMissing); diff --git a/packages/daemon/src/sessions/handlers/session-boot.ts b/packages/daemon/src/sessions/handlers/session-boot.ts index 6a94f549f3..3a8a6a94af 100644 --- a/packages/daemon/src/sessions/handlers/session-boot.ts +++ b/packages/daemon/src/sessions/handlers/session-boot.ts @@ -55,6 +55,7 @@ async function resolveBootTargetDevice( const device = await resolveCommandDevice({ session, flags, + env: sessionStore.host.env, androidAvdSelection: 'include-stopped', platformServices, }); diff --git a/packages/daemon/src/sessions/handlers/session-doctor-device.ts b/packages/daemon/src/sessions/handlers/session-doctor-device.ts index d918a1da30..78a83f27a2 100644 --- a/packages/daemon/src/sessions/handlers/session-doctor-device.ts +++ b/packages/daemon/src/sessions/handlers/session-doctor-device.ts @@ -1,5 +1,6 @@ import { buildDeviceInventoryRequestFromFlags } from '@agent-device/device-selection/dispatch-resolve'; import { readDeviceInventory } from '@agent-device/device-selection/device-inventory-context'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { countDeviceInventoryByGroup, LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS, @@ -40,9 +41,10 @@ export async function appendDeviceInventoryCheck( checks: DoctorCheck[], req: DaemonRequest, session: SessionState | undefined, + env: EnvMap, useColor: boolean, ): Promise { - const selector = deviceInventorySelector(req, session); + const selector = deviceInventorySelector(req, session, env); try { const inventory = await readDoctorDeviceInventory(selector); const devices = filterInventoryForSelector(inventory.devices, selector); @@ -126,17 +128,24 @@ export function resolveDoctorDeviceForAppCheck( return undefined; } -function deviceInventorySelector(req: DaemonRequest, session: SessionState | undefined) { +function deviceInventorySelector( + req: DaemonRequest, + session: SessionState | undefined, + env: EnvMap, +) { const flags = req.flags ?? {}; - return buildDeviceInventoryRequestFromFlags({ - platform: flags.platform ?? session?.device.platform, - target: flags.target ?? session?.device.target, - device: flags.device, - udid: flags.udid, - serial: flags.serial, - iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, - androidDeviceAllowlist: flags.androidDeviceAllowlist, - }); + return buildDeviceInventoryRequestFromFlags( + { + platform: flags.platform ?? session?.device.platform, + target: flags.target ?? session?.device.target, + device: flags.device, + udid: flags.udid, + serial: flags.serial, + iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, + androidDeviceAllowlist: flags.androidDeviceAllowlist, + }, + env, + ); } function filterInventoryForSelector( diff --git a/packages/daemon/src/sessions/handlers/session-doctor.ts b/packages/daemon/src/sessions/handlers/session-doctor.ts index 76c89bb7fb..5a98d34d15 100644 --- a/packages/daemon/src/sessions/handlers/session-doctor.ts +++ b/packages/daemon/src/sessions/handlers/session-doctor.ts @@ -88,7 +88,13 @@ export async function handleDoctorCommand(params: { } const context = hostDiagnosticsContext(options, stateDir, androidAdbExecutor); - const inventory = await appendDeviceInventoryCheck(checks, req, session, useColor); + const inventory = await appendDeviceInventoryCheck( + checks, + req, + session, + params.host.env, + useColor, + ); const toolchain = await hostDiagnostics.toolchainCheck( session?.device.platform ?? inventory?.platform, context, diff --git a/packages/daemon/src/sessions/handlers/session-prepare.ts b/packages/daemon/src/sessions/handlers/session-prepare.ts index c79ae3f3f7..79ad470941 100644 --- a/packages/daemon/src/sessions/handlers/session-prepare.ts +++ b/packages/daemon/src/sessions/handlers/session-prepare.ts @@ -64,6 +64,7 @@ export async function handlePrepareCommand(params: { const device = await resolveCommandDevice({ session, flags, + env: sessionStore.host.env, platformServices: params.platformServices, }); const admission = await admitPrepareRuntime({ diff --git a/packages/daemon/src/sessions/handlers/session-replay-command.ts b/packages/daemon/src/sessions/handlers/session-replay-command.ts index d95911586a..7baf2fc46e 100644 --- a/packages/daemon/src/sessions/handlers/session-replay-command.ts +++ b/packages/daemon/src/sessions/handlers/session-replay-command.ts @@ -37,6 +37,7 @@ export const replayDaemonDependencies: ReplayDaemonDependencies = { resolveSessi export const handleReplayCommand: SessionCommandHandler = async ({ req, + host, sessionName, logPath, sessionStore, @@ -45,6 +46,7 @@ export const handleReplayCommand: SessionCommandHandler = async ({ }) => await runReplayCommand({ ...splitReplayCommandRequest(req), + host, session: createReplaySession(sessionName, logPath, sessionStore), invoke: replayInvokeOverDispatch(invokeReplayAction ?? invoke, req), dependencies: replayDaemonDependencies, @@ -87,6 +89,7 @@ export const handleReplayTestCommand: SessionCommandHandler = async ({ }); return await runReplayTestCommand({ ...splitReplayCommandRequest(req), + host, session: createReplaySession(sessionName, logPath, sessionStore), createSession: (testSessionName, testLogPath) => createReplaySession(testSessionName, testLogPath, sessionStore), diff --git a/packages/daemon/src/sessions/handlers/session-runtime-command.ts b/packages/daemon/src/sessions/handlers/session-runtime-command.ts index 865cd77f90..19eb958fe6 100644 --- a/packages/daemon/src/sessions/handlers/session-runtime-command.ts +++ b/packages/daemon/src/sessions/handlers/session-runtime-command.ts @@ -59,6 +59,7 @@ export async function handleRuntimeCommand(params: { return await handlePortReverseCommand({ req, session: sessionStore.get(sessionName), + env: sessionStore.host.env, inspectFacts: params.inspectFacts, platformServices: params.platformServices, bindDevice: params.bindDevice, diff --git a/packages/daemon/src/sessions/handlers/session-runtime-port-reverse.ts b/packages/daemon/src/sessions/handlers/session-runtime-port-reverse.ts index 3107e0402d..2fafc03aa1 100644 --- a/packages/daemon/src/sessions/handlers/session-runtime-port-reverse.ts +++ b/packages/daemon/src/sessions/handlers/session-runtime-port-reverse.ts @@ -1,4 +1,5 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { DaemonPlatformServices } from '../../core/platform-services.ts'; import type { ProviderPortReverseOptions } from '@agent-device/contracts/device'; import { configureProviderPortReverseRuntimeUse } from '@agent-device/contracts/application-lifecycle-runtime-plan'; @@ -28,6 +29,7 @@ type PortReversePorts = export async function handlePortReverseCommand(params: { req: DaemonRequest; session: ReturnType; + env: EnvMap; inspectFacts?: InspectDeviceRuntimeFacts; platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; @@ -44,6 +46,7 @@ export async function handlePortReverseCommand(params: { const device = await resolveCommandDevice({ session, flags: req.flags, + env: params.env, platformServices: params.platformServices, }); const admission = await admitRuntimeUse({ diff --git a/packages/daemon/src/sessions/handlers/session-shutdown.ts b/packages/daemon/src/sessions/handlers/session-shutdown.ts index 0621be2fc7..05e5d57eb4 100644 --- a/packages/daemon/src/sessions/handlers/session-shutdown.ts +++ b/packages/daemon/src/sessions/handlers/session-shutdown.ts @@ -46,6 +46,7 @@ export async function handleShutdownCommand( const device = await resolveCommandDevice({ flags, session: activeSession, + env: params.sessionStore.host.env, androidAvdSelection: 'include-stopped', platformServices: params.platformServices, }); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-runtime.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-runtime.test.ts index 2b814379ea..3a5a86d110 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-runtime.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-runtime.test.ts @@ -142,7 +142,7 @@ test('open applies stored runtime launchUrl and reports runtime hints', async () expect(mockBindDeviceRuntime).toHaveBeenCalledTimes(1); expect(mockResolveTargetDevice).toHaveBeenCalledWith( { platform: 'android' }, - { appleSimulatorAppTarget: 'Demo' }, + { appleSimulatorAppTarget: 'Demo', env: process.env }, ); expect(callOrder).toEqual(['runtime', 'dispatch:open', 'dispatch:open']); expect(runtimeApplyCalls).toEqual([ diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts b/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts index ad01c3bd5b..12cb4eabc5 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts @@ -496,6 +496,7 @@ async function resolveInventoryCommandDevice(params: { device: await resolveCommandDevice({ session, flags, + env: sessionStore.host.env, platformServices, androidAvdSelection, }), diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts b/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts index 369d9f508d..fee6be8d5f 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts @@ -1,4 +1,4 @@ -import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { DaemonPlatformServices } from '../../../core/platform-services.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; @@ -210,6 +210,7 @@ export async function handleSessionCloseCommands( req, sessionName, logPath, + env: params.host.env, inspectFacts: params.inspectFacts, platformServices: params.platformServices, bindDevice: params.bindDevice, @@ -432,11 +433,12 @@ async function closeWithoutSession(params: { req: DaemonRequest; sessionName: string; logPath: string; + env: EnvMap; inspectFacts?: InspectDeviceRuntimeFacts; platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { - const { req, sessionName, logPath, inspectFacts, bindDevice, platformServices } = params; + const { req, sessionName, logPath, env, inspectFacts, bindDevice, platformServices } = params; const { requireNamedSessionHoldsDevice } = await import('../../../core/explicit-session-resolution.ts'); requireNamedSessionHoldsDevice({ req, sessionAddress: sessionName, session: undefined }); @@ -445,6 +447,7 @@ async function closeWithoutSession(params: { const device = await resolveCommandDevice({ session: undefined, flags: req.flags, + env, platformServices, }); const admission = await admitCloseRuntime({ diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/session-open.ts b/packages/daemon/src/sessions/session-lifecycle/internal/session-open.ts index 0a91eb6e0a..a28a062af2 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-open.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-open.ts @@ -237,7 +237,11 @@ async function handleOpenCommand(params: SessionOpenCommandInput): Promise { const selection = await withDeviceInventoryContext({ ...gateways, requestScope: scope }, () => resolveTargetDeviceSelection( { platform: 'ios' }, - { - appleSimulatorAppTarget: 'com.example.demo', - }, + { env: process.env, appleSimulatorAppTarget: 'com.example.demo' }, ), ); diff --git a/packages/device-selection/src/__tests__/dispatch-resolve-target.test.ts b/packages/device-selection/src/__tests__/dispatch-resolve-target.test.ts index deafd77ca7..6c20312da3 100644 --- a/packages/device-selection/src/__tests__/dispatch-resolve-target.test.ts +++ b/packages/device-selection/src/__tests__/dispatch-resolve-target.test.ts @@ -5,7 +5,7 @@ import { AppError } from '@agent-device/kernel/errors'; test('resolveTargetDevice requires platform when target selector is provided', async () => { await assert.rejects( - () => resolveTargetDevice({ target: 'tv' }), + () => resolveTargetDevice({ target: 'tv' }, { env: process.env }), (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS' && diff --git a/packages/device-selection/src/__tests__/dispatch-resolve.test.ts b/packages/device-selection/src/__tests__/dispatch-resolve.test.ts index 7be4fce22b..7f4f94597d 100644 --- a/packages/device-selection/src/__tests__/dispatch-resolve.test.ts +++ b/packages/device-selection/src/__tests__/dispatch-resolve.test.ts @@ -121,10 +121,13 @@ test('resolveTargetDevice narrows local Android discovery to an explicit serial' return [androidEmulator]; }, async () => - await resolveTargetDeviceInContext({ - platform: 'android', - serial: androidEmulator.id, - }), + await resolveTargetDeviceInContext( + { + platform: 'android', + serial: androidEmulator.id, + }, + { env: process.env }, + ), ); assert.equal(result.id, androidEmulator.id); @@ -142,11 +145,14 @@ test('resolveTargetDevice does not discover an explicit Android serial outside i return []; }, async () => - await resolveTargetDeviceInContext({ - platform: 'android', - serial: androidEmulator.id, - androidDeviceAllowlist: 'emulator-5556', - }), + await resolveTargetDeviceInContext( + { + platform: 'android', + serial: androidEmulator.id, + androidDeviceAllowlist: 'emulator-5556', + }, + { env: process.env }, + ), ), ); @@ -157,8 +163,8 @@ test('resolveTargetDevice reuses request-scoped device resolution cache for iden mockListAppleDevices.mockResolvedValue([bootedSimulator]); const [first, second] = await withResolveTargetDeviceCacheScope(async () => [ - await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }), - await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }), + await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }, { env: process.env }), + await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }, { env: process.env }), ]); assert.equal(first.id, 'sim-2'); @@ -170,8 +176,8 @@ test('resolveTargetDevice request cache key separates device selectors', async ( mockListAppleDevices.mockResolvedValue([simulator, bootedSimulator]); await withResolveTargetDeviceCacheScope(async () => { - await resolveTargetDevice({ platform: 'ios', device: 'iPhone 16' }); - await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }); + await resolveTargetDevice({ platform: 'ios', device: 'iPhone 16' }, { env: process.env }); + await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }, { env: process.env }); }); assert.equal(mockListAppleDevices.mock.calls.length, 2); @@ -185,7 +191,7 @@ test('resolveTargetDevice selects the unique booted simulator with the requested const result = await resolveTargetDevice( { platform: 'ios' }, - { appleSimulatorAppTarget: 'com.example.demo' }, + { env: process.env, appleSimulatorAppTarget: 'com.example.demo' }, ); assert.equal(result.id, secondBootedSimulator.id); @@ -215,7 +221,7 @@ test('app-narrowed selection reports its own typed provenance, not a generic loc async () => await resolveTargetDeviceSelectionInContext( { platform: 'ios' }, - { appleSimulatorAppTarget: 'com.example.demo' }, + { env: process.env, appleSimulatorAppTarget: 'com.example.demo' }, ), ); @@ -233,7 +239,10 @@ test('resolveTargetDevice leaves platform-less static app selection to normal cr return [androidEmulator]; }, async () => - await resolveTargetDeviceInContext({}, { appleSimulatorAppTarget: 'com.example.demo' }), + await resolveTargetDeviceInContext( + {}, + { env: process.env, appleSimulatorAppTarget: 'com.example.demo' }, + ), ); assert.equal(result.id, androidEmulator.id); @@ -247,7 +256,10 @@ test('platform-less resolution refuses to pick between equally booted devices', async () => [androidEmulator, bootedSimulator, secondBootedSimulator], async () => { try { - await resolveTargetDeviceInContext({}, { appleSimulatorAppTarget: 'com.example.demo' }); + await resolveTargetDeviceInContext( + {}, + { env: process.env, appleSimulatorAppTarget: 'com.example.demo' }, + ); } catch (error) { return error; } @@ -275,8 +287,11 @@ test('resolveTargetDevice reuses an app-aware iOS selection for later iOS resolu ); const [appAware, laterResolution] = await withResolveTargetDeviceCacheScope(async () => [ - await resolveTargetDevice({ platform: 'ios' }, { appleSimulatorAppTarget: 'com.example.demo' }), - await resolveTargetDevice({ platform: 'ios' }), + await resolveTargetDevice( + { platform: 'ios' }, + { env: process.env, appleSimulatorAppTarget: 'com.example.demo' }, + ), + await resolveTargetDevice({ platform: 'ios' }, { env: process.env }), ]); assert.equal(appAware.id, secondBootedSimulator.id); @@ -289,7 +304,7 @@ test('resolveTargetDevice refuses booted simulator selection when the requested const error = await resolveTargetDevice( { platform: 'ios' }, - { appleSimulatorAppTarget: 'com.example.demo' }, + { env: process.env, appleSimulatorAppTarget: 'com.example.demo' }, ).catch((error) => error); assert.ok(error instanceof AppError); @@ -310,7 +325,7 @@ test('resolveTargetDevice refuses ambiguous booted simulator app matches', async const error = await resolveTargetDevice( { platform: 'ios' }, - { appleSimulatorAppTarget: 'com.example.demo' }, + { env: process.env, appleSimulatorAppTarget: 'com.example.demo' }, ).catch((error) => error); assert.ok(error instanceof AppError); @@ -328,7 +343,7 @@ test('resolveTargetDevice preserves an explicit device selector when platform is const result = await resolveTargetDevice( { udid: bootedSimulator.id }, - { appleSimulatorAppTarget: 'com.example.demo' }, + { env: process.env, appleSimulatorAppTarget: 'com.example.demo' }, ); assert.equal(result.id, bootedSimulator.id); @@ -339,10 +354,12 @@ test('resolveTargetDevice does not reuse cache across request scopes', async () mockListAppleDevices.mockResolvedValue([bootedSimulator]); await withResolveTargetDeviceCacheScope( - async () => await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }), + async () => + await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }, { env: process.env }), ); await withResolveTargetDeviceCacheScope( - async () => await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }), + async () => + await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }, { env: process.env }), ); assert.equal(mockListAppleDevices.mock.calls.length, 2); @@ -352,9 +369,10 @@ test('resolveTargetDevice reuses cache across nested request scopes', async () = mockListAppleDevices.mockResolvedValue([bootedSimulator]); await withResolveTargetDeviceCacheScope(async () => { - await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }); + await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }, { env: process.env }); await withResolveTargetDeviceCacheScope( - async () => await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }), + async () => + await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }, { env: process.env }), ); }); @@ -368,7 +386,11 @@ test('resolveTargetDevice uses injected device inventory without local discovery assert.equal(request.deviceName, 'Remote iPhone'); return [{ ...bootedSimulator, id: 'remote-ios-1', name: 'Remote iPhone' }]; }, - async () => await resolveTargetDeviceInContext({ platform: 'ios', device: 'Remote iPhone' }), + async () => + await resolveTargetDeviceInContext( + { platform: 'ios', device: 'Remote iPhone' }, + { env: process.env }, + ), ); assert.equal(result.id, 'remote-ios-1'); @@ -378,7 +400,11 @@ test('resolveTargetDevice uses injected device inventory without local discovery test('resolveTargetDevice preserves physical-device backend evidence from injected inventory', async () => { const result = await withDeviceInventoryProvider( async () => [{ ...physical, iosPhysicalDeviceBackend: 'xctest' }], - async () => await resolveTargetDeviceInContext({ platform: 'ios', udid: physical.id }), + async () => + await resolveTargetDeviceInContext( + { platform: 'ios', udid: physical.id }, + { env: process.env }, + ), ); assert.equal(result.iosPhysicalDeviceBackend, 'xctest'); @@ -388,7 +414,11 @@ test('resolveTargetDevice preserves physical-device backend evidence from inject test('resolveTargetDeviceSelection reports one candidate for an explicit Apple identity', async () => { const selection = await withDeviceInventoryProvider( async () => [physical, bootedSimulator], - async () => await resolveTargetDeviceSelectionInContext({ platform: 'ios', udid: physical.id }), + async () => + await resolveTargetDeviceSelectionInContext( + { platform: 'ios', udid: physical.id }, + { env: process.env }, + ), ); assert.equal(selection.device.id, physical.id); @@ -401,7 +431,8 @@ test('platform-only local selection reports inferred precedence instead of expli { local: async () => [bootedSimulator, simulator], }, - async () => await resolveTargetDeviceSelectionInContext({ platform: 'ios' }), + async () => + await resolveTargetDeviceSelectionInContext({ platform: 'ios' }, { env: process.env }), ); assert.equal(selection.device.id, bootedSimulator.id); @@ -419,7 +450,8 @@ test('platform-only provider selection reports provider inference', async () => throw new Error('provider inventory should not fall back to local discovery'); }, }, - async () => await resolveTargetDeviceSelectionInContext({ platform: 'ios' }), + async () => + await resolveTargetDeviceSelectionInContext({ platform: 'ios' }, { env: process.env }), ); assert.equal(selection.device.id, physical.id); @@ -435,7 +467,7 @@ test('resolveTargetDevice preserves Apple simulator preference with injected inv }, local: async () => [simulator], }, - async () => await resolveTargetDeviceInContext({ platform: 'ios' }), + async () => await resolveTargetDeviceInContext({ platform: 'ios' }, { env: process.env }), ); assert.equal(result.id, 'sim-1'); @@ -456,7 +488,7 @@ test('resolveTargetDevice keeps provider Apple inventory isolated from local fal }, local: localDiscover, }, - async () => await resolveTargetDeviceInContext({ platform: 'ios' }), + async () => await resolveTargetDeviceInContext({ platform: 'ios' }, { env: process.env }), ); assert.equal(result.id, physical.id); @@ -477,7 +509,7 @@ test('resolveTargetDevice propagates cancellation from the local Apple simulator throw canceled; }, }, - async () => await resolveTargetDeviceInContext({ platform: 'ios' }), + async () => await resolveTargetDeviceInContext({ platform: 'ios' }, { env: process.env }), ), (error) => error === canceled, ); @@ -499,7 +531,7 @@ test('resolveTargetDevice propagates missing inventory wiring from the local App throw unavailable; }, }, - async () => await resolveTargetDeviceInContext({ platform: 'ios' }), + async () => await resolveTargetDeviceInContext({ platform: 'ios' }, { env: process.env }), ), (error) => error === unavailable, ); @@ -514,7 +546,7 @@ test('resolveTargetDevice keeps genuine local Apple simulator probe failures bes throw new Error('simctl timed out'); }, }, - async () => await resolveTargetDeviceInContext({ platform: 'ios' }), + async () => await resolveTargetDeviceInContext({ platform: 'ios' }, { env: process.env }), ); assert.equal(result.id, physical.id); @@ -524,7 +556,7 @@ test('resolveTargetDevice treats empty injected inventory as authoritative', asy await expectDeviceNotFound(() => withDeviceInventoryProvider( async () => [], - async () => await resolveTargetDeviceInContext({ platform: 'ios' }), + async () => await resolveTargetDeviceInContext({ platform: 'ios' }, { env: process.env }), ), ); @@ -539,10 +571,13 @@ test('resolveTargetDevice resolves web through generic inventory without Apple f return [webDesktop]; }, async () => - await resolveTargetDeviceInContext({ - platform: 'web', - device: 'Agent Browser Chrome', - }), + await resolveTargetDeviceInContext( + { + platform: 'web', + device: 'Agent Browser Chrome', + }, + { env: process.env }, + ), ); assert.equal(result.platform, 'web'); @@ -551,7 +586,7 @@ test('resolveTargetDevice resolves web through generic inventory without Apple f }); test('resolveTargetDevice fast-paths explicit macOS without Apple mobile discovery', async () => { - const result = await resolveTargetDevice({ platform: 'macos' }); + const result = await resolveTargetDevice({ platform: 'macos' }, { env: process.env }); assert.equal(result.platform, 'apple'); assert.equal(result.appleOs, 'macos'); @@ -560,11 +595,14 @@ test('resolveTargetDevice fast-paths explicit macOS without Apple mobile discove }); test('resolveTargetDevice fast-paths Apple desktop target without simulator-set discovery', async () => { - const result = await resolveTargetDevice({ - platform: 'apple', - target: 'desktop', - iosSimulatorDeviceSet: '/tmp/simulators', - }); + const result = await resolveTargetDevice( + { + platform: 'apple', + target: 'desktop', + iosSimulatorDeviceSet: '/tmp/simulators', + }, + { env: process.env }, + ); assert.equal(result.platform, 'apple'); assert.equal(result.appleOs, 'macos'); @@ -573,7 +611,9 @@ test('resolveTargetDevice fast-paths Apple desktop target without simulator-set }); test('resolveTargetDevice fast-path preserves macOS selector validation', async () => { - await expectDeviceNotFound(() => resolveTargetDevice({ platform: 'macos', udid: 'other-mac' })); + await expectDeviceNotFound(() => + resolveTargetDevice({ platform: 'macos', udid: 'other-mac' }, { env: process.env }), + ); assert.equal(mockListAppleDevices.mock.calls.length, 0); }); @@ -588,14 +628,20 @@ test('resolveTargetDevice keeps simulator-set scope separate from the macOS host }, }, async () => { - const simulatorResult = await resolveTargetDeviceInContext({ - platform: 'ios', - iosSimulatorDeviceSet: '/tmp/isolated-set', - }); - const macResult = await resolveTargetDeviceInContext({ - platform: 'macos', - iosSimulatorDeviceSet: '/tmp/isolated-set', - }); + const simulatorResult = await resolveTargetDeviceInContext( + { + platform: 'ios', + iosSimulatorDeviceSet: '/tmp/isolated-set', + }, + { env: process.env }, + ); + const macResult = await resolveTargetDeviceInContext( + { + platform: 'macos', + iosSimulatorDeviceSet: '/tmp/isolated-set', + }, + { env: process.env }, + ); assert.equal(simulatorResult.id, simulator.id); assert.equal(macResult.id, macDesktop.id); }, @@ -619,11 +665,14 @@ test('the resolve_target_device phase shows the requested udid and simulator set await withDiagnosticsScope( { session: 'qa', command: 'record', debug: true, logPath }, async () => { - await resolveTargetDeviceInContext({ - platform: 'apple', - udid: simulator.id, - iosSimulatorDeviceSet: '/tmp/recovery-set', - }); + await resolveTargetDeviceInContext( + { + platform: 'apple', + udid: simulator.id, + iosSimulatorDeviceSet: '/tmp/recovery-set', + }, + { env: process.env }, + ); flushDiagnosticsToSessionFile({ force: true }); }, ); @@ -652,7 +701,7 @@ test('resolveTargetDevice discovers simulators in the daemon operator set when t }, }, async () => { - const result = await resolveTargetDeviceInContext({ platform: 'ios' }); + const result = await resolveTargetDeviceInContext({ platform: 'ios' }, { env: process.env }); assert.equal(result.id, simulator.id); }, ); @@ -663,10 +712,13 @@ test('resolveTargetDevice discovers simulators in the daemon operator set when t test('resolveTargetDevice refuses a request naming another set than the daemon operator set before discovery', async () => { vi.stubEnv('AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET', '/tmp/operator-set'); - const err = await resolveTargetDevice({ - platform: 'ios', - iosSimulatorDeviceSet: '/tmp/other-set', - }).catch((error) => error); + const err = await resolveTargetDevice( + { + platform: 'ios', + iosSimulatorDeviceSet: '/tmp/other-set', + }, + { env: process.env }, + ).catch((error) => error); assert.ok(err instanceof AppError); assert.equal(err.code, 'INVALID_ARGS'); @@ -685,14 +737,20 @@ test('resolveTargetDevice serves macOS and Android requests naming another set o }, }, async () => { - const macResult = await resolveTargetDeviceInContext({ - platform: 'macos', - iosSimulatorDeviceSet: '/tmp/other-set', - }); - const androidResult = await resolveTargetDeviceInContext({ - platform: 'android', - iosSimulatorDeviceSet: '/tmp/other-set', - }); + const macResult = await resolveTargetDeviceInContext( + { + platform: 'macos', + iosSimulatorDeviceSet: '/tmp/other-set', + }, + { env: process.env }, + ); + const androidResult = await resolveTargetDeviceInContext( + { + platform: 'android', + iosSimulatorDeviceSet: '/tmp/other-set', + }, + { env: process.env }, + ); assert.equal(macResult.id, macDesktop.id); assert.equal(androidResult.id, androidEmulator.id); }, diff --git a/packages/device-selection/src/dispatch-resolve.ts b/packages/device-selection/src/dispatch-resolve.ts index 4d04881d67..010618342f 100644 --- a/packages/device-selection/src/dispatch-resolve.ts +++ b/packages/device-selection/src/dispatch-resolve.ts @@ -8,6 +8,7 @@ import { type DeviceTarget, } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { AsyncLocalStorage } from 'node:async_hooks'; import { resolveAndroidSerialAllowlist } from '@agent-device/kernel/device-isolation'; import { withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; @@ -57,6 +58,8 @@ type AppleDeviceSelector = { }; export type ResolveTargetDeviceOptions = { + /** The caller's host environment; allowlists and simulator sets resolve from it. */ + env: EnvMap; androidAvdSelection?: 'running-only' | 'include-stopped'; appleSimulatorAppTarget?: string; }; @@ -86,17 +89,17 @@ function shouldUseAppleSimulatorFallback( export async function resolveTargetDevice( flags: ResolveDeviceFlags, - options: ResolveTargetDeviceOptions = {}, + options: ResolveTargetDeviceOptions, ): Promise { return (await resolveTargetDeviceSelection(flags, options)).device; } export async function resolveTargetDeviceSelection( flags: ResolveDeviceFlags, - options: ResolveTargetDeviceOptions = {}, + options: ResolveTargetDeviceOptions, ): Promise { const inventoryRequest = { - ...buildDeviceInventoryRequestFromFlags(flags), + ...buildDeviceInventoryRequestFromFlags(flags, options.env), androidAvdSelection: options.androidAvdSelection ?? 'running-only', }; const { iosSimulatorSetPath, ...selector } = inventoryRequest; @@ -203,6 +206,7 @@ async function resolveAppleSimulatorFallback( export function buildDeviceInventoryRequestFromFlags( flags: ResolveDeviceFlags, + env: EnvMap, ): DeviceInventoryRequest { const platform = flags.platform; if (flags.target && !platform) { @@ -215,12 +219,9 @@ export function buildDeviceInventoryRequestFromFlags( requestedSetPath: flags.iosSimulatorDeviceSet, platform, target: flags.target, - env: process.env, + env, }); - const androidSerialAllowlist = resolveAndroidSerialAllowlist( - flags.androidDeviceAllowlist, - process.env, - ); + const androidSerialAllowlist = resolveAndroidSerialAllowlist(flags.androidDeviceAllowlist, env); return { platform, target: flags.target, diff --git a/packages/device-selection/src/open-target.ts b/packages/device-selection/src/open-target.ts index ff5c1066da..7aa2f76fb2 100644 --- a/packages/device-selection/src/open-target.ts +++ b/packages/device-selection/src/open-target.ts @@ -1,10 +1,13 @@ import { isDeepLinkTarget } from '@agent-device/contracts/command'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { ResolveTargetDeviceOptions } from '@agent-device/device-selection/dispatch-resolve'; export function buildOpenTargetDeviceResolutionOptions( openTarget: string | undefined, + env: EnvMap, ): ResolveTargetDeviceOptions { return { + env, appleSimulatorAppTarget: appleSimulatorAppTargetForOpenTarget(openTarget), }; } diff --git a/packages/maestro/src/internal/__tests__/replay-plan.test.ts b/packages/maestro/src/internal/__tests__/replay-plan.test.ts index 1ed52bf12e..e3262d7a16 100644 --- a/packages/maestro/src/internal/__tests__/replay-plan.test.ts +++ b/packages/maestro/src/internal/__tests__/replay-plan.test.ts @@ -40,6 +40,7 @@ describe('typed Maestro replay plan', () => { const loadProgram = vi.fn(async () => child); const plan = await compileMaestroReplayPlan(program, { + baseDir: process.cwd(), platform: 'android', target: 'simulator', runtimeHints: { platform: 'android', metroHost: '127.0.0.1', metroPort: 8083 }, @@ -83,6 +84,7 @@ describe('typed Maestro replay plan', () => { expect(Object.isFrozen(plan)).toBe(true); const changed = await compileMaestroReplayPlan(program, { + baseDir: process.cwd(), platform: 'android', target: 'simulator', env: { INCLUDE: 'other.yaml' }, @@ -91,6 +93,7 @@ describe('typed Maestro replay plan', () => { expect(changed.digest).not.toBe(plan.digest); const changedRuntime = await compileMaestroReplayPlan(program, { + baseDir: process.cwd(), platform: 'android', target: 'simulator', runtimeHints: { platform: 'android', metroHost: '127.0.0.1', metroPort: 8084 }, @@ -117,7 +120,7 @@ describe('typed Maestro replay plan', () => { // `repeat`/`retry` branch, leaving the runScript/evalScript branch untested. const runScriptPlan = await compileMaestroReplayPlan( parseMaestroProgram(['---', '- runScript: setup.js', '- inputText: after'].join('\n')), - { platform: 'android', target: 'simulator' }, + { baseDir: process.cwd(), platform: 'android', target: 'simulator' }, ); expect( evaluateMaestroReplayResume(runScriptPlan, { from: 2, planDigest: runScriptPlan.digest }), @@ -130,7 +133,7 @@ describe('typed Maestro replay plan', () => { parseMaestroProgram( ['---', '- evalScript: ${output.x = 1}', '- inputText: after'].join('\n'), ), - { platform: 'android', target: 'simulator' }, + { baseDir: process.cwd(), platform: 'android', target: 'simulator' }, ); expect( evaluateMaestroReplayResume(evalScriptPlan, { from: 2, planDigest: evalScriptPlan.digest }), @@ -152,7 +155,7 @@ describe('typed Maestro replay plan', () => { observe: vi.fn(async ({ generation }) => ({ generation, matched: true })), }; - const initialPlan = await compileMaestroReplayPlan(program); + const initialPlan = await compileMaestroReplayPlan(program, { baseDir: process.cwd() }); await executeMaestroProgram(program, port, { from: 2, planDigest: initialPlan.digest, diff --git a/packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts b/packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts index 7cec3054c4..ad49ace8eb 100644 --- a/packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts +++ b/packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts @@ -27,7 +27,11 @@ export async function executeMaestroProgram( port: MaestroRuntimePort, options: MaestroEngineOptions = {}, ): Promise { - return await executeMaestroPlan(await compileMaestroReplayPlan(program, options), port, options); + return await executeMaestroPlan( + await compileMaestroReplayPlan(program, { ...options, baseDir: process.cwd() }), + port, + options, + ); } export function createMaestroRuntimePort(operations: MaestroRuntimeOperations): MaestroRuntimePort { diff --git a/packages/maestro/src/internal/engine-flow.ts b/packages/maestro/src/internal/engine-flow.ts index 6fa0fabedc..2904ab5582 100644 --- a/packages/maestro/src/internal/engine-flow.ts +++ b/packages/maestro/src/internal/engine-flow.ts @@ -98,12 +98,10 @@ export async function readIncludedProgram( function includePathKey( command: MaestroRunFlowCommand, parentSource: string | undefined, + baseDir: string, ): string | undefined { if (command.include.kind !== 'file') return undefined; - return path.resolve( - parentSource ? path.dirname(parentSource) : process.cwd(), - command.include.path, - ); + return path.resolve(parentSource ? path.dirname(parentSource) : baseDir, command.include.path); } export function sourcePathKey(source: string | undefined): string | undefined { @@ -113,8 +111,9 @@ export function sourcePathKey(source: string | undefined): string | undefined { export function assertIncludePathAvailable( command: MaestroRunFlowCommand, activePaths: ReadonlySet, + baseDir: string, ): string | undefined { - const requestedPath = includePathKey(command, command.source.path); + const requestedPath = includePathKey(command, command.source.path, baseDir); if (requestedPath && activePaths.has(requestedPath)) { throw new AppError('INVALID_ARGS', `Maestro runFlow cycle detected at ${requestedPath}.`); } diff --git a/packages/maestro/src/internal/facade-execution.ts b/packages/maestro/src/internal/facade-execution.ts index fa0d0d5a66..db416f3757 100644 --- a/packages/maestro/src/internal/facade-execution.ts +++ b/packages/maestro/src/internal/facade-execution.ts @@ -133,8 +133,10 @@ export async function executeMaestroFlow( ): Promise { let failed: MaestroFailedAction | undefined; try { - const loader = createMaestroProgramLoader(path.dirname(flow.sourcePath), options.readSource); + const baseDir = path.dirname(flow.sourcePath); + const loader = createMaestroProgramLoader(baseDir, options.readSource); const plan = await compileMaestroReplayPlan(flow[flowProgram], { + baseDir, defaults: options.defaults, env: options.env, platform: options.platform, diff --git a/packages/maestro/src/internal/replay-plan-compilation.ts b/packages/maestro/src/internal/replay-plan-compilation.ts index 2e86e78599..a794e04343 100644 --- a/packages/maestro/src/internal/replay-plan-compilation.ts +++ b/packages/maestro/src/internal/replay-plan-compilation.ts @@ -7,7 +7,7 @@ import { stripUndefined } from './shared.ts'; export async function compileMaestroReplayPlan( program: MaestroProgram, - options: MaestroReplayPlanOptions = {}, + options: MaestroReplayPlanOptions, ): Promise { const { steps, staticallyExecutedControls, staticallySkippedControls } = await compileMaestroReplayPlanSteps(program, options); diff --git a/packages/maestro/src/internal/replay-plan-steps.ts b/packages/maestro/src/internal/replay-plan-steps.ts index 7207b8acb1..a780a2aa3f 100644 --- a/packages/maestro/src/internal/replay-plan-steps.ts +++ b/packages/maestro/src/internal/replay-plan-steps.ts @@ -166,7 +166,11 @@ async function compileRunFlowBody( state: BuildState, ): Promise { const resolvedCommand = resolveRunFlowInclude(command, state.context); - const requestedPath = assertIncludePathAvailable(resolvedCommand, state.activeIncludePaths); + const requestedPath = assertIncludePathAvailable( + resolvedCommand, + state.activeIncludePaths, + state.options.baseDir, + ); const program = await readIncludedProgram(resolvedCommand, state.options); checkpointMaestroCancellation(state.options.signal); const includedPaths = registerIncludedProgramPaths( diff --git a/packages/maestro/src/internal/replay-plan-types.ts b/packages/maestro/src/internal/replay-plan-types.ts index 7e4c898ce9..37495aef9f 100644 --- a/packages/maestro/src/internal/replay-plan-types.ts +++ b/packages/maestro/src/internal/replay-plan-types.ts @@ -46,6 +46,8 @@ export type MaestroReplayPlanOptions = { readonly env?: Readonly>; /** Lowest-precedence defaults, normally replay built-ins. */ readonly defaults?: Readonly>; + /** Directory an include with no parent source file resolves against — the loader's base. */ + readonly baseDir: string; readonly platform?: MaestroPlatform; readonly target?: string; readonly runtimeHints?: Readonly; diff --git a/packages/maestro/src/replay-backend/session-replay-maestro-runtime.ts b/packages/maestro/src/replay-backend/session-replay-maestro-runtime.ts index b0635f8ec1..8c7e0e500e 100644 --- a/packages/maestro/src/replay-backend/session-replay-maestro-runtime.ts +++ b/packages/maestro/src/replay-backend/session-replay-maestro-runtime.ts @@ -7,6 +7,7 @@ import { type MaestroPlatform, } from '@agent-device/maestro'; import { AppError } from '@agent-device/kernel/errors'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { collectReplayShellEnv, parseReplayCliEnvEntries, @@ -177,6 +178,7 @@ async function prepareTypedMaestroReplay( const binding = await resolveMaestroReplayBinding({ host, req, + env: command.host.env, sessionStore, sessionName, session, @@ -191,10 +193,11 @@ async function prepareTypedMaestroReplay( req, sessionName, filePath, + hostCwd: command.host.cwd, platform: binding.platform, target: binding.target, }), - env: buildTypedMaestroEnv(req), + env: buildTypedMaestroEnv(req, command.host.env), signal: host.requestSignal(req.meta?.requestId), }; } @@ -202,21 +205,26 @@ async function prepareTypedMaestroReplay( async function resolveMaestroReplayBinding(params: { host: ReplayBackendHost; req: DaemonWireRequest; + env: EnvMap; sessionStore: ReplaySessionStore; sessionName: string; session: ReturnType; flow: MaestroFlow; }): Promise { - const { host, req, sessionStore, sessionName, session, flow } = params; + const { host, req, env, sessionStore, sessionName, session, flow } = params; const requestedPlatform = req.flags?.platform; const device = session?.device ?? (requestedPlatform === 'android' || requestedPlatform === 'ios' ? undefined - : await host.resolveTargetDevice(req.flags ?? {}, { - appTarget: flow.appTarget, - platform: requestedPlatform, - })); + : await host.resolveTargetDevice( + req.flags ?? {}, + { + appTarget: flow.appTarget, + platform: requestedPlatform, + }, + env, + )); const platform = resolveMaestroPlatform(req, device); const runtimeHints = resolveReplayRuntimeHints({ req, @@ -228,6 +236,7 @@ async function resolveMaestroReplayBinding(params: { return await completeMaestroRuntimeBinding({ host, req, + env, sessionStore, sessionName, device, @@ -242,16 +251,21 @@ async function completeMaestroRuntimeBinding( params: { host: ReplayBackendHost; req: DaemonWireRequest; + env: EnvMap; sessionStore: ReplaySessionStore; sessionName: string; flow: MaestroFlow; } & MaestroReplayBinding, ): Promise { if (params.device || !requiresDeviceRuntimeDefaults(params.runtimeHints)) return params; - const device = await params.host.resolveTargetDevice(params.req.flags ?? {}, { - appTarget: params.flow.appTarget, - platform: params.platform, - }); + const device = await params.host.resolveTargetDevice( + params.req.flags ?? {}, + { + appTarget: params.flow.appTarget, + platform: params.platform, + }, + params.env, + ); return { device, platform: params.platform, @@ -293,6 +307,7 @@ function buildTypedMaestroDefaults(params: { req: DaemonWireRequest; sessionName: string; filePath: string; + hostCwd: string; platform: Extract; target: string; }): Record { @@ -301,15 +316,16 @@ function buildTypedMaestroDefaults(params: { req: params.req, sessionName: params.sessionName, resolvedPath: params.filePath, + hostCwd: params.hostCwd, }), AD_PLATFORM: params.platform, AD_TARGET: params.target, }; } -function buildTypedMaestroEnv(req: DaemonWireRequest): Record { +function buildTypedMaestroEnv(req: DaemonWireRequest, hostEnv: EnvMap): Record { return { - ...collectReplayShellEnv(readReplayShellEnvSource(req.flags?.replayShellEnv)), + ...collectReplayShellEnv(readReplayShellEnvSource(req.flags?.replayShellEnv, hostEnv)), ...parseReplayCliEnvEntries(readReplayCliEnvEntries(req.flags?.replayEnv)), }; } diff --git a/packages/plugins/src/connection.ts b/packages/plugins/src/connection.ts index 1bedc4b079..1a3d6c5efe 100644 --- a/packages/plugins/src/connection.ts +++ b/packages/plugins/src/connection.ts @@ -29,7 +29,7 @@ const primed = new Map(); /** Loads the store's plugins into the connection registry this process reads capabilities from. */ export async function primePluginConnections( - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv, builtinProviders: readonly string[] = [], ): Promise { if (primed.has(resolveUserConfigPath(env))) return; @@ -49,19 +49,19 @@ export async function primePluginConnections( /** The primed connection of a store plugin, or undefined before priming or for an unknown provider. */ export function primedPluginConnection( provider: string, - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv, ): RegisteredConnection | undefined { return primed.get(resolveUserConfigPath(env))?.connections.get(provider); } /** The store plugins that failed to load, as the primed registry saw them. */ -export function pluginLoadFailures(env: NodeJS.ProcessEnv = process.env): readonly FailedPlugin[] { +export function pluginLoadFailures(env: NodeJS.ProcessEnv): readonly FailedPlugin[] { return primed.get(resolveUserConfigPath(env))?.failures ?? []; } export function pluginConnectionCapabilities( provider: string | undefined, - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv, ): ConnectionProviderCapabilities | undefined { if (provider === undefined) return undefined; const store = primed.get(resolveUserConfigPath(env)); @@ -70,7 +70,7 @@ export function pluginConnectionCapabilities( ?.agentDevicePlugin.connection; } -export function pluginConnectionNames(env: NodeJS.ProcessEnv = process.env): string[] { +export function pluginConnectionNames(env: NodeJS.ProcessEnv): string[] { const store = primed.get(resolveUserConfigPath(env)); if (store) return [...store.connections.keys()]; return legacyStoreManifests(env) diff --git a/packages/plugins/src/store.ts b/packages/plugins/src/store.ts index 969532c20f..406ae3e4f7 100644 --- a/packages/plugins/src/store.ts +++ b/packages/plugins/src/store.ts @@ -270,7 +270,7 @@ async function stagePlugin( export async function changePlugin( action: PluginAction, input: string, - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv, reservedProviders: readonly string[], ): Promise { const { name, version } = parsePluginRequest(action, input); diff --git a/packages/replay-port/src/__tests__/session-replay-vars.test.ts b/packages/replay-port/src/__tests__/session-replay-vars.test.ts index f82ddc7983..0d306e020c 100644 --- a/packages/replay-port/src/__tests__/session-replay-vars.test.ts +++ b/packages/replay-port/src/__tests__/session-replay-vars.test.ts @@ -24,6 +24,7 @@ test('buildReplayBuiltinVars applies request values over metadata', () => { sessionName: 'session', metadata: { platform: 'android', target: 'mobile' }, resolvedPath: '/tmp/replay/flows/test.ad', + hostCwd: '/tmp/replay', }); assert.deepEqual(builtins, { @@ -57,6 +58,7 @@ test('buildReplayBuiltinVars omits empty optional values', () => { sessionName: 'session', metadata: { platform: 'android', target: 'mobile' }, resolvedPath: '/tmp/replay/test.ad', + hostCwd: '/tmp/replay', }); assert.deepEqual(builtins, { diff --git a/packages/replay-port/src/daemon-port/native-command.ts b/packages/replay-port/src/daemon-port/native-command.ts index 1f587cd08f..321a505220 100644 --- a/packages/replay-port/src/daemon-port/native-command.ts +++ b/packages/replay-port/src/daemon-port/native-command.ts @@ -56,6 +56,7 @@ import { errorResponse, type DaemonResponse } from '@agent-device/kernel/contrac export async function runReplayCommand(command: ReplayCommand): Promise { const { request: req, + host, session: { name: sessionName, logPath, store: sessionStore, observationStore, coordinator }, tracePath, onStep, @@ -89,6 +90,7 @@ export async function runReplayCommand(command: ReplayCommand): Promise + resolveTargetDevice: async (flags, binding, env) => await resolveTargetDevice( flags, - buildMaestroReplayTargetDeviceResolutionOptions(binding.appTarget, binding.platform), + buildMaestroReplayTargetDeviceResolutionOptions(binding.appTarget, binding.platform, env), ), buildActionFailureResponse: buildReplayBackendActionFailureResponse, }; diff --git a/packages/replay-port/src/daemon-port/replay-script-selection.ts b/packages/replay-port/src/daemon-port/replay-script-selection.ts index 79e3767dfe..4efa1beba8 100644 --- a/packages/replay-port/src/daemon-port/replay-script-selection.ts +++ b/packages/replay-port/src/daemon-port/replay-script-selection.ts @@ -1,5 +1,6 @@ import type { SessionAction } from '@agent-device/contracts/session'; import { resolveDeclaredScriptPlatform } from '@agent-device/ad-script'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { ResolveTargetDeviceOptions } from '@agent-device/device-selection/dispatch-resolve'; import { appleSimulatorAppTargetForOpenTarget } from '@agent-device/device-selection/open-target'; import { isDeepLinkTarget, type CommandFlags } from '@agent-device/contracts/command'; @@ -43,9 +44,10 @@ export function readScriptReplaySelection(actions: SessionAction[]): { export function buildMaestroReplayTargetDeviceResolutionOptions( appTarget: string | undefined, platform: CommandFlags['platform'] | undefined, + env: EnvMap, ): ResolveTargetDeviceOptions { - if (platform !== 'ios') return {}; - return appTargetResolutionOptions(appTarget) ?? {}; + if (platform !== 'ios') return { env }; + return appTargetResolutionOptions(appTarget, env) ?? { env }; } /** Applies a platform configured before the first open to replay dispatch. */ @@ -66,7 +68,8 @@ function isStaticAppTarget(value: string | undefined): value is string { export function appTargetResolutionOptions( openTarget: string | undefined, + env: EnvMap, ): ResolveTargetDeviceOptions | undefined { const appTarget = appleSimulatorAppTargetForOpenTarget(openTarget); - return appTarget ? { appleSimulatorAppTarget: appTarget } : undefined; + return appTarget ? { appleSimulatorAppTarget: appTarget, env } : undefined; } diff --git a/packages/replay-port/src/daemon-port/session-replay-runtime-plan.ts b/packages/replay-port/src/daemon-port/session-replay-runtime-plan.ts index 690ffb5199..acc2186f63 100644 --- a/packages/replay-port/src/daemon-port/session-replay-runtime-plan.ts +++ b/packages/replay-port/src/daemon-port/session-replay-runtime-plan.ts @@ -1,6 +1,7 @@ import type { SessionAction } from '@agent-device/contracts/session'; import type { ReplayDispatchOptions } from '@agent-device/contracts/replay'; import type { CommandFlags, DaemonWireRequest } from '@agent-device/contracts/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { ReplayCommand, ReplayCoordinator } from '@agent-device/replay-port/command-types'; import type { ReplayDispatchRequest, @@ -90,6 +91,8 @@ export type PreparedReplayPlan = { export function prepareReplayPlan(params: { req: DaemonWireRequest; + /** The command's runtime host; variable sources read environment and cwd from it. */ + host: HostEnvironment; sessionName: string; sessionStore: ReplaySessionStore; tracePath: string | undefined; @@ -100,8 +103,17 @@ export function prepareReplayPlan(params: { /** Dispatch options the command carries; every plan step inherits them. */ dispatch: ReplayDispatchOptions | undefined; }): { ok: true; value: PreparedReplayPlan } | { ok: false; response: DaemonResponse } { - const { req, sessionName, sessionStore, tracePath, resolved, script, coordinator, dispatch } = - params; + const { + req, + host, + sessionName, + sessionStore, + tracePath, + resolved, + script, + coordinator, + dispatch, + } = params; const backendRejection = validateReplayBackendFlag(req); if (backendRejection) return { ok: false, response: backendRejection }; @@ -128,6 +140,7 @@ export function prepareReplayPlan(params: { entryIndex: entryIndexResult.value, varSources: buildPreparedReplayVarSources({ req, + host, replayReq, sessionName, resolved, @@ -215,27 +228,29 @@ function applyReplayMetadata( * The `${VAR}` scope's raw INPUTS — builtins (this request's session/ * platform/target/device/artifacts-dir), the script's own `env` header, the * shell's `AD_VAR_*` entries, and `-e KEY=VALUE` CLI entries — read here, - * once, from the request/process. `runAdReplay` is the one place these are + * once, from the request and its command's runtime host. `runAdReplay` is the one place these are * merged into an actual scope and used to resolve an action (#1555 review * P1); this function stops at collecting the plain data. */ function buildPreparedReplayVarSources(params: { req: DaemonWireRequest; + host: HostEnvironment; replayReq: ReplayDispatchRequest; sessionName: string; resolved: string; metadata: AdReplayManifest['metadata']; }): AdReplayVarSources { - const { req, replayReq, sessionName, resolved, metadata } = params; + const { req, host, replayReq, sessionName, resolved, metadata } = params; return { builtins: buildReplayBuiltinVars({ req: replayReq, sessionName, metadata, resolvedPath: resolved, + hostCwd: host.cwd, }), fileEnv: metadata.env, - shellEnv: collectReplayShellEnv(readReplayShellEnvSource(req.flags?.replayShellEnv)), + shellEnv: collectReplayShellEnv(readReplayShellEnvSource(req.flags?.replayShellEnv, host.env)), cliEnv: parseReplayCliEnvEntries(readReplayCliEnvEntries(req.flags?.replayEnv)), }; } diff --git a/packages/replay-port/src/daemon-port/session-replay-runtime-session.ts b/packages/replay-port/src/daemon-port/session-replay-runtime-session.ts index a5d46daba9..f17e6bd580 100644 --- a/packages/replay-port/src/daemon-port/session-replay-runtime-session.ts +++ b/packages/replay-port/src/daemon-port/session-replay-runtime-session.ts @@ -29,11 +29,13 @@ const NO_SCRIPT_PUBLICATION_VIEW: ReplaySessionView['scriptPublication'] = { export function prepareReplaySession(params: { req: DaemonWireRequest; entryIndex: number; + /** The command's host working directory; a `--save-script` path resolves against it. */ + hostCwd: string; sessionStore: ReplaySessionStore; sourcePath: string; coordinator: ReplayCoordinator; }): { ok: true; armSaveScript: () => void } | { ok: false; response: DaemonResponse } { - const { req, entryIndex, sessionStore, sourcePath, coordinator } = params; + const { req, entryIndex, hostCwd, sessionStore, sourcePath, coordinator } = params; const sessionPreflight = validateReplaySessionEntry({ entryIndex, sessionStore, @@ -42,7 +44,7 @@ export function prepareReplaySession(params: { if (sessionPreflight) return { ok: false, response: sessionPreflight }; consumeReplayResumeState({ req, coordinator }); - return prepareSaveScriptSession({ req, sessionStore, sourcePath, coordinator }); + return prepareSaveScriptSession({ req, hostCwd, sessionStore, sourcePath, coordinator }); } function validateReplaySessionEntry(params: { @@ -65,10 +67,11 @@ function validateReplaySessionEntry(params: { function rejectSaveScriptArming(params: { saveScript: boolean | string | undefined; force: boolean | undefined; + hostCwd: string; preRunState: ReplaySessionView['scriptPublication']; sourcePath: string; }): DaemonResponse | undefined { - const { saveScript, force, preRunState, sourcePath } = params; + const { saveScript, force, hostCwd, preRunState, sourcePath } = params; if (saveScript && preRunState.kind === 'authoring') { return errorResponse( 'INVALID_ARGS', @@ -79,6 +82,7 @@ function rejectSaveScriptArming(params: { saveScript, liveForce: force, persistedForce: preRunState.targetForce || undefined, + hostCwd, sourcePath, existingSaveScriptPath: preRunState.targetPath, }); @@ -86,15 +90,17 @@ function rejectSaveScriptArming(params: { function prepareSaveScriptSession(params: { req: DaemonWireRequest; + hostCwd: string; sessionStore: ReplaySessionStore; sourcePath: string; coordinator: ReplayCoordinator; }): { ok: true; armSaveScript: () => void } | { ok: false; response: DaemonResponse } { - const { req, sourcePath, coordinator } = params; + const { req, hostCwd, sourcePath, coordinator } = params; const { saveScript, force } = req.flags ?? {}; const rejection = rejectSaveScriptArming({ saveScript, force, + hostCwd, preRunState: coordinator.view()?.scriptPublication ?? NO_SCRIPT_PUBLICATION_VIEW, sourcePath, }); @@ -173,14 +179,16 @@ function preflightSaveScriptTarget(params: { saveScript: boolean | string | undefined; liveForce: boolean | undefined; persistedForce: boolean | undefined; + hostCwd: string; sourcePath: string; existingSaveScriptPath: string | undefined; }): DaemonResponse | undefined { - const { saveScript, liveForce, persistedForce, sourcePath, existingSaveScriptPath } = params; + const { saveScript, liveForce, persistedForce, hostCwd, sourcePath, existingSaveScriptPath } = + params; if (!saveScript) return undefined; const targetPath = typeof saveScript === 'string' - ? expandSessionPath(saveScript, process.cwd()) + ? expandSessionPath(saveScript, hostCwd) : (existingSaveScriptPath ?? healedScriptSiblingPath(sourcePath)); const effectiveForce = Boolean(liveForce) || (Boolean(persistedForce) && targetPath === existingSaveScriptPath); diff --git a/packages/replay-port/src/daemon-port/session-test-shard-devices.ts b/packages/replay-port/src/daemon-port/session-test-shard-devices.ts index d9179221dc..7c588571a4 100644 --- a/packages/replay-port/src/daemon-port/session-test-shard-devices.ts +++ b/packages/replay-port/src/daemon-port/session-test-shard-devices.ts @@ -1,5 +1,6 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { listDeviceInventory } from '@agent-device/device-selection/device-inventory-context'; import { resolveAndroidSerialAllowlist } from '@agent-device/kernel/device-isolation'; import { @@ -25,9 +26,10 @@ import type { */ export function buildReplayTestShardTargetResolver( flags: CommandFlags | undefined, + env: EnvMap, ): ReplayTestResolveShardTargets { return async (shardCount) => { - const devices = await resolveReplayTestShardDevices(flags, shardCount); + const devices = await resolveReplayTestShardDevices(flags, shardCount, env); return devices.map(toReplayTestShardTarget); }; } @@ -75,9 +77,10 @@ export function buildReplayTestShardFlags( async function resolveReplayTestShardDevices( flags: CommandFlags | undefined, shardCount: number, + env: EnvMap, ): Promise { const explicitSelectors = explicitShardDeviceSelectors(flags); - const inventory = await listDeviceInventory(buildReplayTestShardInventoryRequest(flags)); + const inventory = await listDeviceInventory(buildReplayTestShardInventoryRequest(flags, env)); const devices = selectReplayTestShardDevices(inventory, explicitSelectors, flags); if (devices.length < shardCount) { @@ -91,11 +94,9 @@ async function resolveReplayTestShardDevices( function buildReplayTestShardInventoryRequest( flags: CommandFlags | undefined, + env: EnvMap, ): DeviceInventoryRequest { - const androidSerialAllowlist = resolveAndroidSerialAllowlist( - flags?.androidDeviceAllowlist, - process.env, - ); + const androidSerialAllowlist = resolveAndroidSerialAllowlist(flags?.androidDeviceAllowlist, env); return { platform: flags?.platform, target: flags?.target, @@ -103,7 +104,7 @@ function buildReplayTestShardInventoryRequest( requestedSetPath: flags?.iosSimulatorDeviceSet, platform: flags?.platform, target: flags?.target, - env: process.env, + env, }), androidSerialAllowlist: androidSerialAllowlist ? Array.from(androidSerialAllowlist).sort() diff --git a/packages/replay-port/src/daemon-port/test-command-flag-policy.test.ts b/packages/replay-port/src/daemon-port/test-command-flag-policy.test.ts index 2f5149a482..c139fae6c7 100644 --- a/packages/replay-port/src/daemon-port/test-command-flag-policy.test.ts +++ b/packages/replay-port/src/daemon-port/test-command-flag-policy.test.ts @@ -17,6 +17,7 @@ function rejectedTestCommand(flags: CommandFlags) { flags, meta: { cwd: '/suite' }, }, + host: { env: process.env, cwd: '/suite' }, session: { name: 'default', logPath: '/suite/daemon.log' } as unknown as ReplaySession, createSession, invoke, diff --git a/packages/replay-port/src/daemon-port/test-command.ts b/packages/replay-port/src/daemon-port/test-command.ts index 09a23cdc28..00d0169382 100644 --- a/packages/replay-port/src/daemon-port/test-command.ts +++ b/packages/replay-port/src/daemon-port/test-command.ts @@ -176,7 +176,7 @@ export async function runReplayTestCommand(command: ReplayTestCommand): Promise< // replay attempt executes exactly the text the caller read for that file. let sourceBundles: readonly ReplayScriptSourceBundle[]; try { - suiteRequest = toReplayTestSuiteRequest(req, sessionName); + suiteRequest = toReplayTestSuiteRequest(req, sessionName, command.host.cwd); sourceBundles = requireReplayTestScriptSources(req); } catch (error) { const appErr = asAppError(error); @@ -283,7 +283,7 @@ export async function runReplayTestCommand(command: ReplayTestCommand): Promise< ); }, discoverSources: await buildReplayTestSourceDiscovery(sourceBundles, req.flags?.replayBackend), - resolveShardTargets: buildReplayTestShardTargetResolver(req.flags), + resolveShardTargets: buildReplayTestShardTargetResolver(req.flags, command.host.env), cleanupSession, }); return outcome.status === 'completed' @@ -393,6 +393,7 @@ function requireReplayTestScriptSources( function toReplayTestSuiteRequest( req: DaemonWireRequest, sessionName: string, + hostCwd: string, ): ReplayTestSuiteRequest { const flags = req.flags ?? {}; const cwd = req.meta?.cwd; @@ -404,9 +405,7 @@ function toReplayTestSuiteRequest( requestId: req.meta?.requestId, platformFilter: flags.platform, artifactsDir: - artifactsDir === undefined - ? undefined - : expandSessionPath(artifactsDir, cwd ?? process.cwd()), + artifactsDir === undefined ? undefined : expandSessionPath(artifactsDir, cwd ?? hostCwd), failFast: flags.failFast === true, retries: numberFlag(flags.retries), timeoutMs: numberFlag(flags.timeoutMs), @@ -426,15 +425,16 @@ function nestedReplayCommand( }, ): Pick< ReplayCommand, - 'request' | 'publicNetworkOnly' | 'resolvedSessionScope' | 'dispatch' | 'dependencies' + 'request' | 'host' | 'publicNetworkOnly' | 'resolvedSessionScope' | 'dispatch' | 'dependencies' > { - const { publicNetworkOnly, resolvedSessionScope, dispatch, dependencies } = command; + const { host, publicNetworkOnly, resolvedSessionScope, dispatch, dependencies } = command; const nestedDispatch = dispatch || attempt.openLifecycle ? { ...dispatch, ...(attempt.openLifecycle ? { openLifecycle: attempt.openLifecycle } : {}) } : undefined; return { request: attempt.request, + host, ...(publicNetworkOnly ? { publicNetworkOnly } : {}), ...(resolvedSessionScope ? { resolvedSessionScope } : {}), ...(nestedDispatch ? { dispatch: nestedDispatch } : {}), diff --git a/packages/replay-port/src/session-replay-vars.ts b/packages/replay-port/src/session-replay-vars.ts index 602896298c..d93291a6a8 100644 --- a/packages/replay-port/src/session-replay-vars.ts +++ b/packages/replay-port/src/session-replay-vars.ts @@ -7,12 +7,14 @@ export function buildReplayBuiltinVars(params: { sessionName: string; metadata: ReplayScriptMetadata; resolvedPath: string; + /** The command's host working directory; AD_FILENAME falls back to it when the request omits one. */ + hostCwd: string; }): Record { - const { req, sessionName, metadata, resolvedPath } = params; + const { req, sessionName, metadata, resolvedPath, hostCwd } = params; const flags = req.flags ?? {}; const builtins: Record = { AD_SESSION: sessionName, - AD_FILENAME: resolveReplayFilename(req, resolvedPath), + AD_FILENAME: resolveReplayFilename(req, resolvedPath, hostCwd), }; addReplayStringBuiltin(builtins, 'AD_PLATFORM', flags.platform ?? metadata.platform); addReplayStringBuiltin(builtins, 'AD_TARGET', flags.target ?? metadata.target); @@ -24,8 +26,12 @@ export function buildReplayBuiltinVars(params: { return builtins; } -function resolveReplayFilename(req: DaemonWireRequest, resolvedPath: string): string { - const cwd = req.meta?.cwd ?? process.cwd(); +function resolveReplayFilename( + req: DaemonWireRequest, + resolvedPath: string, + hostCwd: string, +): string { + const cwd = req.meta?.cwd ?? hostCwd; return path.relative(cwd, resolvedPath) || resolvedPath; } diff --git a/src/platform-runtime-device-inventory.test.ts b/src/platform-runtime-device-inventory.test.ts index e9badcb920..65cece5563 100644 --- a/src/platform-runtime-device-inventory.test.ts +++ b/src/platform-runtime-device-inventory.test.ts @@ -78,9 +78,7 @@ describe('production device inventory composition', () => { withDeviceInventoryContext({ ...gateways, requestScope: testRequestScope }, () => resolveTargetDeviceSelection( { platform: 'ios' }, - { - appleSimulatorAppTarget: 'com.example.demo', - }, + { env: process.env, appleSimulatorAppTarget: 'com.example.demo' }, ), ), );