diff --git a/CHANGELOG.md b/CHANGELOG.md index c99ca7fd94..a532cf25b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Behavior change: `resolveHubAppReference`, shipped through `agent-device/plugins/webdriver`, now needs `cwd` to resolve a relative app path. Without it the call fails with `INVALID_ARGS` and `details.reason: provider_app_cwd_missing`; it used to resolve against the process's working directory. Absolute paths, URLs and provider references are unaffected. The daemon fills it from the client's working directory (or its host's) for every provider lease request, so `connect` and `lease_allocate` are unchanged. - The `daemon-not-running` hint now always names `--state-dir `, and a daemon force-retired for outlasting its drain is reported as `Replaced unreachable daemon` instead of `Replaced idle daemon`. `daemon-drain-timeout` and the post-drain force retire are documented in the client API and `help`. - Fix `ensureDaemon()`, the CLI and MCP killing a daemon they had just asked to leave. After the daemon answers the quiesce request with `draining`, the client now waits up to 60 s for it to exit by itself and clean up its registration, so plugin disposal and lease finalization finish (on Windows a signal ends the process outright, and on POSIX a drain longer than 3 s was killed). A daemon still running at the deadline that fails its liveness probes is force-retired; one that still answers is left running and the call fails with `details.reason: 'daemon-drain-timeout'`, with a hint naming `agent-device daemon stop --state-dir `. - `@agent-device/testmu` is now a version 2 provider plugin. `import testmu from '@agent-device/testmu'` gives a factory, `testmu({ credentials: { username, accessKey } })`, that reads those credentials instead of `LT_USERNAME` and `LT_ACCESS_KEY`; without options it reads the variables as before. The package still declares the version 1 plugin entry, so `agent-device plugins add @agent-device/testmu` keeps working on hosts that predate the version 2 contract. diff --git a/packages/capture-kit/src/recording/output-path.test.ts b/packages/capture-kit/src/recording/output-path.test.ts index b3ed31398f..cee5ee91d0 100644 --- a/packages/capture-kit/src/recording/output-path.test.ts +++ b/packages/capture-kit/src/recording/output-path.test.ts @@ -5,7 +5,13 @@ test('preserves platform-specific default recording extensions', () => { const defaults = resolveRecordingOutputPaths({ platform: 'web', cwd: '/workspace' }); expect(defaults.requestedPath).toMatch(/^\.\/recording-\d+\.webm$/); expect(defaults.outputPath).toMatch(/^\/workspace\/recording-\d+\.webm$/); - expect(resolveRecordingOutputPaths({ requestedPath: '/tmp/capture', platform: 'web' })).toEqual({ + expect( + resolveRecordingOutputPaths({ + cwd: process.cwd(), + requestedPath: '/tmp/capture', + platform: 'web', + }), + ).toEqual({ requestedPath: '/tmp/capture.webm', outputPath: '/tmp/capture.webm', }); @@ -24,6 +30,7 @@ test('keeps display paths distinct from expanded native output paths', () => { }); const homePaths = resolveRecordingOutputPaths({ + cwd: process.cwd(), requestedPath: '~/capture.mp4', platform: 'android', }); diff --git a/packages/capture-kit/src/recording/output-path.ts b/packages/capture-kit/src/recording/output-path.ts index d8c5964f69..64f6d714e6 100644 --- a/packages/capture-kit/src/recording/output-path.ts +++ b/packages/capture-kit/src/recording/output-path.ts @@ -27,7 +27,7 @@ export type RecordingOutputPaths = Readonly<{ export function resolveRecordingOutputPaths(params: { requestedPath?: string; platform: Platform; - cwd?: string; + cwd: string; }): RecordingOutputPaths { const requestedPath = params.requestedPath === undefined diff --git a/packages/daemon-contracts/src/daemon-policy-file.test.ts b/packages/daemon-contracts/src/daemon-policy-file.test.ts index 2757f43405..7990900484 100644 --- a/packages/daemon-contracts/src/daemon-policy-file.test.ts +++ b/packages/daemon-contracts/src/daemon-policy-file.test.ts @@ -10,7 +10,7 @@ import { DAEMON_POLICY_ENV, loadDaemonPolicy, parseDaemonPolicy } from './daemon const SOURCE = '/etc/agent-device/policy.json'; test('no configured policy leaves the daemon unrestricted', () => { - expect(loadDaemonPolicy({})).toBeUndefined(); + expect(loadDaemonPolicy({}, process.cwd())).toBeUndefined(); }); test('a policy file that cannot be read or parsed fails loading', () => { @@ -18,12 +18,21 @@ test('a policy file that cannot be read or parsed fails loading', () => { const policyPath = path.join(dir, 'policy.json'); fs.writeFileSync(policyPath, '{ not json'); - expect(() => loadDaemonPolicy({ [DAEMON_POLICY_ENV]: policyPath })).toThrow( + expect(() => loadDaemonPolicy({ [DAEMON_POLICY_ENV]: policyPath }, dir)).toThrow( /Invalid daemon policy/, ); - expect(() => loadDaemonPolicy({ [DAEMON_POLICY_ENV]: path.join(dir, 'missing.json') })).toThrow( - /cannot read policy JSON/, - ); + expect(() => + loadDaemonPolicy({ [DAEMON_POLICY_ENV]: path.join(dir, 'missing.json') }, dir), + ).toThrow(/cannot read policy JSON/); +}); + +test('a relative policy path resolves against the host working directory, not the process', () => { + const dir = mkdtempForTestSync('agent-device-daemon-policy-relative-'); + fs.writeFileSync(path.join(dir, 'policy.json'), JSON.stringify({ version: 1 })); + + const policy = loadDaemonPolicy({ [DAEMON_POLICY_ENV]: 'policy.json' }, dir); + + expect(policy?.sourcePath).toBe(path.join(dir, 'policy.json')); }); test.each([ diff --git a/packages/daemon-contracts/src/daemon-policy-file.ts b/packages/daemon-contracts/src/daemon-policy-file.ts index bef16cd815..06947932ca 100644 --- a/packages/daemon-contracts/src/daemon-policy-file.ts +++ b/packages/daemon-contracts/src/daemon-policy-file.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { commandDescriptors } from '@agent-device/command-registry/registry'; import type { CommandDescriptor } from '@agent-device/command-registry/types'; import { AppError, errorMessage } from '@agent-device/kernel/errors'; +import type { EnvMap } from '@agent-device/kernel/source-value'; /** * ADR 0029: the operator-owned rules one daemon enforces for every request it admits. This module @@ -72,10 +73,10 @@ export function resolveDaemonPolicyCommandName(command: string): string | undefi const POLICY_KEYS = new Set(['version', 'devices', 'commands', 'capabilities', 'leases']); -export function loadDaemonPolicy(env: NodeJS.ProcessEnv = process.env): DaemonPolicy | undefined { +export function loadDaemonPolicy(env: EnvMap, cwd: string): DaemonPolicy | undefined { const configured = env[DAEMON_POLICY_ENV]?.trim(); if (!configured) return undefined; - const sourcePath = path.resolve(configured); + const sourcePath = path.resolve(cwd, configured); let raw: unknown; try { raw = JSON.parse(fs.readFileSync(sourcePath, 'utf8')); diff --git a/packages/daemon-contracts/src/daemon-registration-owner.ts b/packages/daemon-contracts/src/daemon-registration-owner.ts index 893f7462d7..5e7090da51 100644 --- a/packages/daemon-contracts/src/daemon-registration-owner.ts +++ b/packages/daemon-contracts/src/daemon-registration-owner.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { runCmdDetachedMonitored, type ExecDetachedExit } from '@agent-device/host-kit/command'; import { AppError, normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { stopDaemonProcess, waitForDaemonExit, @@ -252,6 +253,10 @@ export function launchDaemonProcess( paths: DaemonPaths; args: string[]; serverMode: DaemonServerMode; + /** The environment the daemon process starts with, before the state dir and mode overrides. */ + env: EnvMap; + /** The daemon's working directory: the one its client resolves relative paths against. */ + cwd: string; ownedStateDir?: OwnedReplayStateDir; }>, ): DaemonStartupLaunch { @@ -264,8 +269,9 @@ export function launchDaemonProcess( const logFd = fs.openSync(input.paths.logPath, 'a'); try { const monitored = runCmdDetachedMonitored(process.execPath, input.args, { + cwd: input.cwd, env: { - ...process.env, + ...input.env, AGENT_DEVICE_STATE_DIR: input.paths.baseDir, AGENT_DEVICE_DAEMON_SERVER_MODE: input.serverMode, }, diff --git a/packages/daemon-contracts/src/daemon-resolution.test.ts b/packages/daemon-contracts/src/daemon-resolution.test.ts new file mode 100644 index 0000000000..e78f790bae --- /dev/null +++ b/packages/daemon-contracts/src/daemon-resolution.test.ts @@ -0,0 +1,22 @@ +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { resolveDaemonPaths } from './daemon-resolution.ts'; + +test('a relative state dir resolves against the working directory it is given', () => { + const paths = resolveDaemonPaths('state/here', { cwd: '/workspace/project' }); + + expect(paths.baseDir).toBe(path.resolve('/workspace/project', 'state/here')); +}); + +test('a relative state dir without a working directory is refused with a typed reason', () => { + expect(() => resolveDaemonPaths('state/here')).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + details: expect.objectContaining({ reason: 'relative_state_dir_without_cwd' }), + }), + ); +}); + +test('an absolute state dir needs no working directory', () => { + expect(resolveDaemonPaths('/var/agent-device/state').baseDir).toBe('/var/agent-device/state'); +}); diff --git a/packages/daemon-contracts/src/daemon-resolution.ts b/packages/daemon-contracts/src/daemon-resolution.ts index 773940d0ba..1d95738b18 100644 --- a/packages/daemon-contracts/src/daemon-resolution.ts +++ b/packages/daemon-contracts/src/daemon-resolution.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { expandUserHomePath, resolveUserPath } from '@agent-device/host-kit/file'; import { findProjectRoot, isSourceCheckoutProjectRoot } from '@agent-device/host-kit/version'; +import { AppError } from '@agent-device/kernel/errors'; import { type EnvMap } from '@agent-device/kernel/source-value'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; @@ -30,6 +31,8 @@ export type DaemonPaths = { type ResolveDaemonPathsOptions = { env?: EnvMap; + /** Resolves a relative state dir; a relative one without it is refused. */ + cwd?: string; projectRoot?: string; }; @@ -53,7 +56,18 @@ function resolveStateDir(raw: string | undefined, options: ResolveDaemonPathsOpt if (!value) { return resolveDefaultDaemonStateDir(options); } - return resolveUserPath(value, { env: options.env }); + const expanded = expandUserHomePath(value, { env: options.env }); + if (path.isAbsolute(expanded)) return expanded; + if (options.cwd === undefined) { + throw new AppError( + 'INVALID_ARGS', + `State dir "${value}" is relative and no working directory was given.`, + { + reason: 'relative_state_dir_without_cwd', + }, + ); + } + return resolveUserPath(value, { env: options.env, cwd: options.cwd }); } function resolveDefaultDaemonStateDir(options: ResolveDaemonPathsOptions = {}): string { diff --git a/packages/daemon-contracts/src/device-claim-inspection.ts b/packages/daemon-contracts/src/device-claim-inspection.ts index 00e83dc551..f84da0d521 100644 --- a/packages/daemon-contracts/src/device-claim-inspection.ts +++ b/packages/daemon-contracts/src/device-claim-inspection.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { matchesPlatformSelector, type PlatformSelector } from '@agent-device/kernel/device'; import { classifyOwnerLivenessFromObservation, @@ -29,8 +30,11 @@ export type DeviceClaimSelectors = { serial?: string; }; -export function inspectDeviceClaims(selectors: DeviceClaimSelectors): InspectedDeviceClaim[] { - const root = resolveDeviceClaimRoot(); +export function inspectDeviceClaims( + selectors: DeviceClaimSelectors, + env: EnvMap, +): InspectedDeviceClaim[] { + const root = resolveDeviceClaimRoot(env); const listed = readClaimEntries(root); if ('claims' in listed) return listed.claims; const parsed = listed.entries diff --git a/packages/daemon-contracts/src/device-claim-paths.test.ts b/packages/daemon-contracts/src/device-claim-paths.test.ts index 5143bbd8b8..ac195626bd 100644 --- a/packages/daemon-contracts/src/device-claim-paths.test.ts +++ b/packages/daemon-contracts/src/device-claim-paths.test.ts @@ -1,21 +1,29 @@ import path from 'node:path'; import { expect, test, vi } from 'vitest'; import { deviceClaimFilePath } from '@agent-device/test-kit'; -import { resolveDeviceClaimPath } from './device-claim-paths.ts'; +import { resolveDeviceClaimPath, resolveDeviceClaimRoot } from './device-claim-paths.ts'; const DEVICE_KEYS = ['local:android:none:emulator-5554', 'local:ios:ios:ABCD-1234']; test('the claim file path test-kit restates matches the production resolver', () => { vi.stubEnv('AGENT_DEVICE_CLAIMS_DIR', ''); for (const key of DEVICE_KEYS) { - expect(deviceClaimFilePath(key)).toBe(resolveDeviceClaimPath(key)); + expect(deviceClaimFilePath(key)).toBe(resolveDeviceClaimPath(key, process.env)); } }); test('the restated path follows the claims directory override like the resolver', () => { - vi.stubEnv('AGENT_DEVICE_CLAIMS_DIR', path.join('relative', 'claims')); + vi.stubEnv('AGENT_DEVICE_CLAIMS_DIR', path.join(path.sep, 'tmp', 'agent-device-claims-override')); for (const key of DEVICE_KEYS) { - expect(deviceClaimFilePath(key)).toBe(resolveDeviceClaimPath(key)); + expect(deviceClaimFilePath(key)).toBe(resolveDeviceClaimPath(key, process.env)); expect(path.isAbsolute(deviceClaimFilePath(key))).toBe(true); } }); + +test('a relative claims dir is refused instead of following the working directory', () => { + expect(() => resolveDeviceClaimRoot({ AGENT_DEVICE_CLAIMS_DIR: 'claims' })).toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'relative_claims_dir' }), + }), + ); +}); diff --git a/packages/daemon-contracts/src/device-claim-paths.ts b/packages/daemon-contracts/src/device-claim-paths.ts index 47e8235a26..f183708fed 100644 --- a/packages/daemon-contracts/src/device-claim-paths.ts +++ b/packages/daemon-contracts/src/device-claim-paths.ts @@ -1,6 +1,8 @@ import crypto from 'node:crypto'; import os from 'node:os'; import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { type AppleOS, type DeviceIdentity, @@ -24,14 +26,24 @@ function formatLocalDeviceKey( return `local:${platform}:${appleOs ?? 'none'}:${id}`; } -export function resolveDeviceClaimRoot(): string { - const override = process.env.AGENT_DEVICE_CLAIMS_DIR?.trim(); - return override - ? path.resolve(override) - : path.join(os.homedir(), '.agent-device', 'device-claims'); +export function resolveDeviceClaimRoot(env: EnvMap): string { + const override = env.AGENT_DEVICE_CLAIMS_DIR?.trim(); + if (!override) return path.join(os.homedir(), '.agent-device', 'device-claims'); + // The store is shared by every daemon on the host, so a directory that depends on where one of + // them happened to start is not a place they can agree on. + if (!path.isAbsolute(override)) { + throw new AppError( + 'INVALID_ARGS', + `AGENT_DEVICE_CLAIMS_DIR must be an absolute path: ${override}`, + { + reason: 'relative_claims_dir', + }, + ); + } + return path.resolve(override); } -export function resolveDeviceClaimPath(deviceKey: string): string { +export function resolveDeviceClaimPath(deviceKey: string, env: EnvMap): string { const hash = crypto.createHash('sha256').update(deviceKey).digest('hex'); - return path.join(resolveDeviceClaimRoot(), `${hash}.json`); + return path.join(resolveDeviceClaimRoot(env), `${hash}.json`); } diff --git a/packages/daemon-contracts/src/device-claim-settlement.ts b/packages/daemon-contracts/src/device-claim-settlement.ts index cadc58543f..b5d5ccd38d 100644 --- a/packages/daemon-contracts/src/device-claim-settlement.ts +++ b/packages/daemon-contracts/src/device-claim-settlement.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import type { DeviceBootObservationService } from '@agent-device/contracts/device-boot'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { ownerIdentityMatches, type readCurrentOwnerIdentity, @@ -71,8 +72,9 @@ export async function resolveExistingClaim(params: { transientCommand?: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; observeDeviceBoot?: DeviceBootObservationService; + env: EnvMap; }): Promise { - const existing = inspectDeviceClaimFile(resolveDeviceClaimPath(params.deviceKey)); + const existing = inspectDeviceClaimFile(resolveDeviceClaimPath(params.deviceKey, params.env)); if (!existing) return { status: 'available' }; if ( existing.claim && @@ -82,7 +84,7 @@ export async function resolveExistingClaim(params: { return { status: 'available' }; } if (existing.claim && isCurrentClaimOwner(existing.claim, params, params.owner)) { - return { status: 'held', ownership: renewHeldClaim(existing.claim) }; + return { status: 'held', ownership: renewHeldClaim(existing.claim, params.env) }; } return await settleForeignClaim(existing, params); } @@ -93,10 +95,10 @@ export async function resolveExistingClaim(params: { * keep a claim stamped before the reboot, and the next foreign `open` would read that stamp as * proof of a device nobody owns and take it out from under a session that is demonstrably running. */ -function renewHeldClaim(claim: DeviceClaim): DeviceClaimSessionOwnership { +function renewHeldClaim(claim: DeviceClaim, env: EnvMap): DeviceClaimSessionOwnership { if (claim.updatedAtMs >= Date.now()) return ownershipFromClaim(claim); const renewed: DeviceClaim = { ...claim, updatedAtMs: Date.now() }; - writeDeviceClaim(renewed); + writeDeviceClaim(renewed, env); return ownershipFromClaim(renewed); } @@ -112,6 +114,7 @@ async function settleForeignClaim( deviceKey: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; observeDeviceBoot?: DeviceBootObservationService; + env: EnvMap; }, ): Promise { const claim = existing.claim; @@ -132,6 +135,7 @@ async function settleForeignClaim( const reconciliation = await settleVerifiedOrphanedClaim( claim, params.reconcileOrphanedDeviceClaim, + params.env, ); if (reconciliation.status === 'retained') { emitClaimConflict(params.deviceKey, existing, reconciliation.reason); @@ -164,11 +168,12 @@ async function foreignClaimSettlement(params: { export async function settleVerifiedOrphanedClaim( claim: DeviceClaim, reconcile: DeviceClaimReconciler, + env: EnvMap, ): Promise { const result = await reconcile(claim); if (result.status === 'retained') return result; try { - fs.unlinkSync(resolveDeviceClaimPath(claim.deviceKey)); + fs.unlinkSync(resolveDeviceClaimPath(claim.deviceKey, env)); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } diff --git a/packages/daemon-contracts/src/device-claim-store.ts b/packages/daemon-contracts/src/device-claim-store.ts index 2f08e7da65..cc98b920aa 100644 --- a/packages/daemon-contracts/src/device-claim-store.ts +++ b/packages/daemon-contracts/src/device-claim-store.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { publishFileSync, acquireProcessLock, withProcessLock } from '@agent-device/host-kit/file'; import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { resolveDeviceClaimPath } from './device-claim-paths.ts'; import type { DeviceClaim } from './device-claim-record.ts'; @@ -9,8 +10,8 @@ import type { DeviceClaim } from './device-claim-record.ts'; const DEVICE_CLAIM_LOCK_TIMEOUT_MS = 30_000; /** The single writer of a claim file. */ -export function writeDeviceClaim(claim: DeviceClaim): void { - const claimPath = resolveDeviceClaimPath(claim.deviceKey); +export function writeDeviceClaim(claim: DeviceClaim, env: EnvMap): void { + const claimPath = resolveDeviceClaimPath(claim.deviceKey, env); fs.mkdirSync(path.dirname(claimPath), { recursive: true, mode: 0o700 }); publishFileSync({ destination: claimPath, @@ -22,13 +23,14 @@ export function writeDeviceClaim(claim: DeviceClaim): void { /** The host-global per-device mutual exclusion every claim write and clear runs under. */ export async function withDeviceClaimLock( deviceKey: string, + env: EnvMap, task: () => Promise, ): Promise { const owner = readCurrentOwnerIdentity(); return await withProcessLock({ acquire: () => acquireProcessLock({ - lockDirPath: `${resolveDeviceClaimPath(deviceKey)}.lock`, + lockDirPath: `${resolveDeviceClaimPath(deviceKey, env)}.lock`, owner: { pid: owner.pid, startTime: owner.startTime, acquiredAtMs: Date.now() }, timeoutMs: DEVICE_CLAIM_LOCK_TIMEOUT_MS, description: `device claim for ${deviceKey}`, diff --git a/packages/daemon-contracts/src/device-claims.ts b/packages/daemon-contracts/src/device-claims.ts index 012ea48437..d8ef17d565 100644 --- a/packages/daemon-contracts/src/device-claims.ts +++ b/packages/daemon-contracts/src/device-claims.ts @@ -1,6 +1,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { deviceIdentity, isApplePlatform, @@ -84,6 +85,7 @@ export async function acquireDeviceClaim(params: { session: string; workspace: string; stateDir: string; + env: EnvMap; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; /** * Asks the device whether it rebooted after a foreign claim was taken, which is the only proof @@ -95,6 +97,7 @@ export async function acquireDeviceClaim(params: { const deviceKey = canonicalLocalDeviceKey(identity); return await withDeviceClaimLock( deviceKey, + params.env, async () => await claimHeldDevice({ ...params, deviceKey, identity }), ); } @@ -136,12 +139,13 @@ export async function acquireTransientDeviceClaim(params: { coveredClaimIsOwnedByRequest: (claim: DeviceClaim) => boolean; workspace: string; stateDir: string; + env: EnvMap; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; }): Promise { const identity = deviceClaimIdentity(params.device); const deviceKey = canonicalLocalDeviceKey(identity); - return await withDeviceClaimLock(deviceKey, async () => { - const existing = inspectDeviceClaimFile(resolveDeviceClaimPath(deviceKey)); + return await withDeviceClaimLock(deviceKey, params.env, async () => { + const existing = inspectDeviceClaimFile(resolveDeviceClaimPath(deviceKey, params.env)); if ( existing?.claim && !isAbandonedDeviceClaim(existing.claim) && @@ -174,6 +178,7 @@ export async function acquireTransientDeviceClaim(params: { transientCommand: params.command, workspace: params.workspace, stateDir: params.stateDir, + env: params.env, reconcileOrphanedDeviceClaim: params.reconcileOrphanedDeviceClaim, }); }); @@ -189,6 +194,7 @@ async function claimHeldDevice(params: { transientCommand?: string; workspace: string; stateDir: string; + env: EnvMap; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; observeDeviceBoot?: DeviceBootObservationService; }): Promise { @@ -222,7 +228,7 @@ async function claimHeldDevice(params: { claimOwnerKind, ...(params.transientCommand === undefined ? {} : { transientCommand: params.transientCommand }), }; - writeDeviceClaim(claim); + writeDeviceClaim(claim, params.env); return { status: 'acquired', ownership: ownershipFromClaim(claim), @@ -240,9 +246,9 @@ async function claimHeldDevice(params: { * alone — one process serves one daemon — and an abandoned claim holds the * device for nobody, so it grants no authority either. */ -export function processOwnsActiveDeviceClaim(device: DeviceInfo): boolean { +export function processOwnsActiveDeviceClaim(device: DeviceInfo, env: EnvMap): boolean { const deviceKey = canonicalLocalDeviceKey(deviceClaimIdentity(device)); - const claim = inspectDeviceClaimFile(resolveDeviceClaimPath(deviceKey))?.claim; + const claim = inspectDeviceClaimFile(resolveDeviceClaimPath(deviceKey, env))?.claim; return ( claim !== undefined && claim.deviceKey === deviceKey && @@ -290,10 +296,11 @@ export type DeviceClaimStaleReleaseOutcome = { export async function releaseProvenStaleDeviceClaims(params: { selectors: DeviceClaimSelectors; reconcile: DeviceClaimReconciler; + env: EnvMap; }): Promise { const outcomes: DeviceClaimStaleReleaseOutcome[] = []; - for (const entry of inspectDeviceClaims(params.selectors)) { - outcomes.push(await releaseInspectedStaleClaim(entry, params.reconcile)); + for (const entry of inspectDeviceClaims(params.selectors, params.env)) { + outcomes.push(await releaseInspectedStaleClaim(entry, params.reconcile, params.env)); } return outcomes; } @@ -301,6 +308,7 @@ export async function releaseProvenStaleDeviceClaims(params: { async function releaseInspectedStaleClaim( entry: InspectedDeviceClaim, reconcile: DeviceClaimReconciler, + env: EnvMap, ): Promise { const claim = entry.claim; const base = { @@ -322,13 +330,14 @@ async function releaseInspectedStaleClaim( // A file whose name is not the hash of its own device key is not the file // the claim lock protects; releasing through it could unlink something else. if ( - resolveDeviceClaimPath(claim.deviceKey) !== path.join(resolveDeviceClaimRoot(), entry.fileName) + resolveDeviceClaimPath(claim.deviceKey, env) !== + path.join(resolveDeviceClaimRoot(env), entry.fileName) ) { return { ...base, status: 'refused', reason: 'claim-file-name-mismatch' }; } const { deviceKey, ownerToken } = claim; - return await withDeviceClaimLock(deviceKey, async () => { - const current = inspectDeviceClaimFile(resolveDeviceClaimPath(deviceKey)); + return await withDeviceClaimLock(deviceKey, env, async () => { + const current = inspectDeviceClaimFile(resolveDeviceClaimPath(deviceKey, env)); if ( !current?.claim || current.claim.ownerToken !== ownerToken || @@ -336,7 +345,7 @@ async function releaseInspectedStaleClaim( ) { return { ...base, status: 'changed' as const, reason: 'claim-changed-during-release' }; } - const result = await settleVerifiedOrphanedClaim(current.claim, reconcile); + const result = await settleVerifiedOrphanedClaim(current.claim, reconcile, env); if (result.status === 'retained') { return { ...base, status: 'retained' as const, reason: result.reason }; } @@ -389,10 +398,12 @@ export type DeviceClaimClearOutcome = 'deleted' | 'absent' | 'ownership-changed' export async function clearDeviceClaim( ownership: DeviceClaimSessionOwnership | undefined, + env: EnvMap, ): Promise { if (!ownership) return 'absent'; return await writeOwnedDeviceClaim( ownership, + env, (_claim, claimPath) => { try { fs.unlinkSync(claimPath); @@ -427,13 +438,15 @@ export type DeviceClaimAbandonOutcome = 'abandoned' | 'absent' | 'ownership-chan */ export async function abandonDeviceClaim( ownership: DeviceClaimSessionOwnership | undefined, + env: EnvMap, ): Promise { if (!ownership) return 'absent'; return await writeOwnedDeviceClaim( ownership, + env, (claim) => { const now = Date.now(); - writeDeviceClaim({ ...claim, abandonedAtMs: now, updatedAtMs: now }); + writeDeviceClaim({ ...claim, abandonedAtMs: now, updatedAtMs: now }, env); return 'abandoned'; }, (conflict) => (conflict ? 'ownership-changed' : 'absent'), @@ -459,12 +472,14 @@ export type DeviceClaimRenewal = */ export async function renewDeviceClaim( ownership: DeviceClaimSessionOwnership | undefined, + env: EnvMap, ): Promise { if (!ownership) return { status: 'lost', conflict: undefined }; return await writeOwnedDeviceClaim( ownership, + env, (claim) => { - writeDeviceClaim({ ...claim, updatedAtMs: Date.now() }); + writeDeviceClaim({ ...claim, updatedAtMs: Date.now() }, env); return { status: 'renewed' } as const; }, (conflict) => ({ status: 'lost' as const, conflict }), @@ -477,11 +492,12 @@ export async function renewDeviceClaim( */ async function writeOwnedDeviceClaim( ownership: DeviceClaimSessionOwnership, + env: EnvMap, act: (claim: DeviceClaim, claimPath: string) => O, lost: (conflict: InspectedDeviceClaim | undefined) => L, ): Promise { - return await withDeviceClaimLock(ownership.deviceKey, async () => { - const claimPath = resolveDeviceClaimPath(ownership.deviceKey); + return await withDeviceClaimLock(ownership.deviceKey, env, async () => { + const claimPath = resolveDeviceClaimPath(ownership.deviceKey, env); const inspected = inspectDeviceClaimFile(claimPath); if (!inspected) return lost(undefined); const claim = inspected.claim; @@ -525,8 +541,9 @@ function claimMatchesOwnership( export async function reconcileOrphanedDeviceClaims( reconcile: DeviceClaimReconciler, daemonStateDir: string, + env: EnvMap, ): Promise<{ examined: number; reconciled: number; retained: number; changed: number }> { - const root = resolveDeviceClaimRoot(); + const root = resolveDeviceClaimRoot(env); let entries: fs.Dirent[]; try { entries = fs.readdirSync(root, { withFileTypes: true }); @@ -551,11 +568,11 @@ export async function reconcileOrphanedDeviceClaims( const { deviceKey, ownerToken } = scanned.claim; // A file whose name is not the hash of its own device key is not the file // the lock protects, so leave it rather than unlink something else. - if (resolveDeviceClaimPath(deviceKey) !== filePath) { + if (resolveDeviceClaimPath(deviceKey, env) !== filePath) { retained += 1; continue; } - await withDeviceClaimLock(deviceKey, async () => { + await withDeviceClaimLock(deviceKey, env, async () => { const current = inspectDeviceClaimFile(filePath); if ( !current?.claim || @@ -565,7 +582,7 @@ export async function reconcileOrphanedDeviceClaims( changed += 1; return; } - const result = await settleVerifiedOrphanedClaim(current.claim, reconcile); + const result = await settleVerifiedOrphanedClaim(current.claim, reconcile, env); if (result.status === 'retained') { retained += 1; return; diff --git a/packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts b/packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts index 7827d2c628..85d3005d17 100644 --- a/packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts +++ b/packages/daemon/src/__tests__/support/daemon-runtime-deps-fixture.ts @@ -87,7 +87,7 @@ export function daemonRuntimeDepsFixture( deviceInventoryGateways: createTestDeviceInventoryGateways(), platformServices: daemonPlatformServicesFixture(), androidObservation: clearAndroidObservationFixture, - providerCredentials: providerCredentialsFixture(input.env, input.baseDir), + providerCredentials: providerCredentialsFixture(input.host.env, input.baseDir), createCommandSurface: fakeCreateCommandSurface, }), ...resolved, diff --git a/packages/daemon/src/__tests__/support/human-control-router-fixture.ts b/packages/daemon/src/__tests__/support/human-control-router-fixture.ts index 3c56a98a2d..8eae0abc4e 100644 --- a/packages/daemon/src/__tests__/support/human-control-router-fixture.ts +++ b/packages/daemon/src/__tests__/support/human-control-router-fixture.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { daemonPlatformServicesFixture, @@ -28,6 +29,7 @@ export function createHumanControlHarness() { }), ); const handleRequest = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: '/tmp/agent-device-human-control.log', platformServices: daemonPlatformServicesFixture(), diff --git a/packages/daemon/src/core/__tests__/daemon-policy.test.ts b/packages/daemon/src/core/__tests__/daemon-policy.test.ts index 73ac1803be..fde89f1075 100644 --- a/packages/daemon/src/core/__tests__/daemon-policy.test.ts +++ b/packages/daemon/src/core/__tests__/daemon-policy.test.ts @@ -7,7 +7,12 @@ import { fakeCreateCommandSurface } from '../../__tests__/support/command-surfac vi.mock('../device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); import type { DeviceInfo } from '@agent-device/kernel/device'; -import { ANDROID_EMULATOR, IOS_SIMULATOR, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + ANDROID_EMULATOR, + IOS_SIMULATOR, + mkdtempForTestSync, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { makeIosSession } from '../../__tests__/support/session-factories.ts'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; @@ -48,6 +53,7 @@ function makeHandler( const bind = vi.fn(lifecycleDeviceRuntimeGateway.bind); const inspectFacts = vi.fn(lifecycleDeviceRuntimeGateway.inspectFacts); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon-policy'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/core/__tests__/request-ended-leases.test.ts b/packages/daemon/src/core/__tests__/request-ended-leases.test.ts index 118325df52..654f626a35 100644 --- a/packages/daemon/src/core/__tests__/request-ended-leases.test.ts +++ b/packages/daemon/src/core/__tests__/request-ended-leases.test.ts @@ -2,7 +2,7 @@ import { createTestDeviceInventoryGateways } from '@agent-device/device-selectio import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { replayScriptSourceBundleFor } from '../../__tests__/support/replay-script-source-fixture.ts'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { makeIosSession } from '../../__tests__/support/session-factories.ts'; @@ -47,6 +47,7 @@ function leasedDaemon() { }), ); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(root, 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts b/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts index 15cd4e57fc..f4d630ecb8 100644 --- a/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts +++ b/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts @@ -11,6 +11,7 @@ import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-d import { createTestAppLogLiveHandle, unavailableDeploymentSnapshotAndShutdownOperationFacts, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; @@ -63,6 +64,7 @@ function makeHandler(gateway: DeviceRuntimeGateway) { return { sessionStore, handler: createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: '/tmp/daemon.log', token: 'token', diff --git a/packages/daemon/src/core/session-replay-transaction.ts b/packages/daemon/src/core/session-replay-transaction.ts index 8dfd16ea37..cec5e29c7a 100644 --- a/packages/daemon/src/core/session-replay-transaction.ts +++ b/packages/daemon/src/core/session-replay-transaction.ts @@ -62,7 +62,7 @@ export function armRepairStep( const state = publicationState(session); const explicitPath = typeof params.saveScript === 'string' - ? expandSessionPath(params.saveScript) + ? expandSessionPath(params.saveScript, process.cwd()) : state.kind === 'repair' ? undefined : params.healedSiblingPath; diff --git a/packages/daemon/src/core/session-runtime-admission.ts b/packages/daemon/src/core/session-runtime-admission.ts index 2225b84f65..7f2c203315 100644 --- a/packages/daemon/src/core/session-runtime-admission.ts +++ b/packages/daemon/src/core/session-runtime-admission.ts @@ -1,4 +1,5 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonPlatformServices } from './platform-services.ts'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import type { @@ -14,6 +15,7 @@ import type { DaemonRequest } from './daemon-request.ts'; import { unsupportedOperationResponse } from './runtime-admission.ts'; export type RuntimeCommandHandlerParams = Readonly<{ + host: HostEnvironment; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; diff --git a/packages/daemon/src/core/session-script-publication-capability.ts b/packages/daemon/src/core/session-script-publication-capability.ts index 43e56d2f86..0781bfa9ef 100644 --- a/packages/daemon/src/core/session-script-publication-capability.ts +++ b/packages/daemon/src/core/session-script-publication-capability.ts @@ -43,7 +43,8 @@ export function armAuthoringOnOpen( requested: { path?: string; force?: boolean }, ): void { session.scriptPublication = armAuthoring({ - path: requested.path !== undefined ? expandSessionPath(requested.path) : undefined, + path: + requested.path !== undefined ? expandSessionPath(requested.path, process.cwd()) : undefined, force: requested.force === true, }); } @@ -91,7 +92,10 @@ export function applyRecordedSaveScriptFlags(session: SessionState, flags: Comma if (!flags.saveScript) return; if (isAuthoringAborted(publicationState(session))) return; const requested = { - path: typeof flags.saveScript === 'string' ? expandSessionPath(flags.saveScript) : undefined, + path: + typeof flags.saveScript === 'string' + ? expandSessionPath(flags.saveScript, process.cwd()) + : undefined, force: flags.force === true, }; const state = publicationState(session); @@ -126,7 +130,10 @@ export function retargetActivePublication( session.scriptPublication = { ...state, target: resolveScriptTarget(state.target, { - path: params.explicitPath !== undefined ? expandSessionPath(params.explicitPath) : undefined, + path: + params.explicitPath !== undefined + ? expandSessionPath(params.explicitPath, process.cwd()) + : undefined, force: params.liveForce === true, }), }; diff --git a/packages/daemon/src/core/session-script-writer.ts b/packages/daemon/src/core/session-script-writer.ts index 3c410ca358..6ee357c54a 100644 --- a/packages/daemon/src/core/session-script-writer.ts +++ b/packages/daemon/src/core/session-script-writer.ts @@ -160,7 +160,7 @@ export class SessionScriptWriter { private resolveScriptPath(session: SessionState): string { const targetPath = scriptTargetPath(session.scriptPublication ?? NO_SCRIPT_PUBLICATION); if (targetPath) { - return expandSessionPath(targetPath); + return expandSessionPath(targetPath, process.cwd()); } const safeName = safeSessionName(session.name); const timestamp = new Date(session.createdAt).toISOString().replaceAll(/[:.]/g, '-'); diff --git a/packages/daemon/src/execution/__tests__/back-runtime.test.ts b/packages/daemon/src/execution/__tests__/back-runtime.test.ts index 90a1c045f7..3eb255b2e6 100644 --- a/packages/daemon/src/execution/__tests__/back-runtime.test.ts +++ b/packages/daemon/src/execution/__tests__/back-runtime.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; @@ -147,6 +148,7 @@ test('request router joins back admission to execution, recording, and ref inval activateCompleteRefFrame(session); sessionStore.publish(session.name, session); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: '/tmp/daemon.log', token: 't', diff --git a/packages/daemon/src/execution/__tests__/focus-runtime.test.ts b/packages/daemon/src/execution/__tests__/focus-runtime.test.ts index a05f5cc25c..7f8745ccce 100644 --- a/packages/daemon/src/execution/__tests__/focus-runtime.test.ts +++ b/packages/daemon/src/execution/__tests__/focus-runtime.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { focusRuntimeOperationFacts } from '@agent-device/contracts/focus-runtime'; @@ -199,6 +200,7 @@ test('request router joins focus admission to execution, recording, and ref inva activateCompleteRefFrame(session); sessionStore.publish(session.name, session); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: '/tmp/daemon.log', token: 't', diff --git a/packages/daemon/src/execution/__tests__/orientation-runtime.test.ts b/packages/daemon/src/execution/__tests__/orientation-runtime.test.ts index 78d0998079..089c9cd936 100644 --- a/packages/daemon/src/execution/__tests__/orientation-runtime.test.ts +++ b/packages/daemon/src/execution/__tests__/orientation-runtime.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; @@ -205,6 +206,7 @@ test('request router joins orientation admission to execution and ref invalidati activateCompleteRefFrame(session); sessionStore.publish(session.name, session); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: '/tmp/daemon.log', token: 't', diff --git a/packages/daemon/src/execution/__tests__/system-button-runtime.test.ts b/packages/daemon/src/execution/__tests__/system-button-runtime.test.ts index 5d0e6133a7..09aaeb8835 100644 --- a/packages/daemon/src/execution/__tests__/system-button-runtime.test.ts +++ b/packages/daemon/src/execution/__tests__/system-button-runtime.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { systemButtonRuntimeOperationFacts } from '@agent-device/contracts/system-button-runtime'; @@ -178,6 +179,7 @@ test('request router joins home admission to execution, recording, and ref inval activateCompleteRefFrame(session); sessionStore.publish(session.name, session); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: '/tmp/daemon.log', token: 't', diff --git a/packages/daemon/src/execution/__tests__/tv-remote-runtime.test.ts b/packages/daemon/src/execution/__tests__/tv-remote-runtime.test.ts index 63a6956136..d0cb543738 100644 --- a/packages/daemon/src/execution/__tests__/tv-remote-runtime.test.ts +++ b/packages/daemon/src/execution/__tests__/tv-remote-runtime.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { @@ -225,6 +226,7 @@ test('request router joins tv-remote admission to execution and ref invalidation activateCompleteRefFrame(session); sessionStore.publish(session.name, session); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: '/tmp/daemon.log', token: 't', diff --git a/packages/daemon/src/execution/__tests__/viewport-runtime.test.ts b/packages/daemon/src/execution/__tests__/viewport-runtime.test.ts index 18c5610c64..f6d11ede8b 100644 --- a/packages/daemon/src/execution/__tests__/viewport-runtime.test.ts +++ b/packages/daemon/src/execution/__tests__/viewport-runtime.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { @@ -174,6 +175,7 @@ test('request router joins viewport admission to execution, recording, and ref i activateCompleteRefFrame(session); sessionStore.publish(session.name, session); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: '/tmp/daemon.log', token: 't', diff --git a/packages/daemon/src/execution/handlers/__tests__/session-selector-dispatch.test.ts b/packages/daemon/src/execution/handlers/__tests__/session-selector-dispatch.test.ts index 91ad4f7339..38dc57b72c 100644 --- a/packages/daemon/src/execution/handlers/__tests__/session-selector-dispatch.test.ts +++ b/packages/daemon/src/execution/handlers/__tests__/session-selector-dispatch.test.ts @@ -31,7 +31,11 @@ import { import type { SessionState } from '../../../core/session-state.ts'; import { handleSessionCommands } from '../../../__tests__/support/handlers/session-command-harness.ts'; import { refFrameState } from '../../../core/ref-frame.ts'; -import { createUnavailableRuntimeFactsForTest, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + createUnavailableRuntimeFactsForTest, + mkdtempForTestSync, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { makeIosSession } from '../../../__tests__/support/session-factories.ts'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; @@ -85,6 +89,7 @@ test.each(['rebuild', 'retire'] as const)( [Symbol.asyncDispose]: async () => {}, }; const running = handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -187,6 +192,7 @@ test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves // dismiss mutates the device → frame expires. const dismissRef = sessionStore.publish(sessionName, makeSession(sessionName, device)); await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -209,6 +215,7 @@ test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves sessionStore.retire(dismissRef); sessionStore.publish(sessionName, makeSession(sessionName, device)); await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -254,6 +261,7 @@ test('keyboard dismiss expires the frame before the invocation runs, even when i sessionStore.publish(sessionName, makeSession(sessionName, device)); await expect( handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -277,6 +285,7 @@ test('keyboard dismiss expires the frame before the invocation runs, even when i test('keyboard requires an active session or explicit device selector', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -306,6 +315,7 @@ test('keyboard dismiss requires active iOS session for explicit selectors', asyn const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/execution/request-dispatch-disclosure.test.ts b/packages/daemon/src/execution/request-dispatch-disclosure.test.ts index 44eddbe412..a6d8a51184 100644 --- a/packages/daemon/src/execution/request-dispatch-disclosure.test.ts +++ b/packages/daemon/src/execution/request-dispatch-disclosure.test.ts @@ -44,6 +44,7 @@ import { DISPATCH_DISCLOSURE_TABLE_PATH, dispatchDisclosureRowsOwnedBy, mkdtempForTestSync, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { @@ -146,6 +147,7 @@ async function route( const sessionStore = makeSessionStore('agent-device-dispatch-route-'); sessionStore.publish(SESSION, session); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/execution/screenshot-runtime.ts b/packages/daemon/src/execution/screenshot-runtime.ts index e28edb852f..6a11847c28 100644 --- a/packages/daemon/src/execution/screenshot-runtime.ts +++ b/packages/daemon/src/execution/screenshot-runtime.ts @@ -323,7 +323,7 @@ function readScreenshotRequest( const positionals = req.positionals ?? []; const flags = req.flags ?? {}; const expand = (value: string | undefined) => - value === undefined ? undefined : expandSessionPath(value, req.meta?.cwd); + value === undefined ? undefined : expandSessionPath(value, req.meta?.cwd ?? process.cwd()); const positionalPath = expand(positionals[0]); const outFlag = expand(flags.out); return { diff --git a/packages/daemon/src/resources/__tests__/filesystem-boundary-faults.test.ts b/packages/daemon/src/resources/__tests__/filesystem-boundary-faults.test.ts index d6f7d7eecd..30b38ebbbe 100644 --- a/packages/daemon/src/resources/__tests__/filesystem-boundary-faults.test.ts +++ b/packages/daemon/src/resources/__tests__/filesystem-boundary-faults.test.ts @@ -102,6 +102,7 @@ function createDeviceClaimFixture(root: string): FilesystemBoundaryFixture { targetPath: claimPath, run: async () => acquireDeviceClaim({ + env: process.env, device, session: 'filesystem-fault', workspace: process.cwd(), diff --git a/packages/daemon/src/resources/__tests__/macos-app-lease.test.ts b/packages/daemon/src/resources/__tests__/macos-app-lease.test.ts index 714cdcf5b6..6fe57b8724 100644 --- a/packages/daemon/src/resources/__tests__/macos-app-lease.test.ts +++ b/packages/daemon/src/resources/__tests__/macos-app-lease.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import assert from 'node:assert/strict'; import { afterEach, beforeEach, test, vi } from 'vitest'; @@ -12,6 +13,8 @@ import { import { buildOpenResult } from '../../sessions/session-lifecycle/internal/session-open-surface.ts'; import type { DaemonRequest } from '../../core/daemon-request.ts'; +bindTestCommandHost(); + const lease: Pick = { backend: 'macos-app', deviceKey: 'com.example.app@4242', @@ -65,36 +68,52 @@ test('the leased app can be opened, driven, captured and closed', () => { assertMacOsAppLeaseAdmitsRequest( lease, request('open', ['com.example.app'], { platform: 'macos' }), + undefined, + process.env, ); for (const command of ['snapshot', 'click', 'fill', 'type', 'press', 'scroll', 'screenshot']) { - assertMacOsAppLeaseAdmitsRequest(lease, request(command), leasedSession); + assertMacOsAppLeaseAdmitsRequest(lease, request(command), leasedSession, process.env); } - assertMacOsAppLeaseAdmitsRequest(lease, request('close', ['com.example.app']), leasedSession); + assertMacOsAppLeaseAdmitsRequest( + lease, + request('close', ['com.example.app']), + leasedSession, + process.env, + ); }); test('a lease of another backend is not confined', () => { assertMacOsAppLeaseAdmitsRequest( { backend: 'ios-instance', deviceKey: 'ios:mobile:UDID' }, request('install', ['/tmp/App.app']), + undefined, + process.env, ); }); test('commands outside the allow list are refused, including ones added later', () => { for (const command of ['install', 'install_source', 'apps', 'alert', 'clipboard', 'settings']) { assertDenied( - () => assertMacOsAppLeaseAdmitsRequest(lease, request(command), leasedSession), + () => assertMacOsAppLeaseAdmitsRequest(lease, request(command), leasedSession, process.env), 'command', ); } assertDenied( - () => assertMacOsAppLeaseAdmitsRequest(lease, request('not-a-command-yet')), + () => + assertMacOsAppLeaseAdmitsRequest(lease, request('not-a-command-yet'), undefined, process.env), 'command', ); }); test('open accepts only the leased bundle and nothing it could launch beside it', () => { assertDenied( - () => assertMacOsAppLeaseAdmitsRequest(lease, request('open', ['com.apple.finder'])), + () => + assertMacOsAppLeaseAdmitsRequest( + lease, + request('open', ['com.apple.finder']), + undefined, + process.env, + ), 'app', ); assertDenied( @@ -102,6 +121,8 @@ test('open accepts only the leased bundle and nothing it could launch beside it' assertMacOsAppLeaseAdmitsRequest( lease, request('open', ['com.example.app', 'https://example.com']), + undefined, + process.env, ), 'app', ); @@ -110,6 +131,8 @@ test('open accepts only the leased bundle and nothing it could launch beside it' assertMacOsAppLeaseAdmitsRequest( lease, request('open', ['com.example.app'], { platform: 'ios' }), + undefined, + process.env, ), 'app', ); @@ -117,11 +140,27 @@ test('open accepts only the leased bundle and nothing it could launch beside it' test('nothing under the lease names a host path or launches beside the app', () => { const temp = '/tmp/agent-device-screenshot-1791219384278-ab12cd.png'; - assertMacOsAppLeaseAdmitsRequest(lease, request('screenshot', [temp]), leasedSession); - assertMacOsAppLeaseAdmitsRequest(lease, request('screenshot', [], { out: temp }), leasedSession); + assertMacOsAppLeaseAdmitsRequest( + lease, + request('screenshot', [temp]), + leasedSession, + process.env, + ); + assertMacOsAppLeaseAdmitsRequest( + lease, + request('screenshot', [], { out: temp }), + leasedSession, + process.env, + ); for (const target of [['/Users/me/.zshrc'], ['/tmp/agent-device-screenshot-1-a.png/../x']]) { assertDenied( - () => assertMacOsAppLeaseAdmitsRequest(lease, request('screenshot', target), leasedSession), + () => + assertMacOsAppLeaseAdmitsRequest( + lease, + request('screenshot', target), + leasedSession, + process.env, + ), 'host-path', ); } @@ -131,6 +170,7 @@ test('nothing under the lease names a host path or launches beside the app', () lease, request('screenshot', [], { out: '/Users/me/x.png' }), leasedSession, + process.env, ), 'host-path', ); @@ -140,7 +180,13 @@ test('nothing under the lease names a host path or launches beside the app', () { saveScript: '/Users/me/x.ad' }, ]) { assertDenied( - () => assertMacOsAppLeaseAdmitsRequest(lease, request('open', ['com.example.app'], flags)), + () => + assertMacOsAppLeaseAdmitsRequest( + lease, + request('open', ['com.example.app'], flags), + undefined, + process.env, + ), 'host-path', ); } @@ -149,6 +195,8 @@ test('nothing under the lease names a host path or launches beside the app', () assertMacOsAppLeaseAdmitsRequest( lease, request('open', ['com.example.app'], {}, { runtime: { launchUrl: 'other://open' } }), + undefined, + process.env, ), 'host-path', ); @@ -158,6 +206,7 @@ test('nothing under the lease names a host path or launches beside the app', () lease, request('close', [], {}, { input: { saveScript: '/Users/me/x.ad' } }), leasedSession, + process.env, ), 'host-path', ); @@ -169,6 +218,7 @@ test('nothing under the lease names a host path or launches beside the app', () batchSteps: [{ command: 'screenshot', positionals: ['/Users/me/x.png'], input: {} }], }), leasedSession, + process.env, ), 'host-path', ); @@ -181,6 +231,7 @@ test('close cannot name another app', () => { lease, request('close', ['com.apple.finder']), leasedSession, + process.env, ), 'app', ); @@ -189,12 +240,22 @@ test('close cannot name another app', () => { test.each(['desktop', 'frontmost-app', 'menubar'])('the %s surface is refused', (surface) => { assertDenied( () => - assertMacOsAppLeaseAdmitsRequest(lease, request('open', ['com.example.app'], { surface })), + assertMacOsAppLeaseAdmitsRequest( + lease, + request('open', ['com.example.app'], { surface }), + undefined, + process.env, + ), 'surface', ); assertDenied( () => - assertMacOsAppLeaseAdmitsRequest(lease, request('snapshot', [], { surface }), leasedSession), + assertMacOsAppLeaseAdmitsRequest( + lease, + request('snapshot', [], { surface }), + leasedSession, + process.env, + ), 'surface', ); }); @@ -206,6 +267,7 @@ test('screenshots stay on the app window', () => { lease, request('screenshot', [], { screenshotFullscreen: true }), leasedSession, + process.env, ), 'capture', ); @@ -218,7 +280,7 @@ test('a session that is not the leased app is refused', () => { makeIosSession('ios', { appBundleId: 'com.example.app' }), ]) { assertDenied( - () => assertMacOsAppLeaseAdmitsRequest(lease, request('snapshot'), session), + () => assertMacOsAppLeaseAdmitsRequest(lease, request('snapshot'), session, process.env), 'app', ); } @@ -236,6 +298,7 @@ test('a batch is refused whole when any step is not allowed', () => { ], }), leasedSession, + process.env, ), 'app', ); @@ -247,6 +310,7 @@ test('a batch is refused whole when any step is not allowed', () => { batchSteps: [{ command: 'snapshot', input: {}, flags: { surface: 'desktop' } }], }), leasedSession, + process.env, ), 'surface', ); @@ -262,12 +326,23 @@ test('no device selector reaches a host device, and open and batch must name pla { androidDeviceAllowlist: 'emulator-5554' }, ]) { assertDenied( - () => assertMacOsAppLeaseAdmitsRequest(lease, request('open', ['com.example.app'], selector)), + () => + assertMacOsAppLeaseAdmitsRequest( + lease, + request('open', ['com.example.app'], selector), + undefined, + process.env, + ), 'device', ); assertDenied( () => - assertMacOsAppLeaseAdmitsRequest(lease, request('snapshot', [], selector), leasedSession), + assertMacOsAppLeaseAdmitsRequest( + lease, + request('snapshot', [], selector), + leasedSession, + process.env, + ), 'device', ); assertDenied( @@ -278,6 +353,7 @@ test('no device selector reaches a host device, and open and batch must name pla batchSteps: [{ command: 'snapshot', input: {}, flags: selector }], }), leasedSession, + process.env, ), 'device', ); @@ -285,11 +361,16 @@ test('no device selector reaches a host device, and open and batch must name pla for (const command of ['open', 'batch']) { assertDenied( () => - assertMacOsAppLeaseAdmitsRequest(lease, { - command, - positionals: command === 'open' ? ['com.example.app'] : [], - flags: {}, - }), + assertMacOsAppLeaseAdmitsRequest( + lease, + { + command, + positionals: command === 'open' ? ['com.example.app'] : [], + flags: {}, + }, + undefined, + process.env, + ), 'device', ); } @@ -307,6 +388,7 @@ test('a batch is refused whole when a later step carries a launch URL in its run ], }), leasedSession, + process.env, ), 'host-path', ); @@ -315,7 +397,23 @@ test('a batch is refused whole when a later step carries a launch URL in its run test('open needs the native app backend, which resolves every action inside the app', () => { vi.stubEnv('AGENT_DEVICE_MACOS_APP_BACKEND', 'xctest'); assertDenied( - () => assertMacOsAppLeaseAdmitsRequest(lease, request('open', ['com.example.app'])), + () => + assertMacOsAppLeaseAdmitsRequest( + lease, + request('open', ['com.example.app']), + undefined, + process.env, + ), + 'backend', + ); +}); + +test('the backend is read from the request host, not the process', () => { + assertDenied( + () => + assertMacOsAppLeaseAdmitsRequest(lease, request('open', ['com.example.app']), undefined, { + AGENT_DEVICE_MACOS_APP_BACKEND: 'xctest', + }), 'backend', ); }); diff --git a/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts b/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts index 078d435d27..f81b782943 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts @@ -1,4 +1,8 @@ -import { ANDROID_EMULATOR, deviceClaimFilePath } from '@agent-device/test-kit'; +import { + ANDROID_EMULATOR, + deviceClaimFilePath, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { expect, test, vi } from 'vitest'; import { daemonPlatformServicesFixture, @@ -44,6 +48,7 @@ function makeAdmission( callerCwd: string | null = '/worktrees/current', ) { return createDeviceClaimAdmission({ + env: process.env, policy, command, session, @@ -56,7 +61,7 @@ function makeAdmission( } function claimedSessions(): (string | undefined)[] { - return inspectDeviceClaims({}).map((entry) => entry.claim?.session); + return inspectDeviceClaims({}, process.env).map((entry) => entry.claim?.session); } // Enforcement is a pure function of the declared policy, not of a command name: @@ -81,7 +86,7 @@ test.for(POLICY_CLAIMS)( expect(claimedSessions()).toEqual(held); await admission[Symbol.asyncDispose](); - expect(inspectDeviceClaims({})).toEqual([]); + expect(inspectDeviceClaims({}, process.env)).toEqual([]); // A claim-free policy never even creates the store. if (held.length === 0) expect(fs.existsSync(claimsDir)).toBe(false); }, @@ -92,6 +97,7 @@ test('a foreign live claim refuses the command before it can reach device operat const ownerStateDir = path.join(root, 'foreign'); fs.mkdirSync(ownerStateDir, { recursive: true }); await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'owner-session', workspace: '/worktrees/foreign', @@ -123,6 +129,7 @@ test('a foreign live claim refuses the command before it can reach device operat test('a claim this daemon holds for the same session covers the command instead of colliding with it', async () => { const { stateDir } = setup(); await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'open-session', workspace: '/worktrees/current', @@ -141,6 +148,7 @@ test('a claim this daemon holds for the same session covers the command instead test('a claim this daemon holds for another session refuses the command like any live owner', async () => { const { stateDir } = setup(); await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'owning-session', workspace: '/worktrees/current', @@ -191,11 +199,12 @@ test('an in-flight transient claim refuses a request named after its label, whil expect(claimedSessions()).toEqual(['transient:install']); await holder[Symbol.asyncDispose](); - expect(inspectDeviceClaims({})).toEqual([]); + expect(inspectDeviceClaims({}, process.env)).toEqual([]); // A genuinely open session may carry that name; its claim is the session kind and covers // its own workspace's command. await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'transient:install', workspace: '/worktrees/current', @@ -215,6 +224,7 @@ test('an in-flight transient claim refuses a request named after its label, whil test("an open session named after a command's label is not renew-adopted by that command's requests", async () => { const { stateDir } = setup(); const open = await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'transient:install', workspace: '/worktrees/current', @@ -233,9 +243,9 @@ test("an open session named after a command's label is not renew-adopted by that await foreign[Symbol.asyncDispose](); // The open session's claim survived, with its original token: nothing was adopted. - const held = inspectDeviceClaims({})[0]?.claim; + const held = inspectDeviceClaims({}, process.env)[0]?.claim; expect(held?.ownerToken).toBe(open.ownership.ownerToken); - expect(await abandonDeviceClaim(open.ownership)).toBe('abandoned'); + expect(await abandonDeviceClaim(open.ownership, process.env)).toBe('abandoned'); }); // A legacy record states no kind, so coverage cannot treat it as an open session's claim. The @@ -290,7 +300,7 @@ test('a legacy record under this process identity covers nothing, not even under expect(error.code).toBe('DEVICE_IN_USE'); expect(error.details?.reason).toBe('DEVICE_CLAIM_LIVE_OWNER'); await claimOwner[Symbol.asyncDispose](); - expect(inspectDeviceClaims({})[0]?.claim?.ownerToken).toBe('legacy-token'); + expect(inspectDeviceClaims({}, process.env)[0]?.claim?.ownerToken).toBe('legacy-token'); } finally { owner.mockRestore(); } @@ -302,6 +312,7 @@ test('a legacy record under this process identity covers nothing, not even under test('a current-version open-session claim of this workspace still covers', async () => { const { stateDir } = setup(); await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'transient:install', workspace: '/worktrees/current', @@ -329,7 +340,7 @@ test('a transient claim this request took covers re-admitting the same device', // key, while the identity key carries the target and binding treats them apart. await admission.admit({ ...ANDROID_EMULATOR, target: 'tv' }, localAndroid); await admission[Symbol.asyncDispose](); - expect(inspectDeviceClaims({})).toEqual([]); + expect(inspectDeviceClaims({}, process.env)).toEqual([]); }); // Session addresses are printed by `device status` and re-enter through `--session`, so @@ -345,6 +356,7 @@ test("a claim at this workspace's implicit address still covers its own workspac // directories one workspace; without a checkout the two are just two paths. fs.mkdirSync(path.join(workspace, '.git'), { recursive: true }); await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'cwd:aaa111:android', workspace, @@ -372,6 +384,7 @@ test('naming another workspace\u2019s session address does not cover a transient fs.mkdirSync(ownerWorkspace, { recursive: true }); fs.mkdirSync(foreignWorkspace, { recursive: true }); await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'cwd:aaa111:android', workspace: ownerWorkspace, @@ -401,6 +414,7 @@ test('naming another workspace\u2019s session address does not cover a transient test('a request that carries no cwd keeps coverage of its session\u2019s claim', async () => { const { stateDir } = setup(); await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'sdk-session', workspace: '/somewhere/else', @@ -442,7 +456,7 @@ test('a failed release abandons the claim so the next command is not locked out' // The claim survived the failed unlink as abandoned, not live: a live survivor would // refuse this session's next install. - const survivor = inspectDeviceClaims({}); + const survivor = inspectDeviceClaims({}, process.env); expect(survivor.map((entry) => entry.claim?.session)).toEqual(['transient:install']); expect(survivor[0]?.claim?.abandonedAtMs).toBeTypeOf('number'); @@ -450,12 +464,13 @@ test('a failed release abandons the claim so the next command is not locked out' await retry.admit(ANDROID_EMULATOR, localAndroid); expect(claimedSessions()).toEqual(['transient:install']); await retry[Symbol.asyncDispose](); - expect(inspectDeviceClaims({})).toEqual([]); + expect(inspectDeviceClaims({}, process.env)).toEqual([]); }); test("an abandoned claim becomes this command's own transient claim and is released", async () => { const { stateDir } = setup(); const aborted = await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'aborted-open', workspace: '/worktrees/current', @@ -464,7 +479,7 @@ test("an abandoned claim becomes this command's own transient claim and is relea }); expect(aborted.status).toBe('acquired'); if (aborted.status !== 'acquired') return; - expect(await abandonDeviceClaim(aborted.ownership)).toBe('abandoned'); + expect(await abandonDeviceClaim(aborted.ownership, process.env)).toBe('abandoned'); const admission = makeAdmission('transient-exclusive', stateDir, 'install'); await admission.admit(ANDROID_EMULATOR, localAndroid); @@ -472,7 +487,7 @@ test("an abandoned claim becomes this command's own transient claim and is relea // Coverage would have left the abandoned record owning the device with nothing to release it. expect(claimedSessions()).toEqual(['transient:install']); await admission[Symbol.asyncDispose](); - expect(inspectDeviceClaims({})).toEqual([]); + expect(inspectDeviceClaims({}, process.env)).toEqual([]); }); test('a provider-owned device takes no host-local claim', async () => { @@ -541,6 +556,7 @@ test('the none policy still reaches no device state at all', async () => { const ownerStateDir = path.join(root, 'foreign'); fs.mkdirSync(ownerStateDir, { recursive: true }); await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'owner-session', workspace: '/worktrees/foreign', @@ -564,6 +580,7 @@ async function bindInRequestSession(params: { cwd?: string; }): Promise<{ claims: (string | undefined)[]; error?: AppError }> { const scope = await createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: { @@ -606,7 +623,7 @@ test('the request scope claims only for commands whose descriptor declares trans expect( (await bindInRequestSession({ command: 'snapshot', stateDir, session: 'default' })).claims, ).toEqual([]); - expect(inspectDeviceClaims({})).toEqual([]); + expect(inspectDeviceClaims({}, process.env)).toEqual([]); }); // `--session transient:install` is a legal explicit name — the store sanitizes only directory @@ -628,12 +645,13 @@ test('the request scope refuses a session addressed by a transient label while a expect(intruder.claims).toEqual(['transient:install']); await holder[Symbol.asyncDispose](); - expect(inspectDeviceClaims({})).toEqual([]); + expect(inspectDeviceClaims({}, process.env)).toEqual([]); }); test('the request scope admits a transient command only in the session whose claim owns the device', async () => { const { stateDir } = setup(); await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'owning-session', workspace: '/worktrees/current', @@ -686,6 +704,7 @@ test('the request scope covers the implicit workspace session that routed to the // The point of the pin: the owning address is not the name the request carried. expect(routedAddress).not.toBe('default'); await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: routedAddress, workspace, diff --git a/packages/daemon/src/resources/device/__tests__/device-claim-prune.test.ts b/packages/daemon/src/resources/device/__tests__/device-claim-prune.test.ts index 00cc022268..555322f958 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claim-prune.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claim-prune.test.ts @@ -67,6 +67,7 @@ test('reconciles claims whose owner is gone and keeps every other claim', async const summary = await reconcileOrphanedDeviceClaims( async () => ({ status: 'reconciled' }), process.cwd(), + process.env, ); const claimPathFor = (name: string) => deviceClaimFilePath(`local:android:none:${name}`); @@ -86,7 +87,11 @@ test('reconciles nothing when the claim store does not exist', async () => { 'agent-device-reconciliation-absent-store', ); assert.deepEqual( - await reconcileOrphanedDeviceClaims(async () => ({ status: 'reconciled' }), process.cwd()), + await reconcileOrphanedDeviceClaims( + async () => ({ status: 'reconciled' }), + process.cwd(), + process.env, + ), { examined: 0, reconciled: 0, @@ -138,6 +143,7 @@ test('leaves a live successor written while the reconciliation waited for the cl const reconciliation = reconcileOrphanedDeviceClaims( async () => ({ status: 'reconciled' }), process.cwd(), + process.env, ); // The reconciliation is now blocked on the lock; stand in the live successor. writeContested(owner.pid, owner.startTime, 'live-token'); @@ -174,6 +180,7 @@ test('reconciles a claim whose owning daemon was replaced while still running', const summary = await reconcileOrphanedDeviceClaims( async () => ({ status: 'reconciled' }), stateDir, + process.env, ); assert.deepEqual(summary, { examined: 1, reconciled: 1, retained: 0, changed: 0 }); @@ -201,6 +208,7 @@ test('leaves a superseded owner in another state dir to the device that asks for const summary = await reconcileOrphanedDeviceClaims( reconcile, path.join(claimsDir, 'our-state'), + process.env, ); assert.deepEqual(summary, { examined: 0, reconciled: 0, retained: 0, changed: 0 }); diff --git a/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts b/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts index 35fe383fb7..70d9851c34 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claim-settlement.test.ts @@ -70,6 +70,7 @@ function claimPath(root: string): string { test('preserves and reports a live foreign claim without overwriting it', async () => { const root = useClaimsRoot(); const first = await acquireDeviceClaim({ + env: process.env, device, session: 'first', workspace: '/worktrees/first', @@ -85,6 +86,7 @@ test('preserves and reports a live foreign claim without overwriting it', async name: device.name, }); const second = await acquireDeviceClaim({ + env: process.env, device, session: 'second', workspace: '/worktrees/second', @@ -93,7 +95,7 @@ test('preserves and reports a live foreign claim without overwriting it', async assert.equal(second.status, 'conflict'); if (second.status !== 'conflict') return; assert.equal(second.conflict.classification, 'live'); - assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.session, 'first'); + assert.equal(inspectDeviceClaims({ serial: device.id }, process.env)[0]?.claim?.session, 'first'); }); // The maintainer's trace on #3714: `install` holds a transient claim under the label @@ -104,6 +106,7 @@ test('preserves and reports a live foreign claim without overwriting it', async test('an open session addressed at a command label never adopts the in-flight command claim', async () => { const root = useClaimsRoot(); const holder = await acquireTransientDeviceClaim({ + env: process.env, device, command: 'install', coveredBySession: 'open-a', @@ -120,6 +123,7 @@ test('an open session addressed at a command label never adopts the in-flight co session: 'transient:install', workspace: '/worktrees/first', stateDir: root, + env: process.env, }); assert.equal(open.status, 'conflict'); @@ -136,6 +140,7 @@ test('an open session addressed at a command label never adopts the in-flight co test('a second request never holds an in-flight command claim, under its own label or another’s', async () => { const root = useClaimsRoot(); const holder = await acquireTransientDeviceClaim({ + env: process.env, device, command: 'install', coveredBySession: 'open-a', @@ -148,6 +153,7 @@ test('a second request never holds an in-flight command claim, under its own lab if (holder.status !== 'acquired') return; const intruder = await acquireTransientDeviceClaim({ + env: process.env, device, command: 'reinstall', coveredBySession: 'transient:install', @@ -160,6 +166,7 @@ test('a second request never holds an in-flight command claim, under its own lab assert.equal(storedClaim().ownerToken, holder.ownership.ownerToken); const sameCommand = await acquireTransientDeviceClaim({ + env: process.env, device, command: 'install', coveredBySession: 'some-other-session', @@ -209,6 +216,7 @@ test('a migrated record states no kind, so acquisition never holds it', async () session: 'legacy-owner', workspace: '/worktrees/legacy', stateDir: root, + env: process.env, }); assert.equal(held.status, 'conflict'); @@ -242,17 +250,20 @@ test('a renewal through a migrated record’s own token writes back the shape th }), ); - const renewal = await renewDeviceClaim({ - deviceKey: canonicalLocalDeviceKey(device), - ownerToken: 'legacy-token', - ownerPid: process.pid, - ownerStartTime: 'test-process-start-time', - }); + const renewal = await renewDeviceClaim( + { + deviceKey: canonicalLocalDeviceKey(device), + ownerToken: 'legacy-token', + ownerPid: process.pid, + ownerStartTime: 'test-process-start-time', + }, + process.env, + ); assert.equal(renewal.status, 'renewed'); const persisted = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record; assert.equal(persisted.schemaVersion, 3); - const reread = inspectDeviceClaims({ serial: device.id })[0]; + const reread = inspectDeviceClaims({ serial: device.id }, process.env)[0]; assert.equal(reread?.classification, 'live'); assert.equal(reread?.claim?.session, 'legacy-owner'); assert.equal(reread?.claim?.claimOwnerKind, 'unstated'); @@ -261,6 +272,7 @@ test('a renewal through a migrated record’s own token writes back the shape th test('does not treat a same-named session in another worktree as its claim owner', async () => { const root = useClaimsRoot(); const first = await acquireDeviceClaim({ + env: process.env, device, session: 'default', workspace: '/worktrees/first', @@ -268,18 +280,23 @@ test('does not treat a same-named session in another worktree as its claim owner }); assert.equal(first.status, 'acquired'); const second = await acquireDeviceClaim({ + env: process.env, device, session: 'default', workspace: '/worktrees/second', stateDir: path.join(root, 'second-state'), }); assert.equal(second.status, 'conflict'); - assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.workspace, '/worktrees/first'); + assert.equal( + inspectDeviceClaims({ serial: device.id }, process.env)[0]?.claim?.workspace, + '/worktrees/first', + ); }); test('reconciles a proven-dead owner and replaces it while acquiring the same claim', async () => { const root = useClaimsRoot(); const first = await acquireDeviceClaim({ + env: process.env, device, session: 'dead-owner', workspace: '/worktrees/dead', @@ -298,6 +315,7 @@ test('reconciles a proven-dead owner and replaces it while acquiring the same cl }); const second = await acquireDeviceClaim({ + env: process.env, device, session: 'replacement', workspace: '/worktrees/replacement', @@ -307,12 +325,16 @@ test('reconciles a proven-dead owner and replaces it while acquiring the same cl assert.equal(second.status, 'acquired'); assert.equal(reconciledSession, 'dead-owner'); - assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.session, 'replacement'); + assert.equal( + inspectDeviceClaims({ serial: device.id }, process.env)[0]?.claim?.session, + 'replacement', + ); }); test('retains a proven-dead claim when exact-owner cleanup remains pending', async () => { const root = useClaimsRoot(); await acquireDeviceClaim({ + env: process.env, device, session: 'cleanup-pending', workspace: '/worktrees/dead', @@ -325,6 +347,7 @@ test('retains a proven-dead claim when exact-owner cleanup remains pending', asy ); const second = await acquireDeviceClaim({ + env: process.env, device, session: 'blocked', workspace: '/worktrees/blocked', @@ -338,12 +361,16 @@ test('retains a proven-dead claim when exact-owner cleanup remains pending', asy assert.equal(second.status, 'conflict'); if (second.status !== 'conflict') return; assert.equal(second.conflict.classification, 'owner-process-dead'); - assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.session, 'cleanup-pending'); + assert.equal( + inspectDeviceClaims({ serial: device.id }, process.env)[0]?.claim?.session, + 'cleanup-pending', + ); }); test('PID reuse is uncertain ownership and never authorizes reconciliation', async () => { const root = useClaimsRoot(); await acquireDeviceClaim({ + env: process.env, device, session: 'reused-pid-owner', workspace: '/worktrees/old', @@ -354,6 +381,7 @@ test('PID reuse is uncertain ownership and never authorizes reconciliation', asy const reconcile = vi.fn(async () => ({ status: 'reconciled' as const })); const result = await acquireDeviceClaim({ + env: process.env, device, session: 'blocked', workspace: '/worktrees/new', @@ -365,12 +393,16 @@ test('PID reuse is uncertain ownership and never authorizes reconciliation', asy if (result.status !== 'conflict') return; assert.equal(result.conflict.classification, 'owner-process-reused'); assert.equal(reconcile.mock.calls.length, 0); - assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.session, 'reused-pid-owner'); + assert.equal( + inspectDeviceClaims({ serial: device.id }, process.env)[0]?.claim?.session, + 'reused-pid-owner', + ); }); test('an internally inconsistent dead claim never authorizes reconciliation', async () => { const root = useClaimsRoot(); await acquireDeviceClaim({ + env: process.env, device, session: 'inconsistent-owner', workspace: '/worktrees/old', @@ -389,6 +421,7 @@ test('an internally inconsistent dead claim never authorizes reconciliation', as const reconcile = vi.fn(async () => ({ status: 'reconciled' as const })); const result = await acquireDeviceClaim({ + env: process.env, device, session: 'blocked', workspace: '/worktrees/new', @@ -406,7 +439,7 @@ test('an internally inconsistent dead claim never authorizes reconciliation', as const reconciled = async () => ({ status: 'reconciled' as const }); function storedClaim() { - const claim = inspectDeviceClaims({ serial: device.id })[0]?.claim; + const claim = inspectDeviceClaims({ serial: device.id }, process.env)[0]?.claim; assert.ok(claim); return claim; } @@ -420,6 +453,7 @@ function rewriteClaimOwner(root: string, ownerPid: number): void { async function seedForeignLiveClaim(root: string, stateDir: string): Promise { fs.mkdirSync(stateDir, { recursive: true }); const seeded = await acquireDeviceClaim({ + env: process.env, device, session: 'cwd:/w:default', workspace: '/w', @@ -449,6 +483,7 @@ test('takes a live foreign claim whose device rebooted after the claim was taken let reconciledSession: string | undefined; const second = await acquireDeviceClaim({ + env: process.env, device, session: 'other', workspace: '/w', @@ -480,6 +515,7 @@ test('keeps a live foreign claim blocking until the device boot is answered', as publishDaemonRegistration(stateDir, { pid: process.ppid, startTime: null }); const second = await acquireDeviceClaim({ + env: process.env, device, session: 'other', workspace: '/w', @@ -502,6 +538,7 @@ test('a boot that predates the claim proves nothing about it and keeps the confl publishDaemonRegistration(stateDir, { pid: process.ppid, startTime: null }); const second = await acquireDeviceClaim({ + env: process.env, device, session: 'other', workspace: '/w', @@ -522,6 +559,7 @@ test('a rebooted device stays claimed while its owner has resources left to sett publishDaemonRegistration(stateDir, { pid: process.ppid, startTime: null }); const second = await acquireDeviceClaim({ + env: process.env, device, session: 'other', workspace: '/w', @@ -541,6 +579,7 @@ test('an owner that reopened its app after the reboot keeps the device', async ( const root = useClaimsRoot(); const stateDir = path.join(root, 'renewed-state'); const first = await acquireDeviceClaim({ + env: process.env, device, session: 'cwd:/w:default', workspace: '/w', @@ -551,6 +590,7 @@ test('an owner that reopened its app after the reboot keeps the device', async ( await new Promise((resolve) => setTimeout(resolve, 2)); const reopened = await acquireDeviceClaim({ + env: process.env, device, session: 'cwd:/w:default', workspace: '/w', @@ -565,6 +605,7 @@ test('an owner that reopened its app after the reboot keeps the device', async ( rewriteClaimOwner(root, process.ppid); publishDaemonRegistration(stateDir, { pid: process.ppid, startTime: null }); const foreign = await acquireDeviceClaim({ + env: process.env, device, session: 'other', workspace: '/w', diff --git a/packages/daemon/src/resources/device/__tests__/device-claims.test.ts b/packages/daemon/src/resources/device/__tests__/device-claims.test.ts index cce909c75c..a3e5451874 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claims.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claims.test.ts @@ -74,6 +74,7 @@ function claimPath(root: string): string { test('clears only the exact owner token and identity, never a successor claim', async () => { const root = useClaimsRoot(); const acquired = await acquireDeviceClaim({ + env: process.env, device, session: 'first', workspace: '/worktrees/first', @@ -88,13 +89,17 @@ test('clears only the exact owner token and identity, never a successor claim', ); // Resolving is not releasing: the outcome is what a caller reporting // ownership must read, since the successor's claim is deliberately kept. - assert.equal(await clearDeviceClaim(acquired.ownership), 'ownership-changed'); - assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.session, 'second'); + assert.equal(await clearDeviceClaim(acquired.ownership, process.env), 'ownership-changed'); + assert.equal( + inspectDeviceClaims({ serial: device.id }, process.env)[0]?.claim?.session, + 'second', + ); }); test('reports the exact outcome of clearing an owned, missing, and unowned claim', async () => { const root = useClaimsRoot(); const acquired = await acquireDeviceClaim({ + env: process.env, device, session: 'owner', workspace: '/worktrees/owner', @@ -103,9 +108,9 @@ test('reports the exact outcome of clearing an owned, missing, and unowned claim assert.equal(acquired.status, 'acquired'); if (acquired.status !== 'acquired') return; - assert.equal(await clearDeviceClaim(acquired.ownership), 'deleted'); - assert.equal(await clearDeviceClaim(acquired.ownership), 'absent'); - assert.equal(await clearDeviceClaim(undefined), 'absent'); + assert.equal(await clearDeviceClaim(acquired.ownership, process.env), 'deleted'); + assert.equal(await clearDeviceClaim(acquired.ownership, process.env), 'absent'); + assert.equal(await clearDeviceClaim(undefined, process.env), 'absent'); }); test('reports an undecodable claim at its own device key as unattributable, not released', async () => { @@ -122,7 +127,7 @@ test('reports an undecodable claim at its own device key as unattributable, not // process that no longer knows it holds the device. fs.writeFileSync(claimPath(root), '{bad json'); - assert.equal(await clearDeviceClaim(ownership), 'unattributable'); + assert.equal(await clearDeviceClaim(ownership, process.env), 'unattributable'); assert.equal(fs.readFileSync(claimPath(root), 'utf8'), '{bad json'); }); @@ -145,7 +150,7 @@ test('keeps corrupt records visible and classifies dead owners without reclaimin updatedAtMs: 1, }), ); - const claims = inspectDeviceClaims({}); + const claims = inspectDeviceClaims({}, process.env); assert.equal( claims.find((claim) => claim.fileName === 'corrupt.json')?.classification, 'inconsistent', @@ -165,7 +170,7 @@ test('fails closed when claim inspection encounters a permission or transient I/ error.code = 'EACCES'; throw error; }); - const claims = inspectDeviceClaims({}); + const claims = inspectDeviceClaims({}, process.env); assert.equal(claims[0]?.classification, 'unknown'); }); @@ -177,13 +182,14 @@ test('classifies transient claim-file read errors as unknown, not inconsistent', error.code = 'EIO'; throw error; }); - const claims = inspectDeviceClaims({}); + const claims = inspectDeviceClaims({}, process.env); assert.equal(claims[0]?.classification, 'unknown'); }); test('matches public Apple claim records through the shared platform selector semantics', async () => { const root = useClaimsRoot(); await acquireDeviceClaim({ + env: process.env, device: { platform: 'apple', appleOs: 'ios', @@ -196,9 +202,9 @@ test('matches public Apple claim records through the shared platform selector se workspace: process.cwd(), stateDir: root, }); - assert.equal(inspectDeviceClaims({ platform: 'apple' }).length, 1); - assert.equal(inspectDeviceClaims({ platform: 'ios' }).length, 1); - assert.equal(inspectDeviceClaims({ platform: 'macos' }).length, 0); + assert.equal(inspectDeviceClaims({ platform: 'apple' }, process.env).length, 1); + assert.equal(inspectDeviceClaims({ platform: 'ios' }, process.env).length, 1); + assert.equal(inspectDeviceClaims({ platform: 'macos' }, process.env).length, 0); }); test('canonicalizes legacy Apple devices without an explicit OS before persisting', async () => { @@ -211,13 +217,14 @@ test('canonicalizes legacy Apple devices without an explicit OS before persistin booted: true, }; const acquired = await acquireDeviceClaim({ + env: process.env, device: legacyAppleDevice, session: 'legacy-ios', workspace: process.cwd(), stateDir: root, }); assert.equal(acquired.status, 'acquired'); - const inspected = inspectDeviceClaims({ udid: legacyAppleDevice.id })[0]; + const inspected = inspectDeviceClaims({ udid: legacyAppleDevice.id }, process.env)[0]; assert.equal(inspected?.classification, 'live'); assert.equal(inspected?.claim?.device.appleOs, 'ios'); assert.equal(inspected?.deviceKey, 'local:apple:ios:legacy-ios-claim'); @@ -238,6 +245,7 @@ test.each([ async (target) => { const root = useClaimsRoot(); await acquireDeviceClaim({ + env: process.env, device: target, session: 'first', workspace: '/worktrees/first', @@ -245,6 +253,7 @@ test.each([ }); const second = await acquireDeviceClaim({ + env: process.env, device: target, session: 'second', workspace: '/worktrees/second', @@ -270,6 +279,7 @@ function rewriteClaimOwner(root: string, ownerPid: number): void { async function seedForeignLiveClaim(root: string, stateDir: string): Promise { fs.mkdirSync(stateDir, { recursive: true }); const seeded = await acquireDeviceClaim({ + env: process.env, device, session: 'cwd:/w:default', workspace: '/w', @@ -294,6 +304,7 @@ test('reconciles a live claim whose owner is no longer the daemon published for }); const second = await acquireDeviceClaim({ + env: process.env, device, session: 'cwd:/w:default', workspace: '/w', @@ -303,7 +314,10 @@ test('reconciles a live claim whose owner is no longer the daemon published for assert.equal(second.status, 'acquired'); assert.equal(reconciledSession, 'cwd:/w:default'); - assert.equal(inspectDeviceClaims({ serial: device.id })[0]?.claim?.ownerPid, process.pid); + assert.equal( + inspectDeviceClaims({ serial: device.id }, process.env)[0]?.claim?.ownerPid, + process.pid, + ); }); test('keeps a live claim blocking while its owner is still the daemon published for its state dir', async () => { @@ -314,6 +328,7 @@ test('keeps a live claim blocking while its owner is still the daemon published const reconcile = vi.fn(async () => ({ status: 'reconciled' as const })); const second = await acquireDeviceClaim({ + env: process.env, device, session: 'other', workspace: '/w', @@ -334,6 +349,7 @@ test('never supersedes a live claim owner on a state dir with no published daemo const reconcile = vi.fn(async () => ({ status: 'reconciled' as const })); const second = await acquireDeviceClaim({ + env: process.env, device, session: 'other', workspace: '/w', @@ -352,6 +368,7 @@ test('never treats a claim owned by the inspecting process as superseded', async const stateDir = path.join(root, 'self-state'); fs.mkdirSync(stateDir, { recursive: true }); const first = await acquireDeviceClaim({ + env: process.env, device, session: 'ours', workspace: '/w', @@ -364,6 +381,7 @@ test('never treats a claim owned by the inspecting process as superseded', async const reconcile = vi.fn(async () => ({ status: 'reconciled' as const })); const second = await acquireDeviceClaim({ + env: process.env, device, session: 'another', workspace: '/w', @@ -380,6 +398,7 @@ test('never treats a claim owned by the inspecting process as superseded', async test('an abandoned claim yields to the next acquire of the daemon that abandoned it', async () => { const root = useClaimsRoot(); const aborted = await acquireDeviceClaim({ + env: process.env, device, session: 'attempt-1', workspace: '/worktrees/suite', @@ -387,14 +406,15 @@ test('an abandoned claim yields to the next acquire of the daemon that abandoned }); assert.equal(aborted.status, 'acquired'); if (aborted.status !== 'acquired') return; - assert.equal(await abandonDeviceClaim(aborted.ownership), 'abandoned'); + assert.equal(await abandonDeviceClaim(aborted.ownership, process.env), 'abandoned'); assert.equal( - typeof inspectDeviceClaims({ serial: device.id })[0]?.claim?.abandonedAtMs, + typeof inspectDeviceClaims({ serial: device.id }, process.env)[0]?.claim?.abandonedAtMs, 'number', ); const reconcile = vi.fn(async () => ({ status: 'reconciled' as const })); const retry = await acquireDeviceClaim({ + env: process.env, device, session: 'attempt-2', workspace: '/worktrees/suite', @@ -403,13 +423,13 @@ test('an abandoned claim yields to the next acquire of the daemon that abandoned }); assert.equal(retry.status, 'acquired'); - const claim = inspectDeviceClaims({ serial: device.id })[0]?.claim; + const claim = inspectDeviceClaims({ serial: device.id }, process.env)[0]?.claim; assert.equal(claim?.session, 'attempt-2'); assert.equal(claim?.abandonedAtMs, undefined); // Abandonment is an owner's own record, not a proof about a dead owner: it never // routes through orphan reconciliation. assert.equal(reconcile.mock.calls.length, 0); - assert.equal(await clearDeviceClaim(aborted.ownership), 'ownership-changed'); + assert.equal(await clearDeviceClaim(aborted.ownership, process.env), 'ownership-changed'); }); test('an abandoned claim keeps fencing a daemon that does not own it', async () => { @@ -421,6 +441,7 @@ test('an abandoned claim keeps fencing a daemon that does not own it', async () const reconcile = vi.fn(async () => ({ status: 'reconciled' as const })); const second = await acquireDeviceClaim({ + env: process.env, device, session: 'other', workspace: '/w', @@ -438,6 +459,7 @@ test('an abandoned claim keeps fencing a daemon that does not own it', async () test("an abandoned claim keeps fencing this process on another daemon's state dir", async () => { const root = useClaimsRoot(); const acquired = await acquireDeviceClaim({ + env: process.env, device, session: 'attempt-1', workspace: '/worktrees/suite', @@ -445,9 +467,10 @@ test("an abandoned claim keeps fencing this process on another daemon's state di }); assert.equal(acquired.status, 'acquired'); if (acquired.status !== 'acquired') return; - assert.equal(await abandonDeviceClaim(acquired.ownership), 'abandoned'); + assert.equal(await abandonDeviceClaim(acquired.ownership, process.env), 'abandoned'); const second = await acquireDeviceClaim({ + env: process.env, device, session: 'attempt-2', workspace: '/worktrees/suite', @@ -462,6 +485,7 @@ test("an abandoned claim keeps fencing this process on another daemon's state di test('reports the exact outcome of abandoning an owned, missing, and unowned claim', async () => { const root = useClaimsRoot(); const acquired = await acquireDeviceClaim({ + env: process.env, device, session: 'owner', workspace: '/worktrees/owner', @@ -470,23 +494,24 @@ test('reports the exact outcome of abandoning an owned, missing, and unowned cla assert.equal(acquired.status, 'acquired'); if (acquired.status !== 'acquired') return; - assert.equal(await abandonDeviceClaim(acquired.ownership), 'abandoned'); + assert.equal(await abandonDeviceClaim(acquired.ownership, process.env), 'abandoned'); const stored = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record; fs.writeFileSync( claimPath(root), JSON.stringify({ ...stored, ownerToken: 'successor-token', session: 'successor' }), ); - assert.equal(await abandonDeviceClaim(acquired.ownership), 'ownership-changed'); + assert.equal(await abandonDeviceClaim(acquired.ownership, process.env), 'ownership-changed'); fs.rmSync(claimPath(root)); - assert.equal(await abandonDeviceClaim(acquired.ownership), 'absent'); - assert.equal(await abandonDeviceClaim(undefined), 'absent'); + assert.equal(await abandonDeviceClaim(acquired.ownership, process.env), 'absent'); + assert.equal(await abandonDeviceClaim(undefined, process.env), 'absent'); }); test('answers the runner authority probe only for a claim this process actively holds', async () => { const root = useClaimsRoot(); - assert.equal(processOwnsActiveDeviceClaim(device), false); + assert.equal(processOwnsActiveDeviceClaim(device, process.env), false); const acquired = await acquireDeviceClaim({ + env: process.env, device, session: 'probe-owner', workspace: '/worktrees/probe', @@ -494,11 +519,14 @@ test('answers the runner authority probe only for a claim this process actively }); assert.equal(acquired.status, 'acquired'); if (acquired.status !== 'acquired') return; - assert.equal(processOwnsActiveDeviceClaim(device), true); - assert.equal(processOwnsActiveDeviceClaim({ ...device, id: 'some-other-device' }), false); + assert.equal(processOwnsActiveDeviceClaim(device, process.env), true); + assert.equal( + processOwnsActiveDeviceClaim({ ...device, id: 'some-other-device' }, process.env), + false, + ); - assert.equal(await abandonDeviceClaim(acquired.ownership), 'abandoned'); - assert.equal(processOwnsActiveDeviceClaim(device), false); + assert.equal(await abandonDeviceClaim(acquired.ownership, process.env), 'abandoned'); + assert.equal(processOwnsActiveDeviceClaim(device, process.env), false); }); test('a same-id claim from another platform family grants no runner authority', async () => { @@ -507,6 +535,7 @@ test('a same-id claim from another platform family grants no runner authority', // must not authorize destructive takeover of that runner: authority matches // the canonical family/OS/id key, never the bare id. const acquired = await acquireDeviceClaim({ + env: process.env, device, session: 'android-owner', workspace: '/worktrees/android', @@ -521,22 +550,24 @@ test('a same-id claim from another platform family grants no runner authority', kind: 'simulator', booted: true, }; - assert.equal(processOwnsActiveDeviceClaim(sameIdAppleSimulator), false); - assert.equal(processOwnsActiveDeviceClaim(device), true); + assert.equal(processOwnsActiveDeviceClaim(sameIdAppleSimulator, process.env), false); + assert.equal(processOwnsActiveDeviceClaim(device, process.env), true); const appleAcquired = await acquireDeviceClaim({ + env: process.env, device: sameIdAppleSimulator, session: 'apple-owner', workspace: '/worktrees/apple', stateDir: root, }); assert.equal(appleAcquired.status, 'acquired'); - assert.equal(processOwnsActiveDeviceClaim(sameIdAppleSimulator), true); + assert.equal(processOwnsActiveDeviceClaim(sameIdAppleSimulator, process.env), true); }); test('denies the runner authority probe for a claim held by another process', async () => { const root = useClaimsRoot(); const acquired = await acquireDeviceClaim({ + env: process.env, device, session: 'foreign-owner', workspace: '/worktrees/foreign', @@ -548,12 +579,13 @@ test('denies the runner authority probe for a claim held by another process', as claimPath(root), JSON.stringify({ ...stored, ownerPid: 999_999, ownerStartTime: 'other-start' }), ); - assert.equal(processOwnsActiveDeviceClaim(device), false); + assert.equal(processOwnsActiveDeviceClaim(device, process.env), false); }); test('releases a provably dead owner through reconciliation and refuses everything else', async () => { const root = useClaimsRoot(); const dead = await acquireDeviceClaim({ + env: process.env, device, session: 'dead-owner', workspace: '/worktrees/dead', @@ -568,6 +600,7 @@ test('releases a provably dead owner through reconciliation and refuses everythi const reconciled: string[] = []; const outcomes = await releaseProvenStaleDeviceClaims({ + env: process.env, selectors: {}, reconcile: async (claim) => { reconciled.push(claim.session); @@ -584,6 +617,7 @@ test('releases a provably dead owner through reconciliation and refuses everythi test('release retains the claim when reconciliation reports unsettled resources', async () => { const root = useClaimsRoot(); const dead = await acquireDeviceClaim({ + env: process.env, device, session: 'retained-owner', workspace: '/worktrees/retained', @@ -597,6 +631,7 @@ test('release retains the claim when reconciliation reports unsettled resources' ); const outcomes = await releaseProvenStaleDeviceClaims({ + env: process.env, selectors: {}, reconcile: async () => ({ status: 'retained', reason: 'screen-recording-cleanup-pending' }), }); @@ -608,6 +643,7 @@ test('release retains the claim when reconciliation reports unsettled resources' test('release refuses live owners and corrupt claims without touching them', async () => { const root = useClaimsRoot(); const live = await acquireDeviceClaim({ + env: process.env, device, session: 'live-owner', workspace: '/worktrees/live', @@ -618,6 +654,7 @@ test('release refuses live owners and corrupt claims without touching them', asy const reconciled: string[] = []; const outcomes = await releaseProvenStaleDeviceClaims({ + env: process.env, selectors: {}, reconcile: async (claim) => { reconciled.push(claim.session); @@ -636,6 +673,7 @@ test('release refuses live owners and corrupt claims without touching them', asy test('release names the exact refusal for uncertain owners and misnamed claim files', async () => { const root = useClaimsRoot(); const acquired = await acquireDeviceClaim({ + env: process.env, device, session: 'reused-owner', workspace: '/worktrees/reused', @@ -660,6 +698,7 @@ test('release names the exact refusal for uncertain owners and misnamed claim fi ); const outcomes = await releaseProvenStaleDeviceClaims({ + env: process.env, selectors: {}, reconcile: async () => ({ status: 'reconciled' }), }); @@ -673,6 +712,7 @@ test('release names the exact refusal for uncertain owners and misnamed claim fi test('release refuses a live process whose state dir is gone', async () => { const root = useClaimsRoot(); const acquired = await acquireDeviceClaim({ + env: process.env, device, session: 'dir-gone-owner', workspace: '/worktrees/dir-gone', @@ -681,6 +721,7 @@ test('release refuses a live process whose state dir is gone', async () => { assert.equal(acquired.status, 'acquired'); const outcomes = await releaseProvenStaleDeviceClaims({ + env: process.env, selectors: {}, reconcile: async () => ({ status: 'reconciled' }), }); @@ -692,6 +733,7 @@ test('release refuses a live process whose state dir is gone', async () => { test('release reports changed when the claim is replaced between scan and lock acquisition', async () => { const root = useClaimsRoot(); const acquired = await acquireDeviceClaim({ + env: process.env, device, session: 'raced-owner', workspace: '/worktrees/raced', @@ -716,7 +758,11 @@ test('release reports changed when the claim is replaced between scan and lock a }); let outcomes: Awaited> = []; let releaseSettled = false; - const release = releaseProvenStaleDeviceClaims({ selectors: {}, reconcile }).then((result) => { + const release = releaseProvenStaleDeviceClaims({ + env: process.env, + selectors: {}, + reconcile, + }).then((result) => { outcomes = result; releaseSettled = true; }); @@ -762,8 +808,9 @@ test('an unknown-schema record is never migrated into a claim, reconciled, or re fs.writeFileSync(claimPath(root), record); const reconcile = vi.fn(async () => ({ status: 'reconciled' as const })); - assert.equal(inspectDeviceClaims({})[0]?.classification, 'inconsistent'); + assert.equal(inspectDeviceClaims({}, process.env)[0]?.classification, 'inconsistent'); const acquired = await acquireDeviceClaim({ + env: process.env, device, session: 'ordinary', workspace: '/worktrees/ordinary', @@ -775,11 +822,15 @@ test('an unknown-schema record is never migrated into a claim, reconciled, or re assert.equal(acquired.conflict.classification, 'inconsistent'); assert.equal(acquired.conflict.claim, undefined); - const outcomes = await releaseProvenStaleDeviceClaims({ selectors: {}, reconcile }); + const outcomes = await releaseProvenStaleDeviceClaims({ + env: process.env, + selectors: {}, + reconcile, + }); assert.equal(outcomes.length, 1); assert.equal(outcomes[0]?.status, 'refused'); assert.equal(outcomes[0]?.reason, 'claim-record-inconsistent'); assert.equal(reconcile.mock.calls.length, 0); - assert.equal(processOwnsActiveDeviceClaim(device), false); + assert.equal(processOwnsActiveDeviceClaim(device, process.env), false); assert.equal(fs.readFileSync(claimPath(root), 'utf8'), record); }); diff --git a/packages/daemon/src/resources/device/device-claim-admission.ts b/packages/daemon/src/resources/device/device-claim-admission.ts index 27c1b39406..5e5c080284 100644 --- a/packages/daemon/src/resources/device/device-claim-admission.ts +++ b/packages/daemon/src/resources/device/device-claim-admission.ts @@ -1,4 +1,5 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { RuntimeOwnerRef } from '@agent-device/contracts/platform-runtime'; import type { DeviceClaimPolicy } from '@agent-device/command-registry/types'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; @@ -61,6 +62,7 @@ export function createDeviceClaimAdmission(params: { */ resolveScopeRoot: (workspace: string | undefined) => string | undefined; stateDir: string; + env: EnvMap; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; }): DeviceClaimAdmission { // The caller admits once per device binding, so this only has to remember what @@ -83,6 +85,7 @@ export function createDeviceClaimAdmission(params: { !requestScopeRoot || params.resolveScopeRoot(claim.workspace) === requestScopeRoot, workspace: params.workspace, stateDir: params.stateDir, + env: params.env, reconcileOrphanedDeviceClaim: params.reconcileOrphanedDeviceClaim, }); if (result.status === 'acquired') { @@ -110,7 +113,7 @@ export function createDeviceClaimAdmission(params: { [Symbol.asyncDispose]: async () => { for (const ownership of acquired.splice(0)) { try { - await clearDeviceClaim(ownership); + await clearDeviceClaim(ownership, params.env); } catch (error) { emitDiagnostic({ level: 'error', @@ -125,7 +128,7 @@ export function createDeviceClaimAdmission(params: { // command, so it must not stay live: abandoned means "holds it for nobody", // which the next acquire supersedes. try { - await abandonDeviceClaim(ownership); + await abandonDeviceClaim(ownership, params.env); } catch (abandonError) { emitDiagnostic({ level: 'error', diff --git a/packages/daemon/src/resources/macos-app-lease.ts b/packages/daemon/src/resources/macos-app-lease.ts index e9f92ab44e..eed27fb66b 100644 --- a/packages/daemon/src/resources/macos-app-lease.ts +++ b/packages/daemon/src/resources/macos-app-lease.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonResponse, DaemonResponseData } from '@agent-device/kernel/contracts'; import os from 'node:os'; import { normalizeBatchCommandName } from '@agent-device/command-registry/batch-policy'; @@ -5,7 +6,7 @@ import { isRemoteTempArtifactPath } from '@agent-device/contracts/daemon-http'; import type { DeviceLease } from '@agent-device/contracts/device'; import { readMacOsAppBackend } from '@agent-device/contracts/session'; import { runCmd } from '@agent-device/host-kit/command'; -import { isProcessAlive, readHostEnvironmentVariable } from '@agent-device/host-kit/process'; +import { isProcessAlive } from '@agent-device/host-kit/process'; import { isMacOs } from '@agent-device/kernel/device'; import { AppError, type DaemonError } from '@agent-device/kernel/errors'; import { isAppLeaseAllowed } from '../core/daemon-command-registry.ts'; @@ -112,11 +113,13 @@ function macOsAppLeaseDenied( export function assertMacOsAppLeaseAdmitsRequest( lease: Pick, req: Pick, - session?: Pick, + session: Pick | undefined, + env: HostEnvironment['env'], ): void { if (lease.backend !== 'macos-app') return; const key = parseMacOsAppLeaseKey(lease.deviceKey); assertInvocation( + env, key, req.command, req.positionals ?? [], @@ -132,6 +135,7 @@ export function assertMacOsAppLeaseAdmitsRequest( for (const step of (req.flags?.batchSteps ?? []) as readonly Record[]) { const positionals = Array.isArray(step.positionals) ? step.positionals.map(String) : []; assertInvocation( + env, key, normalizeBatchCommandName(step.command), positionals, @@ -164,6 +168,7 @@ function withRuntimeInputs( } function assertInvocation( + env: HostEnvironment['env'], key: MacOsAppLeaseKey, command: string, positionals: readonly string[], @@ -178,7 +183,7 @@ function assertInvocation( assertNoDeviceSelector(fields); if (command === 'screenshot') assertWindowCapture(positionals, fields); else assertNoHostInputs(fields); - if (command === 'open') assertOpensLeasedApp(key, positionals); + if (command === 'open') assertOpensLeasedApp(env, key, positionals); if (command === 'close' && positionals.length > 0 && positionals[0] !== key.bundleId) { throw macOsAppLeaseDenied('app', `A macos-app lease only closes ${key.bundleId}.`, { bundleId: key.bundleId, @@ -242,13 +247,17 @@ function assertAppSurface(fields: Readonly>): void { } } -function assertOpensLeasedApp(key: MacOsAppLeaseKey, positionals: readonly string[]): void { +function assertOpensLeasedApp( + env: HostEnvironment['env'], + key: MacOsAppLeaseKey, + positionals: readonly string[], +): void { if (positionals.length !== 1 || positionals[0] !== key.bundleId) { throw macOsAppLeaseDenied('app', `A macos-app lease only opens ${key.bundleId}.`, { bundleId: key.bundleId, }); } - assertNativeBackend(); + assertNativeBackend(env); } /** @@ -256,8 +265,8 @@ function assertOpensLeasedApp(key: MacOsAppLeaseKey, positionals: readonly strin * outside the app's window lands on whatever is there. Only the native backend (ADR 0031) resolves * every action inside the session app. */ -function assertNativeBackend(): void { - if (readMacOsAppBackend(readHostEnvironmentVariable) === 'native') return; +function assertNativeBackend(env: HostEnvironment['env']): void { + if (readMacOsAppBackend((name) => env[name]) === 'native') return; throw macOsAppLeaseDenied( 'backend', 'A macos-app lease needs the native macOS app backend (AGENT_DEVICE_MACOS_APP_BACKEND=native).', diff --git a/packages/daemon/src/server/__tests__/request-execution-scope-host.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope-host.test.ts new file mode 100644 index 0000000000..36895ccd34 --- /dev/null +++ b/packages/daemon/src/server/__tests__/request-execution-scope-host.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from 'vitest'; +import { createTestHostEnvironment } from '@agent-device/test-kit'; +import { + daemonClaimRecoveryGatewayFixture, + daemonPlatformServicesFixture, +} from '../../__tests__/support/platform-services-fixture.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; +import { createRequestExecutionScope } from '../request-execution-scope.ts'; + +test('createRequestExecutionScope exposes the host of the daemon serving the request', async () => { + const host = createTestHostEnvironment({ + env: { AGENT_DEVICE_TEST: '1' }, + cwd: '/host/workspace', + }); + const scope = await createRequestExecutionScope({ + host, + req: { token: 'test-token', session: 'default', command: 'snapshot', positionals: [] }, + sessionStore: makeSessionStore('agent-device-request-scope-host-'), + leaseRegistry: new LeaseRegistry(), + platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), + }); + + expect(scope.host).toBe(host); +}); diff --git a/packages/daemon/src/server/__tests__/request-execution-scope-lease-expiry.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope-lease-expiry.test.ts index 47ab96dcdc..e07d9e9570 100644 --- a/packages/daemon/src/server/__tests__/request-execution-scope-lease-expiry.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-scope-lease-expiry.test.ts @@ -4,12 +4,18 @@ import { daemonClaimRecoveryGatewayFixture, } from '../../__tests__/support/platform-services-fixture.ts'; import { makeSession } from '../../__tests__/support/session-factories.ts'; -import { LINUX_DEVICE } from '@agent-device/test-kit'; +import { + LINUX_DEVICE, + createTestHostEnvironment, + bindTestCommandHost, +} from '@agent-device/test-kit'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { createRequestExecutionScope } from '../request-execution-scope.ts'; import type { DaemonRequest } from '../../core/daemon-request.ts'; +bindTestCommandHost(); + function makeRequest(overrides: Partial): DaemonRequest { return { token: 't', @@ -49,6 +55,7 @@ test('expired leases remove owned sessions before the next command and free capa now = 1_011; const scope = await createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: makeRequest({ command: 'snapshot' }), @@ -104,6 +111,7 @@ test.each(['rebuild', 'retire'] as const)( }); now = 1_011; const scope = await createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: makeRequest({ session: address, flags: { session: address } }), diff --git a/packages/daemon/src/server/__tests__/request-execution-scope-lease-session-end.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope-lease-session-end.test.ts index 8ee9c26b27..ece6abafbd 100644 --- a/packages/daemon/src/server/__tests__/request-execution-scope-lease-session-end.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-scope-lease-session-end.test.ts @@ -1,4 +1,8 @@ -import { deviceClaimFilePath, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + deviceClaimFilePath, + mkdtempForTestSync, + bindTestCommandHost, +} from '@agent-device/test-kit'; import fs from 'node:fs'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; @@ -15,6 +19,8 @@ import type { SessionStore } from '../../core/session-store.ts'; import type { SessionTeardown } from '../../sessions/lease-lifecycle.ts'; import { createLeaseSessionEnd } from '../request-execution-scope.ts'; +bindTestCommandHost(); + vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, emitDiagnostic: vi.fn() }; @@ -32,6 +38,7 @@ async function publishLeasedSession( lease: { leaseId, tenantId: 'tenant-a', runId: 'run-1' }, }); const acquired = await acquireDeviceClaim({ + env: process.env, device: session.device, session: name, workspace: stateDir, @@ -64,7 +71,12 @@ function leaseSessionEnd(sessionStore: SessionStore, teardownSession: SessionTea const executionLocks = new Map>(); return { executionLocks, - ender: createLeaseSessionEnd({ sessionStore, executionLocks, teardownSession }), + ender: createLeaseSessionEnd({ + env: process.env, + sessionStore, + executionLocks, + teardownSession, + }), }; } diff --git a/packages/daemon/src/server/__tests__/request-execution-scope-macos-app-lease.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope-macos-app-lease.test.ts index dd5b338f78..0c83f2de62 100644 --- a/packages/daemon/src/server/__tests__/request-execution-scope-macos-app-lease.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-scope-macos-app-lease.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { expect, test } from 'vitest'; import { daemonPlatformServicesFixture, @@ -41,6 +42,7 @@ async function runScoped(params: { daemonPolicy?: ReturnType; }): Promise<{ ran: boolean; error?: unknown }> { const scope = await createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: { token: 't', session: 'default', positionals: [], ...params.req }, @@ -124,6 +126,7 @@ test('a request under a pid-pinned macos-app lease does not run once that proces }), ); const scope = await createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: { token: 't', session: 'default', command: 'snapshot', positionals: [], flags: {} }, diff --git a/packages/daemon/src/server/__tests__/request-execution-scope-open-device-wait.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope-open-device-wait.test.ts index fff6f742db..4b9445bd47 100644 --- a/packages/daemon/src/server/__tests__/request-execution-scope-open-device-wait.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-scope-open-device-wait.test.ts @@ -10,7 +10,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import { makeSession } from '../../__tests__/support/session-factories.ts'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import type { DaemonRequest } from '../../core/daemon-request.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { readOpenWaitAttempt } from '../../sessions/open-device-contention-wait.ts'; @@ -119,6 +119,7 @@ test('an open that lost the race to a free device re-waits and opens rather than setTimeout(() => sessionStore.retire(holder), 50); const first = await createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: openRequest('first-opener'), @@ -126,6 +127,7 @@ test('an open that lost the race to a free device re-waits and opens rather than leaseRegistry, }); const second = await createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: openRequest('second-opener'), @@ -160,6 +162,7 @@ test('a close that frees the device mid-wait gets through while the open is wait const order: string[] = []; const opened = createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: openRequest('waiter'), @@ -174,6 +177,7 @@ test('a close that frees the device mid-wait gets through while the open is wait ); await sleep(50); const closer = await createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: closeRequest('holder'), @@ -207,6 +211,7 @@ test('an out-of-range wait budget is refused before resolving the target device' const resolveCallsBefore = vi.mocked(resolveTargetDevice).mock.calls.length; await expect( createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: openRequest('unbounded-opener', max + 1, 'request-unbounded'), diff --git a/packages/daemon/src/server/__tests__/request-execution-scope.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope.test.ts index 38c0987e78..e133892ea2 100644 --- a/packages/daemon/src/server/__tests__/request-execution-scope.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-scope.test.ts @@ -21,10 +21,16 @@ import { } from '../request-execution-scope.ts'; import { resolveSessionScope } from '../../sessions/session-routing.ts'; import type { DaemonRequest } from '../../core/daemon-request.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + createTestHostEnvironment, + bindTestCommandHost, +} from '@agent-device/test-kit'; import { makeTestScreenRecordingResource } from '../../__tests__/support/screen-recording-live-handle.ts'; import { handleSessionCloseCommands } from '../../sessions/session-lifecycle/internal/session-close.ts'; +bindTestCommandHost(); + const TEST_ROOT = mkdtempForTestSync('agent-device-request-execution-scope-'); const LOG_PATH = path.join(TEST_ROOT, 'diagnostics.log'); @@ -822,6 +828,7 @@ test('router: deferred tenant connect with no daemon session closes as SESSION_N const response = await scope.runLocked(async () => handleSessionCloseCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: scope.req, sessionName: scope.sessionName, @@ -891,10 +898,12 @@ test('router: an existing lease-less session under tenant isolation still refuse function createTestRequestScope( params: Omit< Parameters[0], - 'platformServices' | 'claimRecoveryGateway' - >, + 'platformServices' | 'claimRecoveryGateway' | 'host' + > & + Partial[0], 'host'>>, ) { return createRequestExecutionScope({ + host: createTestHostEnvironment(), ...params, platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), diff --git a/packages/daemon/src/server/__tests__/request-router-android-modal.test.ts b/packages/daemon/src/server/__tests__/request-router-android-modal.test.ts index f4b682cce7..0779c45fac 100644 --- a/packages/daemon/src/server/__tests__/request-router-android-modal.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-android-modal.test.ts @@ -23,7 +23,7 @@ import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import { makeTestScreenRecordingResource } from '../../__tests__/support/screen-recording-live-handle.ts'; import { androidObservationPolicyFixture as androidObservation } from '../../__tests__/support/android-observation-fixture.ts'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; const providerAdmission = providerDeviceAdmissionFixture(); @@ -154,6 +154,7 @@ test('generic Android gesture commands dismiss blocking system dialogs during re const { openAndroidApp } = await import('@agent-device/platform-android/mechanics'); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -207,6 +208,7 @@ test('generic Android gesture commands continue when recording dialog inspection vi.mocked(openAndroidApp).mockClear(); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -267,6 +269,7 @@ test('generic Android gesture commands skip local dialog recovery for provider d providerAdmission.setActive([runtime]); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-android-snapshot-helper.test.ts b/packages/daemon/src/server/__tests__/request-router-android-snapshot-helper.test.ts index 860293bae0..31c20eaa28 100644 --- a/packages/daemon/src/server/__tests__/request-router-android-snapshot-helper.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-android-snapshot-helper.test.ts @@ -1,7 +1,11 @@ import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { expect, test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { ANDROID_EMULATOR, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + ANDROID_EMULATOR, + mkdtempForTestSync, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { forwardingCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; import { makeSession } from '../../__tests__/support/session-factories.ts'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; @@ -21,6 +25,7 @@ function snapshotHandler(error: AppError) { }, }); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: forwardingCreateCommandSurface, logPath: `${mkdtempForTestSync('agent-device-snapshot-helper-response-')}/daemon.log`, token: 'token', diff --git a/packages/daemon/src/server/__tests__/request-router-apps-admission.test.ts b/packages/daemon/src/server/__tests__/request-router-apps-admission.test.ts index 7befa196a6..7a0b9e0b99 100644 --- a/packages/daemon/src/server/__tests__/request-router-apps-admission.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-apps-admission.test.ts @@ -8,7 +8,7 @@ import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import type { DaemonRequest } from '../../core/daemon-request.ts'; import { createRequestHandler } from '../../__tests__/support/test-device-runtime-gateway.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; function createAppsAdmissionHarness(apps: readonly string[] = []) { @@ -24,6 +24,7 @@ function createAppsAdmissionHarness(apps: readonly string[] = []) { throw new Error('apps catalog must not bind a device'); }); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-artifacts-web.test.ts b/packages/daemon/src/server/__tests__/request-router-artifacts-web.test.ts index 40c634dd0b..2173f6cd73 100644 --- a/packages/daemon/src/server/__tests__/request-router-artifacts-web.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-artifacts-web.test.ts @@ -8,7 +8,11 @@ import { LeaseRegistry } from '../../resources/lease-registry.ts'; import type { DaemonArtifactInventoryEntry } from '@agent-device/contracts/observability'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { makeSession } from '../../__tests__/support/session-factories.ts'; -import { mkdtempForTestSync, WEB_DESKTOP_DEVICE } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + WEB_DESKTOP_DEVICE, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { cleanupDownloadableArtifact, @@ -34,6 +38,7 @@ test('artifacts lists a daemon-tracked artifact produced during a web session', try { const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-cost.test.ts b/packages/daemon/src/server/__tests__/request-router-cost.test.ts index 053768eb5f..94342ec16b 100644 --- a/packages/daemon/src/server/__tests__/request-router-cost.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-cost.test.ts @@ -23,7 +23,7 @@ import type { SessionState } from '../../core/session-state.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { commandRpcParamsSchema } from '@agent-device/kernel/contracts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; // A representative, structurally rich owner payload so the parity assertions exercise nested // objects/arrays rather than a trivial flat record. `scroll` is the subject because it reaches a @@ -55,6 +55,7 @@ function makeHandler(sessionStore = makeSessionStore('agent-device-router-cost-' return { sessionStore, handler: createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-custom-action-flags.test.ts b/packages/daemon/src/server/__tests__/request-router-custom-action-flags.test.ts index ef38356735..5b3b0a464d 100644 --- a/packages/daemon/src/server/__tests__/request-router-custom-action-flags.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-custom-action-flags.test.ts @@ -12,11 +12,12 @@ import path from 'node:path'; import { createRequestHandler } from '../../__tests__/support/test-device-runtime-gateway.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; function createHandler() { return createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-developer-dir.test.ts b/packages/daemon/src/server/__tests__/request-router-developer-dir.test.ts index 3c8081c1d4..8d5d99896e 100644 --- a/packages/daemon/src/server/__tests__/request-router-developer-dir.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-developer-dir.test.ts @@ -5,50 +5,42 @@ import { runCmd } from '@agent-device/host-kit/command'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { createRequestHandler } from '../../__tests__/support/test-device-runtime-gateway.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; -test( - 'a request spawns its commands with its own DEVELOPER_DIR, else the daemon one', - { concurrent: false }, - async () => { - const seen: string[] = []; - const handler = createRequestHandler({ - createCommandSurface: fakeCreateCommandSurface, - logPath: path.join(mkdtempForTestSync('agent-device-router-developer-dir-'), 'daemon.log'), +test('a request spawns its commands with its own DEVELOPER_DIR, else the daemon one', async () => { + const seen: string[] = []; + const handler = createRequestHandler({ + host: createTestHostEnvironment({ + env: { ...process.env, DEVELOPER_DIR: '/daemon/Developer' }, + }), + createCommandSurface: fakeCreateCommandSurface, + logPath: path.join(mkdtempForTestSync('agent-device-router-developer-dir-'), 'daemon.log'), + token: 'test-token', + sessionStore: makeSessionStore('agent-device-router-developer-dir-store-'), + leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways({ + local: async () => { + const child = await runCmd(process.execPath, [ + '-e', + 'process.stdout.write(process.env.DEVELOPER_DIR ?? "")', + ]); + seen.push(child.stdout); + return []; + }, + }), + trackDownloadableArtifact: () => 'artifact-id', + }); + const devices = (developerDir?: string) => + handler({ token: 'test-token', - sessionStore: makeSessionStore('agent-device-router-developer-dir-store-'), - leaseRegistry: new LeaseRegistry(), - deviceInventoryGateways: createTestDeviceInventoryGateways({ - local: async () => { - const child = await runCmd(process.execPath, [ - '-e', - 'process.stdout.write(process.env.DEVELOPER_DIR ?? "")', - ]); - seen.push(child.stdout); - return []; - }, - }), - trackDownloadableArtifact: () => 'artifact-id', + session: 'default', + command: 'devices', + positionals: [], + flags: { platform: 'ios' }, + meta: { requestId: `req-${developerDir ?? 'none'}`, developerDir }, }); - const devices = (developerDir?: string) => - handler({ - token: 'test-token', - session: 'default', - command: 'devices', - positionals: [], - flags: { platform: 'ios' }, - meta: { requestId: `req-${developerDir ?? 'none'}`, developerDir }, - }); - const saved = process.env.DEVELOPER_DIR; - process.env.DEVELOPER_DIR = '/daemon/Developer'; - try { - await Promise.all([devices('/a/Developer'), devices('/b/Developer'), devices(''), devices()]); - } finally { - if (saved === undefined) delete process.env.DEVELOPER_DIR; - else process.env.DEVELOPER_DIR = saved; - } - expect([...seen].sort()).toEqual(['', '/a/Developer', '/b/Developer', '/daemon/Developer']); - }, -); + await Promise.all([devices('/a/Developer'), devices('/b/Developer'), devices(''), devices()]); + expect([...seen].sort()).toEqual(['', '/a/Developer', '/b/Developer', '/daemon/Developer']); +}); diff --git a/packages/daemon/src/server/__tests__/request-router-error-redaction.test.ts b/packages/daemon/src/server/__tests__/request-router-error-redaction.test.ts index 1bae28a6c6..6d29ec8724 100644 --- a/packages/daemon/src/server/__tests__/request-router-error-redaction.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-error-redaction.test.ts @@ -21,7 +21,7 @@ import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { makeIosAppSession } from '../../__tests__/support/session-factories.ts'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { @@ -62,6 +62,7 @@ test('a Maestro replay error comes back unchanged after its inputText registered const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- inputText: "1"\n'); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(root, 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-events.test.ts b/packages/daemon/src/server/__tests__/request-router-events.test.ts index 9f9e261b89..a5ff7e7350 100644 --- a/packages/daemon/src/server/__tests__/request-router-events.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-events.test.ts @@ -7,7 +7,11 @@ import { createRequestHandler } from '../../__tests__/support/test-device-runtim import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { makeIosSession, makeSession } from '../../__tests__/support/session-factories.ts'; -import { mkdtempForTestSync, WEB_DESKTOP_DEVICE } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + WEB_DESKTOP_DEVICE, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; test('events reads the daemon-owned session timeline without appending poll noise', async () => { @@ -21,6 +25,7 @@ test('events reads the daemon-owned session timeline without appending poll nois const eventLogPath = sessionStore.resolveEventLogPath('events-session'); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -66,6 +71,7 @@ test('events accepts a blank limit placeholder for cursor-only reads', async () }); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -106,6 +112,7 @@ test('events returns structured errors for invalid limit and cursor', async () = }); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -154,6 +161,7 @@ test('events flushes pending event writes before reading', async () => { }); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -192,6 +200,7 @@ test('events reads the daemon-owned session timeline for a web-backed session', }); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -226,6 +235,7 @@ test('request timeline records thrown request failures after scope creation', as sessionStore.publish('events-session', makeIosSession('events-session')); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -266,6 +276,7 @@ test('request timeline records setup failures after start is appended', async () const sessionStore = makeSessionStore('agent-device-router-events-setup-failure-'); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-fill-text-stdin.test.ts b/packages/daemon/src/server/__tests__/request-router-fill-text-stdin.test.ts index 1df40feeca..3bedb90b31 100644 --- a/packages/daemon/src/server/__tests__/request-router-fill-text-stdin.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-fill-text-stdin.test.ts @@ -20,6 +20,7 @@ import { createUnavailableRuntimeFactsForTest, mkdtempForTestSync, WEB_DESKTOP_DEVICE, + createTestHostEnvironment, } from '@agent-device/test-kit'; import type { DaemonRequest } from '../../core/daemon-request.ts'; import type { SessionState } from '../../core/session-state.ts'; @@ -79,6 +80,7 @@ function sendToWebSession( }; const dir = mkdtempForTestSync('agent-device-router-fill-text-stdin-runtime-'); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: createTouchCommandSurface(), logPath: path.join(dir, 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-idle-expired.test.ts b/packages/daemon/src/server/__tests__/request-router-idle-expired.test.ts index 6dabe6ed75..637b657c5e 100644 --- a/packages/daemon/src/server/__tests__/request-router-idle-expired.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-idle-expired.test.ts @@ -14,12 +14,13 @@ import { buildIdleExpiryTombstone } from '../session-idle-expiry.ts'; import type { DaemonRequest } from '../../core/daemon-request.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; function makeHandler(prefix: string) { const sessionStore = makeSessionStore(prefix); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-lock-policy.test.ts b/packages/daemon/src/server/__tests__/request-router-lock-policy.test.ts index ad7aec6606..958be0ab02 100644 --- a/packages/daemon/src/server/__tests__/request-router-lock-policy.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-lock-policy.test.ts @@ -31,7 +31,7 @@ import { type PlatformRuntimeOperations, snapshotRuntimePlanUses, } from '@agent-device/contracts/platform-runtime-operations'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; function snapshotDeviceRuntimeGateway(): DeviceRuntimeGateway { const runtime = snapshotRuntimeFixture(); @@ -128,6 +128,7 @@ test('direct daemon requests cannot bypass reject lock policy for existing sessi sessionStore.publish('qa-ios', makeIosSession('qa-ios')); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -165,6 +166,7 @@ test('fresh named sessions with matching explicit serial bind and serialize on t const dispatchGate = installGatedDispatch(); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -244,6 +246,7 @@ test('fresh named sessions with the same name serialize first binding before rej const dispatchGate = installGatedDispatch(); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -318,6 +321,7 @@ test('fresh named sessions with only lock platform default serialize on the sele const dispatchGate = installGatedDispatch(); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -427,6 +431,7 @@ test('fresh named sessions reject incompatible selector combinations before bind for (const testCase of cases) { const sessionStore = makeSessionStore('agent-device-router-lock-'); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -466,6 +471,7 @@ test('batch steps cannot bypass reject lock policy on nested direct requests', a sessionStore.publish('qa-ios', makeIosSession('qa-ios')); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -512,6 +518,7 @@ test('direct daemon requests apply strip lock policy for existing sessions befor systemRuntimeSpies.appSwitcher.mockClear(); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -556,6 +563,7 @@ test('strip lock policy still refuses a request naming a different device, befor }); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -595,6 +603,7 @@ test('batch preserves tenant-scoped session names across nested requests', async systemRuntimeSpies.appSwitcher.mockClear(); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-macos-app-lease.test.ts b/packages/daemon/src/server/__tests__/request-router-macos-app-lease.test.ts index 7e09a37289..a850b24fec 100644 --- a/packages/daemon/src/server/__tests__/request-router-macos-app-lease.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-macos-app-lease.test.ts @@ -2,7 +2,11 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import path from 'node:path'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { MACOS_DEVICE, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + MACOS_DEVICE, + mkdtempForTestSync, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { makeMacOsSession } from '../../__tests__/support/session-factories.ts'; import type { DaemonRequest } from '../../index.ts'; @@ -53,6 +57,7 @@ function leasedRouter() { }); const runtime = screenshotRuntimeFixture(); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -121,6 +126,7 @@ test('a leased snapshot carries the app tree and nothing about the host', async test('a request without a macos-app lease keeps its log locators', async () => { const sessionStore = makeSessionStore('agent-device-router-unleased-'); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-named-session.test.ts b/packages/daemon/src/server/__tests__/request-router-named-session.test.ts index 22ebf30a7a..c858c90d71 100644 --- a/packages/daemon/src/server/__tests__/request-router-named-session.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-named-session.test.ts @@ -14,7 +14,7 @@ import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; // #3674: a run whose daemon was replaced mid-test keeps sending commands to the session it opened. // The answer has to name the session that went missing, and must not depend on how many devices @@ -44,6 +44,7 @@ function makeHandler( ) { const sessionStore = makeSessionStore('agent-device-router-named-session-'); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-open-claim-recovery.test.ts b/packages/daemon/src/server/__tests__/request-router-open-claim-recovery.test.ts index 0719f0275a..5e3ec3a171 100644 --- a/packages/daemon/src/server/__tests__/request-router-open-claim-recovery.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-open-claim-recovery.test.ts @@ -19,7 +19,7 @@ import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { unavailableDeviceRuntimeGateway } from '../../__tests__/support/unavailable-device-runtime-gateway.ts'; import { getResolveTargetDeviceMock } from '../../__tests__/support/request-router-dispatch-mocks.ts'; import { makeIosDevice, openRequest } from './request-router-open-harness.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); @@ -44,6 +44,7 @@ test('open composes claim recovery from the foreign owner state directory', asyn try { writeForeignClaim(claimsDir, foreignStateDir, device); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -64,7 +65,9 @@ test('open composes claim recovery from the foreign owner state directory', asyn const daemonSessionsDir = resolveDaemonPaths(sessionStore.resolveDaemonStateDir()).sessionsDir; expect(recoverySessionDirs).toEqual([ownerSessionsDir]); expect(recoverySessionDirs[0]).not.toBe(daemonSessionsDir); - expect(inspectDeviceClaims({ udid: device.id })[0]?.claim?.session).toBe('takeover'); + expect(inspectDeviceClaims({ udid: device.id }, process.env)[0]?.claim?.session).toBe( + 'takeover', + ); } finally { if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; diff --git a/packages/daemon/src/server/__tests__/request-router-open-claim.test.ts b/packages/daemon/src/server/__tests__/request-router-open-claim.test.ts index 8833960754..348d6934e5 100644 --- a/packages/daemon/src/server/__tests__/request-router-open-claim.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-open-claim.test.ts @@ -59,7 +59,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { inspectDeviceClaims } from '@agent-device/daemon-contracts/device-claims'; import { makeIosDevice, openRequest, storedClaimUpdatedAt } from './request-router-open-harness.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); @@ -71,6 +71,7 @@ function createOpenHandler( leaseRegistry = new LeaseRegistry(), ) { return createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -144,7 +145,9 @@ test('open takes a live foreign claim whose device rebooted after the claim was expect(response.data?.warnings).toContain( 'Took the device from session "shared" in workspace "/worktrees/live": that device rebooted after its claim was taken, so its app and runner were already gone.', ); - expect(inspectDeviceClaims({ udid: device.id })[0]?.claim?.session).toBe('reboot-takeover'); + expect(inspectDeviceClaims({ udid: device.id }, process.env)[0]?.claim?.session).toBe( + 'reboot-takeover', + ); } finally { if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; @@ -191,7 +194,9 @@ test('open renews the claim of an existing session that reopened its app after a expect(foreign.ok).toBe(false); if (foreign.ok) return; expect(foreign.error.code).toBe('DEVICE_IN_USE'); - expect(inspectDeviceClaims({ udid: device.id })[0]?.claim?.session).toBe('renew-owner'); + expect(inspectDeviceClaims({ udid: device.id }, process.env)[0]?.claim?.session).toBe( + 'renew-owner', + ); } finally { if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; @@ -228,7 +233,7 @@ test('an owner reopen that lost the device mid-flight reports the loss instead o expect(reopened.error.code).toBe('DEVICE_IN_USE'); expect(reopened.error.message).toContain('shared'); } - expect(inspectDeviceClaims({ udid: device.id })[0]?.claim?.ownerToken).toBe( + expect(inspectDeviceClaims({ udid: device.id }, process.env)[0]?.claim?.ownerToken).toBe( 'reboot-takeover-token', ); } finally { diff --git a/packages/daemon/src/server/__tests__/request-router-open-harness.ts b/packages/daemon/src/server/__tests__/request-router-open-harness.ts index 326650d55e..e1960d1396 100644 --- a/packages/daemon/src/server/__tests__/request-router-open-harness.ts +++ b/packages/daemon/src/server/__tests__/request-router-open-harness.ts @@ -47,7 +47,7 @@ export function openRequest( } export function storedClaimUpdatedAt(device: DeviceInfo): number { - const claim = inspectDeviceClaims({ udid: device.id })[0]?.claim; + const claim = inspectDeviceClaims({ udid: device.id }, process.env)[0]?.claim; if (claim?.updatedAtMs === undefined) { throw new Error(`no device claim is stored for ${device.id}`); } 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 94d364b3ce..57c6141f18 100644 --- a/packages/daemon/src/server/__tests__/request-router-open.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-open.test.ts @@ -77,7 +77,7 @@ import { openRequest, storedClaimUpdatedAt, } from './request-router-open-harness.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); @@ -93,6 +93,7 @@ function createOpenHandler( > = {}, ) { return createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -150,7 +151,7 @@ test('open returns and creates the session state directory', async () => { await sessionStore.flushEvents('session-a'); expect(fs.existsSync(String(response.data?.eventLogPath))).toBe(true); } - expect(inspectDeviceClaims({ udid: device.id })[0]?.claim?.stateDir).toBe( + expect(inspectDeviceClaims({ udid: device.id }, process.env)[0]?.claim?.stateDir).toBe( sessionStore.resolveDaemonStateDir(), ); }); @@ -680,7 +681,9 @@ test('an open that booted the device keeps the device against a foreign open', a expect(foreign.ok).toBe(false); if (foreign.ok) return; expect(foreign.error.code).toBe('DEVICE_IN_USE'); - expect(inspectDeviceClaims({ udid: device.id })[0]?.claim?.session).toBe('boot-owner'); + expect(inspectDeviceClaims({ udid: device.id }, process.env)[0]?.claim?.session).toBe( + 'boot-owner', + ); } finally { if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; diff --git a/packages/daemon/src/server/__tests__/request-router-provider-credentials.test.ts b/packages/daemon/src/server/__tests__/request-router-provider-credentials.test.ts index 2205a68de7..332a2f6608 100644 --- a/packages/daemon/src/server/__tests__/request-router-provider-credentials.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-provider-credentials.test.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { expect, test } from 'vitest'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { createRequestHandler } from '../../__tests__/support/test-device-runtime-gateway.ts'; import { fakeCreateCommandSurface as testCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; @@ -19,6 +19,7 @@ test.for([ const stateDir = mkdtempForTestSync('agent-device-router-provider-credentials-'); let allocated = 0; const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(stateDir, 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-record-flags.test.ts b/packages/daemon/src/server/__tests__/request-router-record-flags.test.ts index bcb9c0b05e..4f5a85d433 100644 --- a/packages/daemon/src/server/__tests__/request-router-record-flags.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-record-flags.test.ts @@ -12,11 +12,12 @@ import path from 'node:path'; import { createRequestHandler } from '../../__tests__/support/test-device-runtime-gateway.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; function createHandler() { return createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-record-runtime-lock.test.ts b/packages/daemon/src/server/__tests__/request-router-record-runtime-lock.test.ts index af4d60a2a0..6744737212 100644 --- a/packages/daemon/src/server/__tests__/request-router-record-runtime-lock.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-record-runtime-lock.test.ts @@ -11,7 +11,10 @@ import type { ScreenRecordingLiveHandle } from '@agent-device/contracts/screen-r import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '@agent-device/test-kit'; +import { + unavailableDeploymentSnapshotAndShutdownOperationFacts, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { createRequestHandler } from '../../__tests__/support/test-device-runtime-gateway.ts'; @@ -42,6 +45,7 @@ test('fresh default-device recording starts serialize before durable admission', const runtime = makeRecordingGateway(firstStartBlocked); const sessionStore = makeSessionStore('request-router-record-runtime-lock-'); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: '/tmp/daemon.log', token: 'token', diff --git a/packages/daemon/src/server/__tests__/request-router-recording-health.test.ts b/packages/daemon/src/server/__tests__/request-router-recording-health.test.ts index f8984eb5b4..abd6ea584c 100644 --- a/packages/daemon/src/server/__tests__/request-router-recording-health.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-recording-health.test.ts @@ -45,7 +45,7 @@ import type { SessionState } from '../../core/session-state.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { makeTestScreenRecordingResource } from '../../__tests__/support/screen-recording-live-handle.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; // The observation spy hangs off the port the router composes with (ADR 0019 section 11), not off // the platform module behind it. @@ -90,6 +90,7 @@ test('router blocks non-record commands when recording was invalidated', async ( sessionStore.publish('default', session); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -146,6 +147,7 @@ test('router allows canonical iOS simulator gestures during overlay recording af sessionId: 'runner-after', }); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-repair-expired.test.ts b/packages/daemon/src/server/__tests__/request-router-repair-expired.test.ts index 4e7b9fc407..2511216b13 100644 --- a/packages/daemon/src/server/__tests__/request-router-repair-expired.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-repair-expired.test.ts @@ -24,13 +24,14 @@ import { scopeRequestSession } from '../../sessions/request-admission.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { inspectAdReplay } from '@agent-device/ad-replay'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); function makeHandler(prefix: string) { const sessionStore = makeSessionStore(prefix); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-replay-env.test.ts b/packages/daemon/src/server/__tests__/request-router-replay-env.test.ts index 41d46f6991..ee3fbaa1a4 100644 --- a/packages/daemon/src/server/__tests__/request-router-replay-env.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-replay-env.test.ts @@ -5,7 +5,7 @@ import { expect, test } from 'vitest'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { createRequestHandler } from '../../__tests__/support/test-device-runtime-gateway.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { replayScriptSourceBundleFor } from '../../__tests__/support/replay-script-source-fixture.ts'; import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; @@ -14,6 +14,7 @@ function createHarness() { return { root, handler: createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(root, 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-replay-scope.test.ts b/packages/daemon/src/server/__tests__/request-router-replay-scope.test.ts index 33754da684..e5bd06ca76 100644 --- a/packages/daemon/src/server/__tests__/request-router-replay-scope.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-replay-scope.test.ts @@ -29,7 +29,11 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => }; }); -import { IOS_SIMULATOR, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + IOS_SIMULATOR, + mkdtempForTestSync, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { makeIosSession } from '../../__tests__/support/session-factories.ts'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; @@ -65,6 +69,7 @@ test('replay runs active-session actions inside the parent request provider scop const providerScopes = requestPlatformProvidersFake(); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -106,6 +111,7 @@ test('replay routes session-changing actions through the full request path', asy const providerScopes = requestPlatformProvidersFake(); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -143,6 +149,7 @@ test('session list includes a cwd-scoped session opened by replay', async () => const sessionStore = makeSessionStore('agent-device-replay-open-scope-'); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -206,6 +213,7 @@ test('fresh replay retains a dynamically selected device through finalization', }); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-response-level.test.ts b/packages/daemon/src/server/__tests__/request-router-response-level.test.ts index 9d9a7fc7ad..b86bce5d3e 100644 --- a/packages/daemon/src/server/__tests__/request-router-response-level.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-response-level.test.ts @@ -40,7 +40,7 @@ import type { SessionState } from '../../core/session-state.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { commandRpcParamsSchema } from '@agent-device/kernel/contracts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; const REPRESENTATIVE_PAYLOAD = { message: 'scroll-ok', items: [1, 2, 3] } as const; /** What the bound `app-switcher` leaf answers; this file's registered view digests it. */ @@ -69,6 +69,7 @@ function makeHandler() { return { sessionStore, handler: createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts b/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts index 286bab4bad..57d4a37698 100644 --- a/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts @@ -29,7 +29,12 @@ import type { SessionState } from '../../core/session-state.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { PNG } from '@agent-device/capture-kit/png'; -import { ANDROID_EMULATOR, IOS_SIMULATOR, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + ANDROID_EMULATOR, + IOS_SIMULATOR, + mkdtempForTestSync, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { makeSession as makeBaseSession } from '../../__tests__/support/session-factories.ts'; @@ -80,6 +85,7 @@ function screenshotRouter( sessionStore.publish(session.name, session); const runtime = screenshotRuntimeFixture(options); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -237,6 +243,7 @@ test('router serializes concurrent commands for the same device across sessions' }); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-session-address.test.ts b/packages/daemon/src/server/__tests__/request-router-session-address.test.ts index edcd427f6e..6a89361463 100644 --- a/packages/daemon/src/server/__tests__/request-router-session-address.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-session-address.test.ts @@ -33,7 +33,7 @@ import { awaitFixtureReadiness } from '../../__tests__/support/application-lifec import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonRequest } from '../../core/daemon-request.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; /** * The production route for the #2031/#1394 defect: a request without `--session` resolves the @@ -63,6 +63,7 @@ function makeIosDevice(id: string): DeviceInfo { function createHandler(sessionStore: ReturnType) { return createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-router-typed-error.test.ts b/packages/daemon/src/server/__tests__/request-router-typed-error.test.ts index daed8613cc..9d4ac838a9 100644 --- a/packages/daemon/src/server/__tests__/request-router-typed-error.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-typed-error.test.ts @@ -36,7 +36,7 @@ import { PRE_DISPATCH_REFUSAL_REASONS, retriableForErrorCode, } from '@agent-device/kernel/errors'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; const mockLifecycleEffect = vi.mocked(dispatchApplicationLifecycleEffect); @@ -65,6 +65,7 @@ function makeHandler(sessionStore = makeSessionStore('agent-device-router-typed- return { sessionStore, handler: createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/server/__tests__/request-save-script-transports.test.ts b/packages/daemon/src/server/__tests__/request-save-script-transports.test.ts index bb6e50625d..78258b43d9 100644 --- a/packages/daemon/src/server/__tests__/request-save-script-transports.test.ts +++ b/packages/daemon/src/server/__tests__/request-save-script-transports.test.ts @@ -36,6 +36,7 @@ import { listenOnLoopback, mkdtempForTestSync, skipWhenLoopbackUnavailable, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { flushSessionEventLogWrites } from '@agent-device/session-journal/session-event-log'; import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; @@ -75,6 +76,7 @@ function setup(): Harness { const session = makeIosSession(SESSION); sessionStore.publish(SESSION, session); const handleRequest = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(root, 'daemon.log'), token: TOKEN, diff --git a/packages/daemon/src/server/__tests__/session-idle-expiry-harness.ts b/packages/daemon/src/server/__tests__/session-idle-expiry-harness.ts index 705989cb4c..501276e40d 100644 --- a/packages/daemon/src/server/__tests__/session-idle-expiry-harness.ts +++ b/packages/daemon/src/server/__tests__/session-idle-expiry-harness.ts @@ -118,6 +118,7 @@ export function createIdleExpiryHarness(): Readonly<{ sessionWithLiveClaim: async (fixture, name = 'default') => { const { stateDir } = fixture.claims; const acquired = await acquireDeviceClaim({ + env: process.env, device: IOS_SIMULATOR, session: name, workspace: stateDir, diff --git a/packages/daemon/src/server/http/daemon-runtime-artifacts.test.ts b/packages/daemon/src/server/http/daemon-runtime-artifacts.test.ts index 65f94c0d57..8e31d53d8d 100644 --- a/packages/daemon/src/server/http/daemon-runtime-artifacts.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-artifacts.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonRuntimeDepsFixture } from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; import { cleanupDownloadableArtifact, @@ -19,13 +19,15 @@ async function downloadTrackedArtifact(env: Record, awaitConsump fileName: 'runtime-artifact.txt', }); const runtime = await startDaemonRuntime({ - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - ...env, - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + ...env, + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, diff --git a/packages/daemon/src/server/http/daemon-runtime-composition.test.ts b/packages/daemon/src/server/http/daemon-runtime-composition.test.ts index 32127d020f..71c8a61268 100644 --- a/packages/daemon/src/server/http/daemon-runtime-composition.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-composition.test.ts @@ -7,7 +7,12 @@ import { tryAcquireDaemonRegistration, } from '@agent-device/daemon-contracts/daemon-registration-owner'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { IOS_SIMULATOR, mkdtempForTestSync, WEB_DESKTOP_DEVICE } from '@agent-device/test-kit'; +import { + IOS_SIMULATOR, + mkdtempForTestSync, + WEB_DESKTOP_DEVICE, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { installInteractorResolution, interactorResolution, @@ -40,12 +45,14 @@ test('daemon startup installs the interactor resolution the composition supplied let runtime: Awaited> = null; try { runtime = await startDaemonRuntime({ - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, @@ -73,7 +80,7 @@ test('a daemon attempt losing the lock shuts the providers down through the comp }); const exit = vi.fn(); const runtime = await startDaemonRuntime({ - env: { AGENT_DEVICE_STATE_DIR: stateDir }, + host: createTestHostEnvironment({ env: { AGENT_DEVICE_STATE_DIR: stateDir } }), exit, registerProcessHandlers: false, stderr: { write: () => {} }, @@ -88,6 +95,37 @@ test('a daemon attempt losing the lock shuts the providers down through the comp await held.owner.finish(); }); +test('the server runs on the host it was given and hands that host to the composition', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-host-'); + const host = createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + cwd: '/agent-device-test/host-cwd', + }); + let composed: DaemonRuntimeDepsInput | undefined; + const fixtureDeps = daemonRuntimeDepsFixture(); + const runtime = await startDaemonRuntime({ + host, + registerProcessHandlers: false, + exit: () => {}, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + composeDeps: async (input) => { + composed = input; + return await fixtureDeps(input); + }, + }); + try { + assert.equal(composed?.host, host); + assert.equal(composed?.baseDir, resolveDaemonPaths(stateDir).baseDir); + } finally { + await runtime?.shutdown(); + } +}); + /** * The one-assembly guarantee (#3431 review): the server owns the paths, the ADR 0029 policy load, * the session store, and the `assertShutdownAllowed` derivation, and passes them to the @@ -108,12 +146,14 @@ test('the server derives the policy gate and session callbacks and hands them to let runtime: Awaited> = null; try { runtime = await startDaemonRuntime({ - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_POLICY: policyPath, - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_POLICY: policyPath, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), exit: (code) => exits.push(code), registerProcessHandlers: false, stderr: { write: () => {} }, @@ -176,12 +216,14 @@ test('an unreadable policy stops the assembly before the factory is called', asy let runtime: Awaited> = null; try { runtime = await startDaemonRuntime({ - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_POLICY: path.join(stateDir, 'missing-policy.json'), - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_POLICY: path.join(stateDir, 'missing-policy.json'), + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), exit: (code) => exits.push(code), stderr: { write: (chunk) => stderr.push(chunk) }, stdout: { write: () => {} }, diff --git a/packages/daemon/src/server/http/daemon-runtime-idle-reap.test.ts b/packages/daemon/src/server/http/daemon-runtime-idle-reap.test.ts index eff7cd1d1e..af6061195a 100644 --- a/packages/daemon/src/server/http/daemon-runtime-idle-reap.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-idle-reap.test.ts @@ -19,7 +19,7 @@ vi.mock('../../resources/lease-registry.ts', async (importOriginal) => { import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { daemonRuntimeDepsFixture } from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; import { startDaemonRuntime } from './daemon-runtime.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; afterEach(() => { vi.useRealTimers(); @@ -39,12 +39,14 @@ test('daemon runtime self-reaps after the idle window when nothing ever uses it' try { const runtime = await startDaemonRuntime({ composeDeps: daemonRuntimeDepsFixture(), - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '80', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '80', + }, + }), exit: (code) => { exitCode = code; resolveExit(); @@ -79,12 +81,14 @@ test('daemon runtime never self-reaps when AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS i try { const runtime = await startDaemonRuntime({ composeDeps: daemonRuntimeDepsFixture(), - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + }, + }), exit: (code) => { exitCode = code; }, @@ -117,12 +121,14 @@ test('only a retained lease keeps the daemon runtime from self-reaping', async ( try { const runtime = await startDaemonRuntime({ composeDeps: daemonRuntimeDepsFixture(), - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '80', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '80', + }, + }), exit: (code) => { exitCode = code; resolveExit(); diff --git a/packages/daemon/src/server/http/daemon-runtime-lease-expiry.test.ts b/packages/daemon/src/server/http/daemon-runtime-lease-expiry.test.ts index 21b1f9eab8..3d2892f5a8 100644 --- a/packages/daemon/src/server/http/daemon-runtime-lease-expiry.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-lease-expiry.test.ts @@ -38,7 +38,7 @@ vi.mock('../request-execution-scope.ts', async (importOriginal) => { import { SessionStore } from '../../core/session-store.ts'; import { daemonRuntimeDepsFixture } from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; import { startDaemonRuntime } from './daemon-runtime.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; afterEach(() => { vi.useRealTimers(); @@ -54,13 +54,15 @@ test('an expired lease ends its session without any further request', async () = try { const runtime = await startDaemonRuntime({ composeDeps: daemonRuntimeDepsFixture(), - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_LEASE_MIN_TTL_MS: '1', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_LEASE_MIN_TTL_MS: '1', + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, @@ -96,12 +98,14 @@ test('shutdown cancels and drains lease ends before it tears sessions down', asy try { const runtime = await startDaemonRuntime({ composeDeps: daemonRuntimeDepsFixture(), - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, diff --git a/packages/daemon/src/server/http/daemon-runtime-lifecycle-shutdown.test.ts b/packages/daemon/src/server/http/daemon-runtime-lifecycle-shutdown.test.ts index b618bf8181..7b506c5324 100644 --- a/packages/daemon/src/server/http/daemon-runtime-lifecycle-shutdown.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-lifecycle-shutdown.test.ts @@ -1,4 +1,9 @@ -import { ANDROID_EMULATOR, deviceClaimFilePath, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + ANDROID_EMULATOR, + deviceClaimFilePath, + mkdtempForTestSync, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import fs from 'node:fs'; import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; @@ -140,12 +145,14 @@ test('daemon shutdown detaches before session teardown and force-finalizes only }, }), }), - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), exit: (code) => exits.push(code), registerProcessHandlers: false, stderr: { write: (chunk) => startupErrors.push(chunk) }, @@ -193,12 +200,14 @@ test('a SIGTERM shutdown gives the handoff a diagnostics scope to write its reas }, }), }), - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, @@ -246,12 +255,14 @@ test('shutdown includes drain publications, releases their claims and refuses po }, }), }), - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, @@ -261,6 +272,7 @@ test('shutdown includes drain publications, releases their claims and refuses po expect(runtime).not.toBeNull(); const store = shutdownProbe.store!; const acquired = await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'draining', workspace: stateDir, @@ -325,12 +337,14 @@ test('shutdown joins dispatch completion after force-closing the client before t }, }), }), - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, @@ -354,6 +368,7 @@ test('shutdown joins dispatch completion after force-closing the client before t expect(runtime).not.toBeNull(); const store = shutdownProbe.store!; const acquired = await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'dispatching', workspace: stateDir, @@ -491,12 +506,14 @@ test('daemon shutdown releases a session lease whose session teardown rejects', }, }), }), - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, @@ -550,12 +567,14 @@ test('daemon shutdown releases a retainOnClose lease that no session holds', asy }, }), }), - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, diff --git a/packages/daemon/src/server/http/daemon-runtime-metadata-ownership.test.ts b/packages/daemon/src/server/http/daemon-runtime-metadata-ownership.test.ts index fe9955f61c..6da4e3eebb 100644 --- a/packages/daemon/src/server/http/daemon-runtime-metadata-ownership.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-metadata-ownership.test.ts @@ -5,7 +5,11 @@ import type { import fs from 'node:fs'; import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; -import { mkdtempForTestSync, publishDaemonRegistration } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + publishDaemonRegistration, + createTestHostEnvironment, +} from '@agent-device/test-kit'; const lifecycleEvents = vi.hoisted(() => [] as string[]); const startupFailure = vi.hoisted(() => ({ active: false })); @@ -50,12 +54,14 @@ function startRuntime(stateDir: string, exit: (code: number) => void) { pluginInventory: providerComposition.inventory, shutdownProviderRuntimes: async () => providerComposition.cleanup, })), - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), exit, registerProcessHandlers: false, stderr: { write: () => {} }, diff --git a/packages/daemon/src/server/http/daemon-runtime-owner-lifecycle.test.ts b/packages/daemon/src/server/http/daemon-runtime-owner-lifecycle.test.ts index 8888613a11..a838601426 100644 --- a/packages/daemon/src/server/http/daemon-runtime-owner-lifecycle.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-owner-lifecycle.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonRuntimeDepsFixture, platformOwnerLifecycleFixture, @@ -25,12 +25,14 @@ function startDaemon( configureForDaemonLock: PlatformOwnerLifecycle['configureForDaemonLock'], ) { return startDaemonRuntime({ - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, diff --git a/packages/daemon/src/server/http/daemon-runtime-quiesce.test.ts b/packages/daemon/src/server/http/daemon-runtime-quiesce.test.ts index d097a68b2b..de8c21c769 100644 --- a/packages/daemon/src/server/http/daemon-runtime-quiesce.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-quiesce.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import net from 'node:net'; import { afterEach, expect, test, vi } from 'vitest'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; import { buildDaemonQuiesceRequest, resolveDaemonPaths, @@ -105,12 +105,14 @@ async function bootDaemon(options: { serverMode?: 'socket' | 'dual' } = {}): Pro }); const runtime = await startDaemonRuntime({ composeDeps: daemonRuntimeDepsFixture(), - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: options.serverMode ?? 'socket', - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: options.serverMode ?? 'socket', + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + }, + }), exit: resolveExit, registerProcessHandlers: false, stderr: { write: () => {} }, @@ -333,12 +335,14 @@ test('a quiesce asked while the daemon is still starting up is refused as busy, }); const starting = startDaemonRuntime({ composeDeps: daemonRuntimeDepsFixture(), - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: 'socket', - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'socket', + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + }, + }), exit: resolveExit, registerProcessHandlers: false, stderr: { write: () => {} }, diff --git a/packages/daemon/src/server/http/daemon-runtime-recording-reaper.test.ts b/packages/daemon/src/server/http/daemon-runtime-recording-reaper.test.ts index ab279d09cc..8304b4c083 100644 --- a/packages/daemon/src/server/http/daemon-runtime-recording-reaper.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-recording-reaper.test.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import { afterEach, expect, test, vi } from 'vitest'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; const reapCalls = vi.hoisted(() => [] as Array>); @@ -27,12 +27,14 @@ test('daemon startup reaps orphaned simctl recorders with a graceful finalize wi try { const runtime = await startDaemonRuntime({ composeDeps: daemonRuntimeDepsFixture(), - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, diff --git a/packages/daemon/src/server/http/daemon-runtime-rpc-flow-failure.test.ts b/packages/daemon/src/server/http/daemon-runtime-rpc-flow-failure.test.ts index e168788efe..ec814ad7e9 100644 --- a/packages/daemon/src/server/http/daemon-runtime-rpc-flow-failure.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-rpc-flow-failure.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import { afterEach, expect, test, vi } from 'vitest'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; // The HTTP server hands a `/rpc` flow rejection it answered itself to `onUnownedRequestFailure`, // because that event has no request scope to be recorded in — the request's own scope opens later, @@ -99,12 +99,14 @@ async function driveFlowFailure(options: { stderrThrows?: boolean }): Promise { exitCode = code; }, diff --git a/packages/daemon/src/server/http/daemon-runtime.ts b/packages/daemon/src/server/http/daemon-runtime.ts index 162829aced..6438c7a16a 100644 --- a/packages/daemon/src/server/http/daemon-runtime.ts +++ b/packages/daemon/src/server/http/daemon-runtime.ts @@ -62,7 +62,9 @@ import { createDaemonShutdownClaimLedger } from './daemon-shutdown-claims.ts'; import { createAudioProbeAdmissionLedger } from '@agent-device/capture-kit/audio-probe-admission-ledger'; import { createPerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; +import { withCommandHost } from '@agent-device/host-kit/command'; import { emitDiagnostic, type ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; +import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; import { readCurrentOwnerIdentity, reapOwnedProcessRecordsAtStartup, @@ -183,7 +185,8 @@ export type DaemonRuntimeDeps = Readonly<{ /** Everything the composition factory needs that the server owns: paths, policy, and the store. */ export type DaemonRuntimeDepsInput = Readonly<{ - env: NodeJS.ProcessEnv; + /** The environment and working directory this daemon runs with; nothing below reads the process's. */ + host: HostEnvironment; /** The resolved state-dir root; the server resolves daemon paths and passes it down. */ baseDir: string; sessionsDir: string; @@ -204,7 +207,7 @@ export type ComposeDaemonRuntimeDeps = ( ) => Promise; export type DaemonRuntimeOptions = { - env?: NodeJS.ProcessEnv; + host: HostEnvironment; stdout?: WritableOutput; stderr?: WritableOutput; exit?: (code: number) => void; @@ -404,11 +407,20 @@ export async function teardownDaemonSessionForShutdown(params: { export async function startDaemonRuntime( options: DaemonRuntimeOptions, ): Promise { - const env = options.env ?? process.env; + return await withCommandHost({ host: options.host, requestEnv: undefined }, async () => { + return await startDaemonRuntimeOnHost(options); + }); +} + +async function startDaemonRuntimeOnHost( + options: DaemonRuntimeOptions, +): Promise { + const { host } = options; + const { env } = host; const stdout = options.stdout ?? process.stdout; const stderr = options.stderr ?? process.stderr; const exit = options.exit ?? ((code: number) => process.exit(code)); - const daemonPaths = resolveDaemonPaths(env.AGENT_DEVICE_STATE_DIR); + const daemonPaths = resolveDaemonPaths(env.AGENT_DEVICE_STATE_DIR, { env, cwd: host.cwd }); const { baseDir, infoPath, logPath, sessionsDir } = daemonPaths; const daemonServerMode = resolveDaemonServerMode(env.AGENT_DEVICE_DAEMON_SERVER_MODE); const retainArtifacts = isEnvTruthy(env.AGENT_DEVICE_RETAIN_ARTIFACTS); @@ -417,7 +429,7 @@ export async function startDaemonRuntime( // runtime, so no capability is ever built under a policy that failed validation. let daemonPolicy: DaemonPolicy | undefined; try { - daemonPolicy = loadDaemonPolicy(env); + daemonPolicy = loadDaemonPolicy(env, host.cwd); } catch (error) { stderr.write(`Daemon error: ${asAppError(error).message}\n`); exit(1); @@ -431,7 +443,7 @@ export async function startDaemonRuntime( const sessionStore = new SessionStore(sessionsDir); const deps = await options.composeDeps({ - env, + host, baseDir, sessionsDir, assertShutdownAllowed, @@ -505,6 +517,7 @@ export async function startDaemonRuntime( ); leaseExpirySweep.unref(); const dispatchRequest = createRequestHandler({ + host, logPath, token, sessionStore, @@ -541,7 +554,7 @@ export async function startDaemonRuntime( }); }; - const shutdownClaimLedger = createDaemonShutdownClaimLedger(); + const shutdownClaimLedger = createDaemonShutdownClaimLedger(env); const teardownDaemonSession = async (ref: SessionRef): Promise => { const session = sessionStore.resolveCurrent(ref) ?? ref.session; @@ -609,6 +622,7 @@ export async function startDaemonRuntime( }; const leaseSessionEnd = createLeaseSessionEnd({ + env, sessionStore, executionLocks: getLeaseRegistryExecutionLocks(leaseRegistry), teardownSession: settleIdleExpiredSession, @@ -621,6 +635,7 @@ export async function startDaemonRuntime( }; const sessionIdleExpiry = createSessionIdleExpiry({ + env, sessionStore, idleExpiryMs: resolveSessionIdleExpiryMs(env), executionLocks: getLeaseRegistryExecutionLocks(leaseRegistry), @@ -783,7 +798,7 @@ export async function startDaemonRuntime( try { await deps.ownerLifecycle.configureForDaemonLock({ stateDir: baseDir, - hasDeviceClaimAuthority: processOwnsActiveDeviceClaim, + hasDeviceClaimAuthority: (device) => processOwnsActiveDeviceClaim(device, env), onDiagnostic: (diagnostic) => startupDiagnostics.push(diagnostic), }); const legacyMarkerRecovery = await deps.ownerLifecycle.recoverLegacyAppLogMarkers(sessionsDir); @@ -857,6 +872,7 @@ export async function startDaemonRuntime( composeGateway: deps.claimRecoveryGateway, }), baseDir, + env, ); // Arms the initial idle-reap timer: a daemon that starts and never // receives a request must still be able to reap itself. @@ -880,7 +896,7 @@ export async function startDaemonRuntime( // cannot start, PNG processing falls back to the in-process sync path. prewarmPngWorker(); - const shutdown = async (shutdownOptions: { exitCode?: number; cause?: unknown } = {}) => { + const shutdownOnHost = async (shutdownOptions: { exitCode?: number; cause?: unknown } = {}) => { idleReap.cancel(); sessionIdleExpiry.cancel(); leaseSessionEnd.cancel(); @@ -956,6 +972,13 @@ export async function startDaemonRuntime( exit(shutdownOptions.exitCode ?? 0); }; + // Signal handlers and external callers run outside the startup scope, so shutdown re-enters it. + const shutdown = async (shutdownOptions: { exitCode?: number; cause?: unknown } = {}) => + await withCommandHost( + { host, requestEnv: undefined }, + async () => await shutdownOnHost(shutdownOptions), + ); + startingUp = false; if (options.registerProcessHandlers !== false) { @@ -993,10 +1016,11 @@ async function reconcileDeviceClaimsForDaemonStartup( logPath: string, reconcile: DeviceClaimReconciler, stateDir: string, + env: EnvMap, ): Promise { await withDaemonDiagnosticsScope({ logPath }, async () => { try { - const summary = await reconcileOrphanedDeviceClaims(reconcile, stateDir); + const summary = await reconcileOrphanedDeviceClaims(reconcile, stateDir, env); if (summary.examined > 0) { emitDiagnostic({ phase: 'device_claim_reconcile', data: summary }); } diff --git a/packages/daemon/src/server/http/daemon-session-idle-expiry-scheduling.test.ts b/packages/daemon/src/server/http/daemon-session-idle-expiry-scheduling.test.ts index b32bf91c2b..045187e4e7 100644 --- a/packages/daemon/src/server/http/daemon-session-idle-expiry-scheduling.test.ts +++ b/packages/daemon/src/server/http/daemon-session-idle-expiry-scheduling.test.ts @@ -9,7 +9,13 @@ import { WINDOW_MS, } from '../__tests__/session-idle-expiry-harness.ts'; import { makeIosSession } from '../../__tests__/support/session-factories.ts'; -import { IOS_SIMULATOR } from '@agent-device/test-kit'; +import { + IOS_SIMULATOR, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; + +bindTestCommandHost(); /** * #2833: WHEN the reaper starts an expiry and how long it may take. The window that arms it, the @@ -44,6 +50,7 @@ test('a session still inside its window survives a sweep that actually ran', asy const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -71,6 +78,7 @@ test('the policy being off arms nothing at all, even for a long-dead session', a let settleCalls = 0; const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: 0, executionLocks: new Map(), @@ -100,6 +108,7 @@ test('a window longer than a timer can express does not sweep a session that is let sweeps = 0; const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: LONG_WINDOW_MS, executionLocks: new Map(), @@ -131,6 +140,7 @@ test('a window longer than a timer can express still expires once the window ela session.lastActivityAtMs = NOW; const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: LONG_WINDOW_MS, executionLocks: new Map(), @@ -166,6 +176,7 @@ test('an expiry waits for the session lock a command of its own holds', async () const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, @@ -207,6 +218,7 @@ test('an expiry waits for the DEVICE lock a command on another session holds', a const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, @@ -249,6 +261,7 @@ test('a command that re-stamped activity while the expiry queued cancels it', as let sweepsCompleted = 0; const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, @@ -297,6 +310,7 @@ test('a session that moved to another device is fenced by that device, not the s const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, @@ -355,6 +369,7 @@ test('a settle that outlives the budget is caught by the sweep rather than the p idleClaimedSession(sessionStore); const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -377,6 +392,7 @@ test('a sweep runs inside the composition diagnostics scope it is given', async const scopes: string[] = []; const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -411,6 +427,7 @@ test('a settle that outruns its wait still finishes the release it started', asy // A window short enough that the retry becomes reachable while the settle is still stuck. const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: 40, executionLocks: new Map(), @@ -472,6 +489,7 @@ test('a daemon beginning to leave does not start settling the next session', asy }); const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -512,6 +530,7 @@ test('an over-budget expiry keeps the locks the release itself needs', async () }); const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, @@ -562,6 +581,7 @@ test('an expiry that lands after its budget still reports the session it release const expired: string[] = []; const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -601,6 +621,7 @@ test('a stuck expiry does not leave the timer firing with no delay', async () => let clock = NOW; const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: 20, executionLocks: new Map(), @@ -661,6 +682,7 @@ test('a settle that outlives its window is retried a full window after it ends, let settledAt = 0; const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW, executionLocks: new Map(), diff --git a/packages/daemon/src/server/http/daemon-session-idle-expiry.test.ts b/packages/daemon/src/server/http/daemon-session-idle-expiry.test.ts index 17fc12a43e..70c07af109 100644 --- a/packages/daemon/src/server/http/daemon-session-idle-expiry.test.ts +++ b/packages/daemon/src/server/http/daemon-session-idle-expiry.test.ts @@ -1,4 +1,8 @@ -import { deviceClaimFilePath } from '@agent-device/test-kit'; +import { + deviceClaimFilePath, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -19,6 +23,8 @@ import { makeRepairCompleteSession, } from '../../__tests__/support/session-factories.ts'; +bindTestCommandHost(); + /** * #2833: what one expiry COMMITS, once it has run. Whether a session past its window is the kind that * may be expired at all, and once it is: its claim released, its record deleted, its repair @@ -47,6 +53,7 @@ test('a session past its window releases its claim, is deleted, and leaves a mar assert.equal(claimFileHeld(deviceClaim), true, 'the fixture stands a claim that is really held'); const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -82,6 +89,7 @@ test('a settle that cannot confirm the claim gone keeps the session record stand try { const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -121,6 +129,7 @@ test('a claim that yields no attributable owner keeps the session record standin const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -152,6 +161,7 @@ test('a settle that fails keeps the session and its claim, and retries on the ne const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -192,6 +202,7 @@ test('a session shutdown finalized mid-settle is not also reported as expired', const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -220,6 +231,7 @@ test('a session closed by its owner stops the retry clock tracking it', async () let settleCalls = 0; const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -262,6 +274,7 @@ test('a settled session of another kind is never touched by the sweep', async () const settledNames: string[] = []; const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -298,6 +311,7 @@ test('a held-back settle leaves a repair transaction uncommitted, so a later pas const claimsDir = path.dirname(deviceClaimFilePath(deviceClaim.deviceKey)); const sweeps = createSweepBarrier(); const settleParams = { + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -359,6 +373,7 @@ test('a committed expiry finalizes the repair transaction it ends', async () => const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -393,6 +408,7 @@ test('an idle settle cannot finalize or retire a replacement at its scoped addre }); let entered = false; const controller = createSessionIdleExpiry({ + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), diff --git a/packages/daemon/src/server/http/daemon-session-idle-expiry.ts b/packages/daemon/src/server/http/daemon-session-idle-expiry.ts index cb67444652..970158f3b1 100644 --- a/packages/daemon/src/server/http/daemon-session-idle-expiry.ts +++ b/packages/daemon/src/server/http/daemon-session-idle-expiry.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { clearDeviceClaim, @@ -75,6 +76,7 @@ export type SessionIdleExpiryController = Readonly<{ * in any other order would deadlock against a request's pair. */ export function createSessionIdleExpiry(params: { + env: HostEnvironment['env']; sessionStore: SessionStore; idleExpiryMs: number; executionLocks: Map>; @@ -215,6 +217,7 @@ export function createSessionIdleExpiry(params: { } type IdleExpirySweepParams = { + env: HostEnvironment['env']; sessionStore: SessionStore; idleExpiryMs: number; executionLocks: Map>; @@ -360,6 +363,7 @@ async function settleIdleSessionUnderLock( const atMs = params.now(); if (!isSessionIdleExpired(settled, params.idleExpiryMs, atMs)) return NOTHING_TO_RETRY; const outcome = await settleExpiredSession({ + env: params.env, ref: params.sessionStore.refresh(params.ref), idleExpiryMs: params.idleExpiryMs, expiredAtMs: atMs, @@ -452,6 +456,7 @@ function rememberRetry( * successor owns the device now, and never blocks the expiry. */ async function settleExpiredSession(params: { + env: HostEnvironment['env']; ref: SessionRef; idleExpiryMs: number; expiredAtMs: number; @@ -463,7 +468,7 @@ async function settleExpiredSession(params: { const deviceKey = session.deviceClaim?.deviceKey; const identity = { session: sessionName, idleExpiryMs, ...(deviceKey ? { deviceKey } : {}) }; if (!(await releaseExpiredSessionResources(params, identity))) return undefined; - const claim = await clearExpiredClaim(sessionName, session.deviceClaim); + const claim = await clearExpiredClaim(sessionName, session.deviceClaim, params.env); if (claim === CLAIM_CLEAR_FAILED || !CLAIM_GONE[claim]) { emitDiagnostic({ level: 'warn', @@ -543,10 +548,11 @@ async function releaseExpiredSessionResources( async function clearExpiredClaim( sessionName: string, ownership: SessionState['deviceClaim'], + env: HostEnvironment['env'], ): Promise { if (!ownership) return 'absent'; try { - return await clearDeviceClaim(ownership); + return await clearDeviceClaim(ownership, env); } catch (error) { emitDiagnostic({ level: 'warn', diff --git a/packages/daemon/src/server/http/daemon-shutdown-claims.test.ts b/packages/daemon/src/server/http/daemon-shutdown-claims.test.ts index 7f3cd9e54f..59e7fb02a4 100644 --- a/packages/daemon/src/server/http/daemon-shutdown-claims.test.ts +++ b/packages/daemon/src/server/http/daemon-shutdown-claims.test.ts @@ -1,4 +1,4 @@ -import { ANDROID_EMULATOR, deviceClaimFilePath } from '@agent-device/test-kit'; +import { ANDROID_EMULATOR, deviceClaimFilePath, bindTestCommandHost } from '@agent-device/test-kit'; import { afterEach, expect, test, vi } from 'vitest'; import { isolatedDeviceClaimStores, @@ -12,6 +12,8 @@ import { import { createDaemonShutdownClaimLedger } from './daemon-shutdown-claims.ts'; import type { SessionState } from '../../core/session-state.ts'; +bindTestCommandHost(); + afterEach(() => { vi.restoreAllMocks(); }); @@ -30,6 +32,7 @@ function claimRecord(session: SessionState, name: string) { async function claimedSession(name: string): Promise { const { stateDir } = setup(); const acquired = await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: name, workspace: stateDir, @@ -49,7 +52,7 @@ async function claimedSession(name: string): Promise { const session = await claimedSession('default'); - const ledger = createDaemonShutdownClaimLedger(); + const ledger = createDaemonShutdownClaimLedger(process.env); await ledger.releaseClaim(session); ledger.finalize(session); @@ -60,19 +63,21 @@ test('a claim cleared after clean teardown is reported released', async () => { superseded: [], unattributable: [], }); - expect(inspectDeviceClaims({})).toEqual([]); + expect(inspectDeviceClaims({}, process.env)).toEqual([]); }); test('a claim left behind by a failed teardown is reported orphaned', async () => { const session = await claimedSession('stuck'); - const ledger = createDaemonShutdownClaimLedger(); + const ledger = createDaemonShutdownClaimLedger(process.env); // Teardown never reached a safe terminal state, so `releaseClaim` never runs. ledger.finalize(session); expect(ledger.claims.released).toEqual([]); expect(ledger.claims.orphaned).toEqual([claimRecord(session, 'stuck')]); - expect(inspectDeviceClaims({}).map((entry) => entry.claim?.session)).toEqual(['stuck']); + expect(inspectDeviceClaims({}, process.env).map((entry) => entry.claim?.session)).toEqual([ + 'stuck', + ]); }); test('a claim replaced by a successor owner is reported superseded, never released', async () => { @@ -84,6 +89,7 @@ test('a claim replaced by a successor owner is reported superseded, never releas // deliberately leaves it alone, so "the call resolved" cannot mean "released". fs.rmSync(deviceClaimFilePath(deviceKey)); const successor = await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'successor', workspace: '/worktrees/successor', @@ -92,7 +98,7 @@ test('a claim replaced by a successor owner is reported superseded, never releas }); expect(successor.status).toBe('acquired'); - const ledger = createDaemonShutdownClaimLedger(); + const ledger = createDaemonShutdownClaimLedger(process.env); await ledger.releaseClaim(session); ledger.finalize(session); @@ -100,7 +106,9 @@ test('a claim replaced by a successor owner is reported superseded, never releas expect(ledger.claims.orphaned).toEqual([]); expect(ledger.claims.superseded).toEqual([claimRecord(session, 'replaced')]); // The successor keeps its device: teardown must never delete a foreign claim. - expect(inspectDeviceClaims({}).map((entry) => entry.claim?.session)).toEqual(['successor']); + expect(inspectDeviceClaims({}, process.env).map((entry) => entry.claim?.session)).toEqual([ + 'successor', + ]); }); test('an undecodable record is reported unattributable, never orphaned or superseded', async () => { @@ -113,7 +121,7 @@ test('an undecodable record is reported unattributable, never orphaned or supers // have. Claiming the bucket would send the operator to a command that refuses this record. fs.writeFileSync(deviceClaimFilePath(deviceKey), '{bad json'); - const ledger = createDaemonShutdownClaimLedger(); + const ledger = createDaemonShutdownClaimLedger(process.env); await ledger.releaseClaim(session); ledger.finalize(session); @@ -127,7 +135,7 @@ test('an undecodable record is reported unattributable, never orphaned or supers test('a claim clear that throws is reported orphaned, the one bucket with a working remedy', async () => { const session = await claimedSession('unrecorded'); - const ledger = createDaemonShutdownClaimLedger(); + const ledger = createDaemonShutdownClaimLedger(process.env); // The clear never returned a verdict at all. This is the state the `orphaned` advice is written // for: our own owner identity dies with the exiting daemon, which is exactly the proof // `device release --stale` needs, and the record still names us. @@ -148,7 +156,7 @@ test('a claim clear that throws is reported orphaned, the one bucket with a work }); test('a session that never held a claim contributes nothing', async () => { - const ledger = createDaemonShutdownClaimLedger(); + const ledger = createDaemonShutdownClaimLedger(process.env); const session: SessionState = { name: 'remote', device: ANDROID_EMULATOR, diff --git a/packages/daemon/src/server/http/daemon-shutdown-claims.ts b/packages/daemon/src/server/http/daemon-shutdown-claims.ts index ad3c74cc46..ca12d58f67 100644 --- a/packages/daemon/src/server/http/daemon-shutdown-claims.ts +++ b/packages/daemon/src/server/http/daemon-shutdown-claims.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { publicPlatformString } from '@agent-device/kernel/device'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { @@ -58,7 +59,9 @@ const CLEAR_UNRECORDED = 'clear-unrecorded'; * it. Nor is it `superseded`, which would assert a successor * this verdict explicitly refuses to assume. */ -export function createDaemonShutdownClaimLedger(): DaemonShutdownClaimLedger { +export function createDaemonShutdownClaimLedger( + env: HostEnvironment['env'], +): DaemonShutdownClaimLedger { const claims: DaemonShutdownClaims = { released: [], orphaned: [], @@ -71,7 +74,7 @@ export function createDaemonShutdownClaimLedger(): DaemonShutdownClaimLedger { releaseClaim: async (session) => { if (!session.deviceClaim) return; try { - outcomes.set(session.name, await clearDeviceClaim(session.deviceClaim)); + outcomes.set(session.name, await clearDeviceClaim(session.deviceClaim, env)); } catch (error) { outcomes.set(session.name, CLEAR_UNRECORDED); emitDiagnostic({ diff --git a/packages/daemon/src/server/request-execution-scope.ts b/packages/daemon/src/server/request-execution-scope.ts index bffe64b459..659724ead7 100644 --- a/packages/daemon/src/server/request-execution-scope.ts +++ b/packages/daemon/src/server/request-execution-scope.ts @@ -1,3 +1,4 @@ +import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; import { resolveSessionRequestLog, resolveSessionRunnerLogPath, @@ -120,6 +121,8 @@ const gatewayMissing = async (): Promise => { }; export type RequestExecutionScope = AsyncDisposable & { + /** The environment and working directory of the daemon serving this request. */ + host: HostEnvironment; req: DaemonRequest; command: string; sessionName: string; @@ -168,6 +171,7 @@ type LockedRequestScopeResult = | { type: 'response'; response: DaemonResponse }; export async function createRequestExecutionScope(params: { + host: HostEnvironment; req: DaemonRequest; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; @@ -259,10 +263,11 @@ export async function createRequestExecutionScope(params: { }); const dispatchLedger = requestDispatchLedger(scopedReq); const { claimAdmission, runtimeBindings } = createRequestDeviceAccess({ + env: params.host.env, command, sessionName, dispatchLedger, - workspace: scopedReq.meta?.cwd ?? process.cwd(), + workspace: scopedReq.meta?.cwd ?? params.host.cwd, callerCwd: scopedReq.meta?.cwd, stateDir: sessionStore.resolveDaemonStateDir(), deviceRuntimeGateway: params.deviceRuntimeGateway, @@ -272,6 +277,7 @@ export async function createRequestExecutionScope(params: { }); const scope: RequestExecutionScope = { + host: params.host, req: scopedReq, command, sessionName, @@ -290,6 +296,7 @@ export async function createRequestExecutionScope(params: { throwIfRequestCanceled(scopedReq.meta?.requestId); try { await cleanupExpiredLeasedSession({ + env: params.host.env, sessionName, sessionStore, leaseRegistry, @@ -303,6 +310,7 @@ export async function createRequestExecutionScope(params: { }), }); scopedReq = admitRequestLeaseForLockedScope({ + env: params.host.env, req: scopedReq, sessionName, sessionStore, @@ -395,6 +403,7 @@ export async function createRequestExecutionScope(params: { * stays out of the claim store. */ function createRequestDeviceAccess(params: { + env: EnvMap; command: string; sessionName: string; dispatchLedger: RequestDispatchLedger; @@ -421,6 +430,7 @@ function createRequestDeviceAccess(params: { callerCwd: params.callerCwd, resolveScopeRoot: resolveCallerScopeRoot, stateDir: params.stateDir, + env: params.env, reconcileOrphanedDeviceClaim: createOwnerScopedDeviceClaimReconciler({ scope: platformRequestScope, composeGateway: params.claimRecoveryGateway, @@ -662,6 +672,7 @@ export type LeaseSessionEnd = Readonly<{ }>; export function createLeaseSessionEnd(params: { + env: HostEnvironment['env']; sessionStore: SessionStore; executionLocks: Map>; teardownSession: SessionTeardown; @@ -687,6 +698,7 @@ export function createLeaseSessionEnd(params: { sessionStore, teardownSession, reason, + params.env, ); }, ), diff --git a/packages/daemon/src/server/request-router.ts b/packages/daemon/src/server/request-router.ts index 7f1cc96145..23f8df44a8 100644 --- a/packages/daemon/src/server/request-router.ts +++ b/packages/daemon/src/server/request-router.ts @@ -6,6 +6,7 @@ import { import { withResolveTargetDeviceCacheScope } from '@agent-device/device-selection/dispatch-resolve'; import { withDeviceInventoryContext } from '@agent-device/device-selection/device-inventory-context'; import type { LeaseLifecycleProvider, ProviderAppCatalog } from '@agent-device/contracts/device'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { ComposedDeviceInventoryGateways } from '@agent-device/contracts/platform-module'; import type { DeviceRuntimeGateway } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; @@ -15,7 +16,7 @@ import { retriableForErrorCode, type DaemonError, } from '@agent-device/kernel/errors'; -import { withRequestCommandEnv } from '@agent-device/host-kit/command'; +import { withCommandHost } from '@agent-device/host-kit/command'; import { timingSafeStringEqual } from '@agent-device/host-kit/transport'; import { type ResponseCost, @@ -106,6 +107,8 @@ import type { DaemonPolicy } from '@agent-device/daemon-contracts/daemon-policy- // --------------------------------------------------------------------------- export type RequestRouterDeps = { + /** The environment and working directory every request of this daemon runs with. */ + host: HostEnvironment; logPath: string; token: string; sessionStore: SessionStore; @@ -150,6 +153,7 @@ export type RequestRouterDeps = { export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { const { + host, logPath, token, requestPlatformProviders = EMPTY_REQUEST_PLATFORM_PROVIDERS, @@ -196,8 +200,8 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { logPath, }, async () => { - const response = await withRequestCommandEnv( - requestCommandEnv(req), + const response = await withCommandHost( + { host, requestEnv: requestCommandEnv(req) }, async () => await runRequestWithinScope(req), ); if (!response.ok) { @@ -248,6 +252,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { async () => await withResolveTargetDeviceCacheScope(async () => { scope = await createRequestExecutionScope({ + host, req, sessionStore, leaseRegistry, @@ -345,6 +350,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { const { dispatchLedger } = lockedScope; const requestScope = createPlatformRequestScope(lockedScope.req); const handlerResponse = await runRequestHandlerChain({ + host, req: lockedScope.req, sessionName: lockedScope.sessionName, logPath: lockedScope.logPath, @@ -416,6 +422,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { try { const scopedReq = bindReplayDeviceExecutionLock(req, parentScope); childScope = await createRequestExecutionScope({ + host, req: scopedReq, sessionStore, leaseRegistry, diff --git a/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts b/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts index ef2e3b8d2d..7e2a974c1b 100644 --- a/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts +++ b/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { test, expect, vi } from 'vitest'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { makeIosSession } from '../../__tests__/support/session-factories.ts'; @@ -12,6 +13,8 @@ import { } from '../lease-lifecycle.ts'; import type { DaemonRequest } from '../../core/daemon-request.ts'; +bindTestCommandHost(); + vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, emitDiagnostic: vi.fn() }; @@ -46,6 +49,7 @@ test('admitRequestLeaseForLockedScope heartbeats and stores admitted lease on th now = 2_000; const req = admitRequestLeaseForLockedScope({ + env: process.env, req: makeRequest({ command: 'snapshot' }), sessionName: 'default', sessionStore, @@ -82,6 +86,7 @@ test('cleanupExpiredLeasedSession consumes expired lease and deletes the session const teardownSession = vi.fn(async () => {}); const cleaned = await cleanupExpiredLeasedSession({ + env: process.env, sessionName: 'default', sessionStore, leaseRegistry, 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 6c9ac99b12..5a1309cc2c 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 @@ -8,7 +8,7 @@ import { platformResourceCleanupFixture } from '../../../__tests__/support/platf import { SessionStore } from '../../../core/session-store.ts'; import { makeIosSession } from '../../../__tests__/support/session-factories.ts'; import { maestroScriptSourceBundleFor } from '../../../__tests__/support/replay-script-source-fixture.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import type { DaemonRequest } from '../../../core/daemon-request.ts'; import { unavailableBindDevice, @@ -73,6 +73,7 @@ async function runWithNetworkFlag(publicNetworkOnly: boolean | undefined) { // the asserted path: dropping it in the handler must fail this test, since an // unset field reads as trusted and a remote flow would run evalScript. const response = await handleReplayCommand({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req, diff --git a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts index 661387946c..60d33377c8 100644 --- a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts @@ -20,7 +20,11 @@ import { storeSessionForTest } from '../../../__tests__/support/store-factory.ts * completely unchanged. */ import { test, expect, vi, beforeEach } from 'vitest'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; vi.mock('@agent-device/platform-apple/runner/operations', () => ({ resolveRunnerAppBundleId: vi.fn(), @@ -69,6 +73,8 @@ import { legacyDispatchCapture, } from '../../../__tests__/support/legacy-snapshot-capture-fixture.ts'; +bindTestCommandHost(); + const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -240,6 +246,7 @@ test('diagnostic get/is reads mid-repair are excluded from the healed script by // --- Finalize: `close --save-script` commits the healed `.ad`. --- const closeResponse = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -319,6 +326,7 @@ test("a --record'ed diagnostic read lands in the healed script (the diverged-ste expect(session.actions.map((a) => a.command)).toEqual(['open', 'get', 'click']); const closeResponse = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', diff --git a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts index 5527f8becf..751f8c68fa 100644 --- a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; import { storeSessionForTest } from '../../../__tests__/support/store-factory.ts'; /** @@ -53,6 +54,8 @@ import { setup, } from './session-replay-repair-transaction.fixtures.ts'; +bindTestCommandHost(); + const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); const mockLifecycleDispatch = vi.mocked(dispatchApplicationLifecycleEffect); @@ -107,6 +110,7 @@ test('BLOCKER 2 (new): a repair close whose PLATFORM close fails never commits a mockLifecycleDispatch.mockRejectedValueOnce(platformCloseError); const closeResponse = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -148,6 +152,7 @@ test('BLOCKER 2 (new): a repair close whose PLATFORM close fails never commits a // Retry once the platform close succeeds: commits cleanly. mockLifecycleDispatch.mockResolvedValueOnce(undefined); const retry = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -190,6 +195,7 @@ test('BLOCKER 3 (second follow-up): a retry after a SUCCESSFUL platform close bu const before = fs.readFileSync(healedPath, 'utf8'); const closeResponse = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -218,6 +224,7 @@ test('BLOCKER 3 (second follow-up): a retry after a SUCCESSFUL platform close bu // wedge recovery entirely) on a second close of an already-closed target. const retryPath = path.join(root, 'flow.promoted.ad'); const retry = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -289,6 +296,7 @@ test('BLOCKER 3 (third follow-up): an untargeted close that performed NO platfor // (no positional target, not `web`), so the platform close never dispatches // at all; the commit then fails (no-clobber). const first = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, command: 'close', positionals: [], flags: {} }, sessionName, @@ -301,6 +309,7 @@ test('BLOCKER 3 (third follow-up): an untargeted close that performed NO platfor expect(sessionStore.get(sessionName)).toBeDefined(); const retry = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -333,6 +342,7 @@ test('BLOCKER 3 (third follow-up): a retry targeting a DIFFERENT app than the su // First attempt targets app-a; the platform close succeeds, the commit fails. const first = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -355,6 +365,7 @@ test('BLOCKER 3 (third follow-up): a retry targeting a DIFFERENT app than the su // already closed just because SOME close succeeded. It must dispatch again, // against app-b specifically. const retry = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', diff --git a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts index be06a9a2c4..4ef1674a09 100644 --- a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; import { storeSessionForTest } from '../../../__tests__/support/store-factory.ts'; /** @@ -67,6 +68,8 @@ import { setup, } from './session-replay-repair-transaction.fixtures.ts'; +bindTestCommandHost(); + const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); const mockLifecycleDispatch = vi.mocked(dispatchApplicationLifecycleEffect); @@ -117,6 +120,7 @@ test('#1258: close --save-script --force overwrites an existing COMPLETE healed ); const closeResponse = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -370,6 +374,7 @@ test('#1258 preflight is per-target: a --from continuation RETARGETING to an exi // (never the rejected ) — proof the rejected retarget corrupted neither // the completion flag nor the target path. const closeResponse = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, command: 'close', positionals: [], flags: {} }, sessionName, @@ -410,6 +415,7 @@ test('#1258 force is per-target: re-arming --save-script= WITHOUT --force dro // `close --save-script=` (NO --force): retargeting from to // without a live opt-in drops the force that was granted for . const closeResponse = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -454,6 +460,7 @@ test('#1258 force per-target, contrast: re-arming --save-script= WITH --force // `close --save-script= --force`: the live opt-in re-grants force for the // new target, overwriting it. const closeResponse = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', diff --git a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.test.ts b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.test.ts index 09e18bb822..86afd44030 100644 --- a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; import { storeSessionForTest } from '../../../__tests__/support/store-factory.ts'; /** @@ -73,6 +74,8 @@ import { setup, } from './session-replay-repair-transaction.fixtures.ts'; +bindTestCommandHost(); + const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); const mockLifecycleDispatch = vi.mocked(dispatchApplicationLifecycleEffect); @@ -186,6 +189,7 @@ test('end-to-end repair transaction: cold divergence stays alive, corrective res // --- The agent finalizes: `close --save-script` (the real handler, not a // direct writer call) commits the now-COMPLETE healed `.ad`. --- const closeResponse = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -251,6 +255,7 @@ test('end-to-end repair transaction: cold divergence stays alive, corrective res // The agent walks away: a plain `close` (no --save-script) reaches the // still repair-armed session — Fix 1/2's "abort/discard", not a commit. const abandonedCloseResponse = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -432,6 +437,7 @@ test('BLOCKER 2b/2c: a close whose commit FAILS (no-clobber) keeps the session f const before = fs.readFileSync(path.join(root, 'flow.healed.ad'), 'utf8'); const closeResponse = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, command: 'close', positionals: [], flags: {} }, sessionName, @@ -466,6 +472,7 @@ test('BLOCKER 2b/2c: a close whose commit FAILS (no-clobber) keeps the session f // Retry with an explicit path commits cleanly — exactly ONE terminal close. const retryPath = path.join(root, 'flow.promoted.ad'); const retry = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', diff --git a/packages/daemon/src/sessions/__tests__/replay-suite/session-test-fail-fast.test.ts b/packages/daemon/src/sessions/__tests__/replay-suite/session-test-fail-fast.test.ts index 363cffc890..e98c7f5d2f 100644 --- a/packages/daemon/src/sessions/__tests__/replay-suite/session-test-fail-fast.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-suite/session-test-fail-fast.test.ts @@ -6,7 +6,7 @@ import { expect, test } from 'vitest'; import { makeSessionStore } from '../../../__tests__/support/store-factory.ts'; import { handleSessionCommands } from '../../../__tests__/support/handlers/session-command-harness.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; test('test --fail-fast continues after passing scripts', async () => { @@ -16,6 +16,7 @@ test('test --fail-fast continues after passing scripts', async () => { const invokedPaths: string[] = []; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/__tests__/replay-suite/session-test-runner.test.ts b/packages/daemon/src/sessions/__tests__/replay-suite/session-test-runner.test.ts index 4354b06119..39f852f07a 100644 --- a/packages/daemon/src/sessions/__tests__/replay-suite/session-test-runner.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-suite/session-test-runner.test.ts @@ -13,9 +13,15 @@ import { import type { DaemonRequest } from '../../../core/daemon-request.ts'; import { handleSessionCommands } from '../../../__tests__/support/handlers/session-command-harness.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; +bindTestCommandHost(); + // Opening a Simulator schedules a best-effort runner prewarm that outlives the request; a real one // would spawn xcodebuild after this file finished and land in whichever file the worker runs next. vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { @@ -68,6 +74,7 @@ test('session_list includes device_udid and ios_simulator_device_set for iOS ses ); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', command: 'session_list', positionals: [] }, @@ -105,6 +112,7 @@ test('test filters replay scripts by context platform and skips untyped files', const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -177,6 +185,7 @@ test('test binds each replay script to its declared platform metadata', async () let response; try { response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -217,6 +226,7 @@ test('test cleans up suite-owned sessions after each executed script', async () fs.writeFileSync(path.join(root, '01-android.ad'), 'context platform=android\nopen "Demo"\n'); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -258,6 +268,7 @@ test('test retries failed scripts with fresh suite-owned sessions', async () => const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -313,6 +324,7 @@ test('test applies per-script timeout and writes attempt artifacts', async () => let invocationCount = 0; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -382,6 +394,7 @@ test('open does not retain a session when the request was canceled before comple markRequestCanceled(requestId); try { const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -415,6 +428,7 @@ test('test returns invalid args when no replay scripts match the platform filter fs.writeFileSync(path.join(root, '01-ios.ad'), 'context platform=ios\nopen "Settings"\n'); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -446,6 +460,7 @@ test('test rejects duplicate replay test metadata in the context header', async ); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/__tests__/replay-suite/session-test-suite-infrastructure.test.ts b/packages/daemon/src/sessions/__tests__/replay-suite/session-test-suite-infrastructure.test.ts index 35833817c9..14e8034435 100644 --- a/packages/daemon/src/sessions/__tests__/replay-suite/session-test-suite-infrastructure.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-suite/session-test-suite-infrastructure.test.ts @@ -7,7 +7,7 @@ import path from 'node:path'; import { handleSessionCommands } from '../../../__tests__/support/handlers/session-command-harness.ts'; import { expectOkData, makeSessionStore } from './session-test-suite.fixtures.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; vi.mock('../../../core/snapshot-interactor-capture.ts', () => ({ @@ -23,6 +23,7 @@ test('test --json marks a typed live device claim as infrastructure without retr let attempts = 0; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -72,6 +73,7 @@ test('test --json retries DEVICE_IN_USE without typed device-claim provenance', let attempts = 0; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/__tests__/replay-suite/session-test-suite.test.ts b/packages/daemon/src/sessions/__tests__/replay-suite/session-test-suite.test.ts index 94844e9693..c749f66e79 100644 --- a/packages/daemon/src/sessions/__tests__/replay-suite/session-test-suite.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-suite/session-test-suite.test.ts @@ -38,9 +38,15 @@ import { makeAndroidSession, makeMacOsSession, } from '../../../__tests__/support/session-factories.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { storeSessionForTest } from '../../../__tests__/support/store-factory.ts'; +bindTestCommandHost(); + const ANDROID_ONE: DeviceInfo = { platform: 'android', id: 'emulator-5554', @@ -65,6 +71,7 @@ test('test does not retry infrastructure startup failures and stops the suite', const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -114,6 +121,7 @@ test('test --fail-fast stops the suite after the first failure and leaves the re const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -162,6 +170,7 @@ test('test surfaces a suite-level failure when a source fails to parse', async ( const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -198,6 +207,7 @@ test('test discovers Maestro YAML suites when replay backend is set', async () = const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -235,6 +245,7 @@ test('test emits progress when attempts retry and pass', async () => { (event) => events.push(event), async () => await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -344,6 +355,7 @@ test('test stops before retrying when a rejected close leaves the prior macOS se let replayAttempts = 0; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -397,6 +409,7 @@ test('test stops retrying after maxAttempts when every attempt fails', async () let attemptCount = 0; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -442,6 +455,7 @@ test('test emits skip progress without synthetic duration', async () => { (event) => events.push(event), async () => await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -496,6 +510,7 @@ test('test aggregates snapshot diagnostics from replay session samples', async ( let captures = 0; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -556,6 +571,7 @@ test('test aggregates snapshot diagnostics from failed replay session samples', fs.writeFileSync(path.join(root, '01-fail.ad'), 'context platform=android\nopen "Demo"\n'); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -630,6 +646,7 @@ test('test stops the suite when the parent request is canceled during an active (event) => events.push(event), async () => await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -697,6 +714,7 @@ test('test --shard-all runs each runnable entry on each selected device', async async () => [ANDROID_ONE, ANDROID_TWO], async () => await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -774,6 +792,7 @@ test('test --shard-split distributes runnable entries by modulo and keeps skips async () => [ANDROID_TWO, ANDROID_ONE], async () => await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -824,6 +843,7 @@ test('test sharding rejects mutually exclusive shard modes', async () => { fs.writeFileSync(path.join(root, '01-a.ad'), 'context platform=android\nopen "Demo"\n'); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -852,6 +872,7 @@ test('test sharding rejects non-positive shard counts', async () => { fs.writeFileSync(path.join(root, '01-a.ad'), 'context platform=android\nopen "Demo"\n'); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -883,6 +904,7 @@ test('test sharding rejects fewer matched devices than requested shards', async async () => [ANDROID_ONE], async () => await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -919,6 +941,7 @@ test('test sharding does not require devices when every entry is skipped', async }, async () => await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/__tests__/request-admission.test.ts b/packages/daemon/src/sessions/__tests__/request-admission.test.ts index 0b92d7116b..f640f1f1a9 100644 --- a/packages/daemon/src/sessions/__tests__/request-admission.test.ts +++ b/packages/daemon/src/sessions/__tests__/request-admission.test.ts @@ -36,6 +36,7 @@ test('close on a tenant-isolated session with no lease ever allocated admits wit }), registry, undefined, + { env: process.env }, ); assert.equal(result, undefined); @@ -60,6 +61,7 @@ test('close on a lease-less but stored tenant-isolated session still requires a }), registry, session, + { env: process.env }, ), /tenant isolation requires lease id/, ); @@ -83,6 +85,7 @@ test('close with an app target still requires a lease id even with no lease anyw }), registry, undefined, + { env: process.env }, ), /tenant isolation requires lease id/, ); @@ -105,6 +108,7 @@ test('close with an explicit lease id but no session lease still requires an act }), registry, undefined, + { env: process.env }, ), /Lease is not active/, ); @@ -122,6 +126,7 @@ test('non-close commands on a tenant-isolated session still require a lease id', }), registry, undefined, + { env: process.env }, ), /tenant isolation requires lease id/, ); @@ -142,6 +147,7 @@ test.each(['bogus', 'proxy', 'browserstack'])( }), registry, undefined, + { env: process.env }, ), /tenant isolation requires lease id/, ); @@ -159,7 +165,7 @@ test('sessionless apps admits a provider declared by the runtime app catalog', ( }), registry, undefined, - { providerAppCatalog: limrunAppCatalog }, + { env: process.env, providerAppCatalog: limrunAppCatalog }, ); assert.equal(result, undefined); @@ -197,6 +203,7 @@ test('admitting a command does not shorten a proxy lease allocated above the old }), registry, undefined, + { env: process.env }, ); assert.equal(result?.leaseId, lease.leaseId); @@ -239,6 +246,7 @@ test('a command that names its own window renews the lease onto it', () => { }), registry, undefined, + { env: process.env }, ); assert.equal(result!.expiresAt - result!.heartbeatAt, 90_000); @@ -266,6 +274,7 @@ test('close still admits and heartbeats a real active lease', () => { }), registry, session, + { env: process.env }, ); assert.equal(result?.leaseId, lease.leaseId); @@ -305,6 +314,7 @@ test('a request under a macos-app lease is confined to the leased app', () => { makeRequest({ command: 'snapshot', meta: leaseMeta(lease) }), registry, session, + { env: process.env }, )?.leaseId, lease.leaseId, ); @@ -314,6 +324,7 @@ test('a request under a macos-app lease is confined to the leased app', () => { makeRequest({ command: 'snapshot', flags: { surface: 'desktop' }, meta: leaseMeta(lease) }), registry, session, + { env: process.env }, ), { code: 'UNAUTHORIZED' }, ); @@ -329,6 +340,7 @@ test('a request under a macos-app lease that names no existing session is refuse makeRequest({ command, flags: { platform: 'macos' }, meta: leaseMeta(lease) }), registry, undefined, + { env: process.env }, ), (error: { code?: string; details?: Record }) => error.code === 'UNAUTHORIZED' && @@ -352,7 +364,7 @@ test('a daemon policy that requires a macos-app lease refuses unleased and other makeRequest({ command: 'open', positionals: ['com.apple.finder'] }), registry, undefined, - { daemonPolicy: policy }, + { env: process.env, daemonPolicy: policy }, ), denied, ); @@ -375,7 +387,7 @@ test('a daemon policy that requires a macos-app lease refuses unleased and other }), registry, undefined, - { daemonPolicy: policy }, + { env: process.env, daemonPolicy: policy }, ), denied, ); @@ -385,7 +397,7 @@ test('a daemon policy that requires a macos-app lease refuses unleased and other makeRequest({ command: 'snapshot', meta: leaseMeta(lease) }), registry, makeMacOsSession('default', { appBundleId: 'com.example.app' }), - { daemonPolicy: policy }, + { env: process.env, daemonPolicy: policy }, )?.leaseId, lease.leaseId, ); diff --git a/packages/daemon/src/sessions/__tests__/request-handler-catalog.test.ts b/packages/daemon/src/sessions/__tests__/request-handler-catalog.test.ts index dbea6af489..91f7ead218 100644 --- a/packages/daemon/src/sessions/__tests__/request-handler-catalog.test.ts +++ b/packages/daemon/src/sessions/__tests__/request-handler-catalog.test.ts @@ -32,7 +32,7 @@ import { unavailableInspectFacts, } from '../../__tests__/support/unavailable-device-runtime-gateway.ts'; import type { DaemonRequest } from '../../core/daemon-request.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { createAudioProbeAdmissionLedger } from '@agent-device/capture-kit/audio-probe-admission-ledger'; import { createPerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; @@ -116,6 +116,7 @@ test('lease handler executes commands owned by the lease route', async () => { for (const command of leaseCommands) { const response = await handleLeaseCommands({ + host: createTestHostEnvironment(), req: { command, token: 'test-token', @@ -140,6 +141,7 @@ test('lease handler preserves device-aware lease fields', async () => { const leaseRegistry = new LeaseRegistry(); const sessionStore = makeSessionStore('agent-device-lease-fields-'); const allocateResponse = await handleLeaseCommands({ + host: createTestHostEnvironment(), req: { command: INTERNAL_COMMANDS.leaseAllocate, token: 'test-token', @@ -167,6 +169,7 @@ test('lease handler preserves device-aware lease fields', async () => { assert.equal(allocateLease.leaseProvider, 'proxy'); const heartbeatResponse = await handleLeaseCommands({ + host: createTestHostEnvironment(), req: { command: INTERNAL_COMMANDS.leaseHeartbeat, token: 'test-token', @@ -201,6 +204,7 @@ test('lease artifacts lists daemon inventory for proxy lease scopes', async () = try { const response = await handleLeaseCommands({ + host: createTestHostEnvironment(), req: proxyArtifactsRequest(), sessionName: 'catalog-test', sessionStore, @@ -219,6 +223,7 @@ test('lease release calls provider hook using the released lease without heartbe const sessionStore = makeSessionStore('agent-device-lease-release-'); const releaseCalls: string[] = []; const allocateResponse = await handleLeaseCommands({ + host: createTestHostEnvironment(), req: { command: INTERNAL_COMMANDS.leaseAllocate, token: 'test-token', @@ -241,6 +246,7 @@ test('lease release calls provider hook using the released lease without heartbe const lease = readLeaseResponse(allocateResponse); const releaseResponse = await handleLeaseCommands({ + host: createTestHostEnvironment(), req: { command: INTERNAL_COMMANDS.leaseRelease, token: 'test-token', @@ -299,6 +305,7 @@ test('lease allocation hands the provider the request-bound signal and a deadlin try { const response = await handleLeaseCommands({ + host: createTestHostEnvironment(), req: { command: INTERNAL_COMMANDS.leaseAllocate, token: 'test-token', @@ -361,6 +368,7 @@ test('a lease allocated for a requester that left is released, not registered', await assert.rejects( () => handleLeaseCommands({ + host: createTestHostEnvironment(), req: leaseAllocateRequest(requestId), sessionName: 'catalog-test', sessionStore, @@ -410,6 +418,7 @@ test('a failed provider release after cancellation is reported as unreleased wit await assert.rejects( () => handleLeaseCommands({ + host: createTestHostEnvironment(), req: leaseAllocateRequest(requestId), sessionName: 'catalog-test', sessionStore, @@ -457,6 +466,7 @@ test('a throwing provider release after cancellation is reported as unreleased, await assert.rejects( () => handleLeaseCommands({ + host: createTestHostEnvironment(), req: leaseAllocateRequest(requestId), sessionName: 'catalog-test', sessionStore, @@ -521,6 +531,7 @@ async function runCatalogCommandThroughHandlerChain( async () => [], async () => await runRequestHandlerChain({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req, diff --git a/packages/daemon/src/sessions/__tests__/request-handler-chain-provider-scope.test.ts b/packages/daemon/src/sessions/__tests__/request-handler-chain-provider-scope.test.ts index 92c239f223..3947bb7d10 100644 --- a/packages/daemon/src/sessions/__tests__/request-handler-chain-provider-scope.test.ts +++ b/packages/daemon/src/sessions/__tests__/request-handler-chain-provider-scope.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts'; // Proves the exact androidAdbExecutor reference reaches the session handler // through the neutral providerScope, and that an empty scope forwards none. @@ -77,6 +78,7 @@ test('the android adb executor from the generic provider scope reaches the sessi }); await runRequestHandlerChain({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), ...baseChainParams('provider-scope-test'), @@ -94,6 +96,7 @@ test('an empty provider scope forwards no android adb executor to the session ha handleSessionCommandsMock.mockClear(); await runRequestHandlerChain({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), ...baseChainParams('provider-scope-empty'), diff --git a/packages/daemon/src/sessions/__tests__/request-recording-health.test.ts b/packages/daemon/src/sessions/__tests__/request-recording-health.test.ts index afbff6ea41..013b015334 100644 --- a/packages/daemon/src/sessions/__tests__/request-recording-health.test.ts +++ b/packages/daemon/src/sessions/__tests__/request-recording-health.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture, daemonClaimRecoveryGatewayFixture, @@ -199,6 +200,7 @@ test.each(['rebuild', 'retire'] as const)( }), ); await using scope = await createRequestExecutionScope({ + host: createTestHostEnvironment(), req: { token: 'token', session: 'default', command: 'snapshot', positionals: [] }, sessionStore: store, leaseRegistry: new LeaseRegistry(), diff --git a/packages/daemon/src/sessions/__tests__/session-idle-activity.test.ts b/packages/daemon/src/sessions/__tests__/session-idle-activity.test.ts index 7b57609208..1c4fc28aee 100644 --- a/packages/daemon/src/sessions/__tests__/session-idle-activity.test.ts +++ b/packages/daemon/src/sessions/__tests__/session-idle-activity.test.ts @@ -30,7 +30,7 @@ import { } from '../../server/request-execution-scope.ts'; import { withKeyedLock } from '@agent-device/kernel/keyed-lock'; import type { DaemonRequest } from '../../core/daemon-request.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; const TEST_ROOT = mkdtempForTestSync('agent-device-session-idle-activity-'); const LOG_PATH = path.join(TEST_ROOT, 'diagnostics.log'); @@ -59,6 +59,7 @@ test('a command that completes under the session lock stamps the session it ran const sessionStore = storeWithSession(); const scope = await withDiagnosticsScope({ command: 'snapshot', logPath: LOG_PATH }, () => createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: makeRequest({ command: 'snapshot' }), @@ -76,6 +77,7 @@ test('a lock-exempt inventory command reports no activity, even against a claim- const sessionStore = storeWithSession(); const scope = await withDiagnosticsScope({ command: 'devices', logPath: LOG_PATH }, () => createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: makeRequest({ command: 'devices' }), @@ -98,6 +100,7 @@ test('a command whose client hung up mid-flight preserves no activity', async () { command: 'snapshot', requestId, logPath: LOG_PATH }, () => createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: makeRequest({ command: 'snapshot', meta: { requestId } }), @@ -127,6 +130,7 @@ test('a command still running when the expiry arrives blocks it until the stamp const locks = getLeaseRegistryExecutionLocks(leaseRegistry); const scope = await withDiagnosticsScope({ command: 'snapshot', logPath: LOG_PATH }, () => createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: makeRequest({ command: 'snapshot' }), @@ -179,6 +183,7 @@ test('the reaper never observes a mid-command session through the shared map', a const leaseRegistry = new LeaseRegistry(); const scope = await withDiagnosticsScope({ command: 'snapshot', logPath: LOG_PATH }, () => createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: makeRequest({ command: 'snapshot' }), diff --git a/packages/daemon/src/sessions/__tests__/snapshot-custom-actions-platform-guard.test.ts b/packages/daemon/src/sessions/__tests__/snapshot-custom-actions-platform-guard.test.ts index 926757915d..bbcdeb18e2 100644 --- a/packages/daemon/src/sessions/__tests__/snapshot-custom-actions-platform-guard.test.ts +++ b/packages/daemon/src/sessions/__tests__/snapshot-custom-actions-platform-guard.test.ts @@ -21,7 +21,7 @@ import { captureSnapshotUse, } from '@agent-device/contracts/platform-runtime-operations'; import { snapshotRuntimeFixture } from '../../__tests__/support/snapshot-runtime-fixture.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; function snapshotDeviceRuntimeGateway(): DeviceRuntimeGateway { @@ -51,6 +51,7 @@ function handlerForDevice(device: DeviceInfo) { appBundleId: 'com.example.app', } as SessionState); return createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/packages/daemon/src/sessions/handlers/__tests__/human-control.test.ts b/packages/daemon/src/sessions/handlers/__tests__/human-control.test.ts index 2fc4341818..da95e14cb6 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/human-control.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/human-control.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { daemonPlatformServicesFixture, @@ -35,6 +36,7 @@ test('lease-owner takeover uses the production admission gate, preserves the ses assert.equal(release.ok, true); assert.deepEqual(registry.listHumanControlHolds({ kind: 'host' }), []); const scope = await createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: humanControlRequest(lease, 'click', []), @@ -89,6 +91,7 @@ test('a fresh-session mutation without an advisory device lock still drains befo const { registry, lease, sessionStore } = createHumanControlHarness(); const req = { ...humanControlRequest(lease, 'click', []), session: 'fresh-session' }; const scope = await createRequestExecutionScope({ + host: createTestHostEnvironment(), req, sessionStore, leaseRegistry: registry, @@ -111,6 +114,7 @@ test('a fresh-session mutation without an advisory device lock still drains befo await Promise.resolve(); assert.equal(active, false); const later = await createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: { ...req, session: 'another-fresh-session' }, @@ -131,6 +135,7 @@ test('a nested mutation is stopped when takeover begins during its parent reques const { registry, lease, sessionStore } = createHumanControlHarness(); const req = humanControlRequest(lease, 'replay', []); const scope = await createRequestExecutionScope({ + host: createTestHostEnvironment(), req, sessionStore, leaseRegistry: registry, @@ -143,6 +148,7 @@ test('a nested mutation is stopped when takeover begins during its parent reques scope: HUMAN_CONTROL_SCOPE, }); const nested = await createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: humanControlRequest(lease, 'click', []), diff --git a/packages/daemon/src/sessions/handlers/__tests__/lease-artifacts.test.ts b/packages/daemon/src/sessions/handlers/__tests__/lease-artifacts.test.ts index 563321103e..2984041223 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/lease-artifacts.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/lease-artifacts.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import assert from 'node:assert/strict'; import { test } from 'vitest'; @@ -161,6 +162,7 @@ test('artifacts refuses a provider session returned after a canceled allocation try { await assert.rejects( handleLeaseCommands({ + host: createTestHostEnvironment(), req: leaseRequest('lease_allocate', { requestId, tenantId: 'tenant-a', @@ -245,6 +247,7 @@ function createWorld( async function allocateLease(world: World, tenantId: string, runId: string): Promise { const response = await handleLeaseCommands({ + host: createTestHostEnvironment(), req: leaseRequest('lease_allocate', { tenantId, runId, @@ -264,6 +267,7 @@ async function allocateLease(world: World, tenantId: string, runId: string): Pro async function releaseLease(world: World, lease: DeviceLease): Promise { const response = await handleLeaseCommands({ + host: createTestHostEnvironment(), req: leaseRequest('lease_release', { tenantId: lease.tenantId, runId: lease.runId, @@ -290,6 +294,7 @@ async function listArtifacts( }, ): Promise { return (await handleLeaseCommands({ + host: createTestHostEnvironment(), req: leaseRequest( 'artifacts', { diff --git a/packages/daemon/src/sessions/handlers/__tests__/lease.test.ts b/packages/daemon/src/sessions/handlers/__tests__/lease.test.ts index cc48cd05dc..297805b97e 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/lease.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/lease.test.ts @@ -8,7 +8,7 @@ import { makeSessionStore } from '../../../__tests__/support/store-factory.ts'; import type { DeviceLease } from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { withTestDeviceInventory } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { IOS_SIMULATOR, TVOS_SIMULATOR } from '@agent-device/test-kit'; +import { IOS_SIMULATOR, TVOS_SIMULATOR, createTestHostEnvironment } from '@agent-device/test-kit'; import { AppError } from '@agent-device/kernel/errors'; import { clearRequestCanceled, @@ -30,6 +30,7 @@ for (const operation of ['allocate', 'release'] as const) { const finish = createControlLatch(); const request = humanControlRequest(lease, `lease_${operation}`, []); const mutation = handleLeaseCommands({ + host: createTestHostEnvironment(), req: request, sessionName: request.session, sessionStore: makeSessionStore('agent-device-held-provider-'), @@ -62,6 +63,7 @@ test('lease_allocate answers with the lease id the request named', async () => { const registry = new LeaseRegistry(); const leaseId = 'abcdef0123456789abcdef0123456789'; const response = await handleLeaseCommands({ + host: createTestHostEnvironment(), req: { token: 'test-token', session: 'default', @@ -96,6 +98,7 @@ test('lease_release still releases a retainOnClose lease through the provider', }; const response = await handleLeaseCommands({ + host: createTestHostEnvironment(), req: request, sessionName: 'default', sessionStore: makeSessionStore('agent-device-retained-release-'), @@ -122,6 +125,7 @@ test('lease_release ends the sessions opened under the released lease', async () const ended: string[] = []; const release = (leaseId: string) => handleLeaseCommands({ + host: createTestHostEnvironment(), req: { token: 'test-token', session: 'default', @@ -164,6 +168,7 @@ test('lease_release refuses a host-allocated macos-app lease and keeps it', asyn }; const run = (command: 'lease_release' | 'lease_heartbeat') => handleLeaseCommands({ + host: createTestHostEnvironment(), req: { token: 'test-token', session: 'default', command, positionals: [], meta }, sessionName: 'default', sessionStore: makeSessionStore('agent-device-macos-app-release-'), @@ -197,6 +202,7 @@ test('lease_allocate stores retainOnClose from the request meta', async () => { }; const response = await handleLeaseCommands({ + host: createTestHostEnvironment(), req: request, sessionName: 'default', sessionStore: makeSessionStore('agent-device-retained-allocate-'), @@ -217,6 +223,7 @@ test('activation drains canceled provider allocation and its release cleanup', a request.meta = { ...request.meta, requestId }; let providerReleased = false; const mutation = handleLeaseCommands({ + host: createTestHostEnvironment(), req: request, sessionName: request.session, sessionStore: makeSessionStore('agent-device-held-canceled-provider-'), @@ -277,6 +284,7 @@ test('a lease whose provider allocation outlasts its TTL is active when allocati let now = 0; const registry = new LeaseRegistry({ now: () => now, defaultLeaseTtlMs: 60_000 }); const response = await handleLeaseCommands({ + host: createTestHostEnvironment(), req: allocateRequest(), sessionName: 'lease-ttl-test', sessionStore: makeSessionStore('agent-device-slow-provider-'), @@ -319,6 +327,7 @@ test('a lease allocated without a provider keeps the TTL it was created with', a defaultLeaseTtlMs: 60_000, }); const response = await handleLeaseCommands({ + host: createTestHostEnvironment(), req: allocateRequest(), sessionName: 'lease-ttl-test', sessionStore: makeSessionStore('agent-device-no-provider-'), @@ -341,6 +350,7 @@ test("a refused repeat allocation keeps the run's live lease", async () => { let calls = 0; const allocate = async (req: DaemonRequest) => await handleLeaseCommands({ + host: createTestHostEnvironment(), req, sessionName: 'lease-ttl-test', sessionStore, @@ -387,6 +397,7 @@ test('a repeat allocation canceled by its requester releases the reused lease', let calls = 0; const allocate = async (req: DaemonRequest) => await handleLeaseCommands({ + host: createTestHostEnvironment(), req, sessionName: 'lease-ttl-test', sessionStore, @@ -443,6 +454,7 @@ test('a tenant cannot allocate a macos-app lease', async () => { }; await assert.rejects( handleLeaseCommands({ + host: createTestHostEnvironment(), req: request, sessionName: request.session, sessionStore: makeSessionStore('agent-device-macos-app-allocate-'), @@ -496,6 +508,7 @@ async function allocateAgainstInventory( }, async () => await handleLeaseCommands({ + host: createTestHostEnvironment(), req: request, sessionName: request.session, sessionStore: makeSessionStore('agent-device-proxy-allocate-by-spec-'), @@ -593,6 +606,34 @@ test("a proxy allocation naming another owner's lease id still picks and is refu ); }); +test('a lease request that names no cwd gives the provider the bound host cwd', async () => { + const registry = new LeaseRegistry(); + const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST); + const seen: Array = []; + const respond = async (cwd: string | undefined) => { + const request = humanControlRequest(lease, 'lease_heartbeat', []); + if (cwd) request.meta = { ...request.meta, cwd }; + await handleLeaseCommands({ + host: { env: {}, cwd: '/bound/host/cwd' }, + req: request, + sessionName: request.session, + sessionStore: makeSessionStore('agent-device-lease-cwd-'), + leaseRegistry: registry, + leaseLifecycleProvider: { + heartbeat: async (_lease, context) => { + seen.push(context?.cwd); + return {}; + }, + }, + }); + }; + + await respond(undefined); + await respond('/client/sent/cwd'); + + assert.deepEqual(seen, ['/bound/host/cwd', '/client/sent/cwd']); +}); + const RUNTIME_LESS_LEASES: readonly (readonly [string, Record])[] = [ ['no provider', {}], ['a backend and no provider', { leaseBackend: 'ios-instance' }], @@ -604,6 +645,7 @@ for (const [label, scope] of RUNTIME_LESS_LEASES) { test(`lease_allocate keeps a runtime-less logical lease with ${label}`, async () => { const registry = new LeaseRegistry(); const response = await handleLeaseCommands({ + host: createTestHostEnvironment(), req: { token: 'test-token', session: 'default', @@ -625,6 +667,7 @@ test('lease_allocate refuses a provider no registered runtime claims, before rec const registry = new LeaseRegistry(); await assert.rejects( handleLeaseCommands({ + host: createTestHostEnvironment(), req: { token: 'test-token', session: 'default', @@ -661,6 +704,7 @@ test('lease_allocate names the plugin that failed to register when it could have }); await assert.rejects( handleLeaseCommands({ + host: createTestHostEnvironment(), req: { token: 'test-token', session: 'default', @@ -705,6 +749,7 @@ test('lease_allocate names the plugin that failed to register when it could have test('lease_allocate treats a daemon that reports no provider runtimes as having none registered', async () => { await assert.rejects( handleLeaseCommands({ + host: createTestHostEnvironment(), req: { token: 'test-token', session: 'default', @@ -730,6 +775,7 @@ test('lease_allocate treats a daemon that reports no provider runtimes as having test('lease_allocate classifies the trimmed provider id and rejects a malformed one as INVALID_ARGS', async () => { const allocate = async (leaseProvider: string, providerRuntimeIds: string[]) => await handleLeaseCommands({ + host: createTestHostEnvironment(), req: { token: 'test-token', session: 'default', diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-appstate-device-runtime.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-appstate-device-runtime.test.ts index 21bc8ffffe..8936f90d2f 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-appstate-device-runtime.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-appstate-device-runtime.test.ts @@ -4,7 +4,11 @@ import { test, expect, vi } from 'vitest'; import { handleAppStateCommand } from '../session-appstate.ts'; import { makeSessionStore } from '../../../__tests__/support/store-factory.ts'; import { withTestDeviceInventory } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '@agent-device/test-kit'; +import { + unavailableDeploymentSnapshotAndShutdownOperationFacts, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; import { type DeviceBinding, @@ -20,6 +24,8 @@ import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/p import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; import type { BindDeviceRuntime } from '../../../core/request-runtime-binding.ts'; +bindTestCommandHost(); + test('appstate rejects web before Android app-state backend dispatch', async () => { const device = { platform: 'web' as const, @@ -40,6 +46,7 @@ test('appstate rejects web before Android app-state backend dispatch', async () }, async () => await handleAppStateCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -163,6 +170,7 @@ test('appstate rejects a missing readiness fact even when appState is available' { local: async () => [device] }, async () => await handleAppStateCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -261,6 +269,7 @@ test('sessionless Android appstate inspects once, binds once, and preserves oper { local: async () => [device] }, async () => await handleAppStateCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-appstate-facts.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-appstate-facts.test.ts index c65c263872..22dc63415f 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-appstate-facts.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-appstate-facts.test.ts @@ -2,7 +2,11 @@ import { test, expect } from 'vitest'; import * as path from 'node:path'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { providerRuntimeOwner } from '@agent-device/contracts/platform-runtime'; -import { createUnavailableRuntimeFactsForTest, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + createUnavailableRuntimeFactsForTest, + mkdtempForTestSync, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; import { handleSessionCommands, @@ -38,6 +42,7 @@ test('appstate refuses unavailable provider facts before binding the device', as sessionStore.publish('default', makeSession('default', { device: staleDevice })); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: async () => { throw new Error('appstate must refuse before loading a command surface'); }, diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-appstate-input.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-appstate-input.test.ts index bd503710c7..30a07e29d5 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-appstate-input.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-appstate-input.test.ts @@ -15,7 +15,7 @@ import { handleSessionCommands, mockInspectDeviceRuntimeFacts, } from '../../../__tests__/support/handlers/session-command-harness.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { AppError, PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors'; import type { AppStateRuntimeInput, @@ -59,6 +59,7 @@ test('appstate on iOS requires active session on selected device', async () => { mockDispatch.mockRejectedValue(new Error('snapshot dispatch should not run')); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -107,6 +108,7 @@ test('appstate returns session appName when bundle id is unavailable', async () mockDispatch.mockRejectedValue(new Error('snapshot dispatch should not run')); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -158,6 +160,7 @@ test('appstate fails when iOS session has no tracked app', async () => { mockResolveTargetDevice.mockResolvedValue(selectedDevice); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -193,6 +196,7 @@ test('appstate without session on iOS selector returns SESSION_NOT_FOUND', async mockResolveTargetDevice.mockResolvedValue(selectedDevice); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -218,6 +222,7 @@ test('appstate without session on iOS selector returns SESSION_NOT_FOUND', async test('appstate with explicit missing session returns SESSION_NOT_FOUND', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -249,6 +254,7 @@ test('appstate with explicit missing session returns SESSION_NOT_FOUND', async ( test('clipboard requires an active session or explicit device selector', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -291,6 +297,7 @@ test('clipboard rejects unsupported iOS physical devices', async () => { mockDispatch.mockRejectedValue(new Error('dispatch should not run for unsupported targets')); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -378,6 +385,7 @@ test('appstate on iOS reads the session app state from the runner when its owner }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: iosSessionRequest(sessionName), @@ -411,6 +419,7 @@ test('appstate on iOS keeps the session answer, with no state, when no runner is const runtime = appleAppStateRuntime(async () => ({})); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: iosSessionRequest(sessionName), @@ -442,6 +451,7 @@ test('appstate on iOS keeps the session answer, with no state, when the runner c }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: iosSessionRequest(sessionName), diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-appstate.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-appstate.test.ts index 1151835b25..e17c1ebdf8 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-appstate.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-appstate.test.ts @@ -4,7 +4,10 @@ import { makeSessionStore } from '../../../__tests__/support/handlers/session-te import { handleAppStateCommand } from '../session-appstate.ts'; import { PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors'; import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; -import { createUnavailableRuntimeFactsForTest } from '@agent-device/test-kit'; +import { + createUnavailableRuntimeFactsForTest, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonRequest } from '../../../core/daemon-request.ts'; @@ -15,6 +18,7 @@ function appStateRequest(session: string, flags: DaemonRequest['flags']): Daemon test('appstate returns missing-session error for explicit session flag', async () => { const response = await handleAppStateCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: appStateRequest('named', { platform: 'ios', session: 'named' }), sessionName: 'named', @@ -35,6 +39,7 @@ test('appstate returns missing-session error for explicit session flag', async ( test('appstate --platform macos missing-session error never suggests omitting --session', async () => { const response = await handleAppStateCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: appStateRequest('named', { platform: 'macos', session: 'named' }), sessionName: 'named', @@ -53,6 +58,7 @@ test('appstate --platform macos missing-session error never suggests omitting -- test('appstate --platform apple missing-session error never suggests omitting --session', async () => { const response = await handleAppStateCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: appStateRequest('named', { platform: 'apple', session: 'named' }), sessionName: 'named', @@ -89,6 +95,7 @@ test('appstate --udid with no --platform resolves the stated device and THEN ans const inspected: string[] = []; try { const response = await handleAppStateCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: appStateRequest('lost', { session: 'lost', udid: 'SIM-UDID-A' }), sessionName: 'lost', @@ -138,6 +145,7 @@ test('appstate --udid on an Apple device without naming a session keeps the plai resolved.mockResolvedValue(iosSim); try { const response = await handleAppStateCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: appStateRequest('default', { udid: 'SIM-UDID-B' }), sessionName: 'default', @@ -195,6 +203,7 @@ test('appstate keeps the admission-time named-session verdict when the store cha }); try { const response = await handleAppStateCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: appStateRequest('lost', { session: 'lost', udid: 'SIM-UDID-C' }), sessionName: 'lost', @@ -229,6 +238,7 @@ test('appstate with a named session and an identity device selector keeps the st const inspected: string[] = []; try { const response = await handleAppStateCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: appStateRequest('named', { session: 'named', platform: 'android', device: 'Pixel 9' }), sessionName: 'named', @@ -259,6 +269,7 @@ test('appstate with a named session and an identity device selector keeps the st test('appstate refuses --platform ios without a session at the message level', async () => { const response = await handleAppStateCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: appStateRequest('default', { platform: 'ios', device: 'iPhone 17' }), sessionName: 'default', @@ -276,6 +287,7 @@ test('appstate refuses --platform ios without a session at the message level', a test('appstate refuses --platform macos without a session at the message level', async () => { const response = await handleAppStateCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: appStateRequest('default', { platform: 'macos', device: 'Mac' }), sessionName: 'default', diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-boot-shutdown-device-claims.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-boot-shutdown-device-claims.test.ts index aaba503c53..a488664e70 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-boot-shutdown-device-claims.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-boot-shutdown-device-claims.test.ts @@ -13,7 +13,11 @@ import { readinessDeviceRuntimeGateway, } from '../../../__tests__/support/handlers/session-command-harness.ts'; import { normalizeError } from '@agent-device/kernel/errors'; -import { ANDROID_EMULATOR } from '@agent-device/test-kit'; +import { + ANDROID_EMULATOR, + createTestHostEnvironment, + bindTestCommandHost, +} from '@agent-device/test-kit'; import { isolatedDeviceClaimStores, retainOrphanedDeviceClaims, @@ -29,6 +33,8 @@ import { handleBootCommand } from '../session-boot.ts'; import { handleShutdownCommand } from '../session-shutdown.ts'; import type { DaemonRequest } from '../../../core/daemon-request.ts'; +bindTestCommandHost(); + // #1799: `boot`/`shutdown` route through a daemon that need not own the target // device. Two daemons on one host each keep their own state directory, so the // host-global claim store is their only shared authority. These tests are shaped @@ -47,6 +53,7 @@ async function claimForeignDaemon(root: string): Promise { const ownerStateDir = path.join(root, OWNER_STATE_DIR_NAME); fs.mkdirSync(ownerStateDir, { recursive: true }); const acquired = await acquireDeviceClaim({ + env: process.env, device: ANDROID_EMULATOR, session: 'owner-session', workspace: '/worktrees/daemon-a', @@ -71,6 +78,7 @@ async function runStateCommand( meta: { cwd: '/worktrees/daemon-b' }, }; const scope = await createRequestExecutionScope({ + host: createTestHostEnvironment(), req, sessionStore: store, leaseRegistry: new LeaseRegistry(), @@ -85,6 +93,7 @@ async function runStateCommand( }); try { const handlerParams = { + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req, sessionName: 'default', @@ -124,7 +133,9 @@ test('shutdown refuses a device held by a foreign live claim and never reaches t ); expect(mockShutdownTargetRuntime).not.toHaveBeenCalled(); // The refusal leaves the owner's claim exactly as it was. - expect(inspectDeviceClaims({}).map((entry) => entry.claim?.session)).toEqual(['owner-session']); + expect(inspectDeviceClaims({}, process.env).map((entry) => entry.claim?.session)).toEqual([ + 'owner-session', + ]); }); test('boot refuses a device held by a foreign live claim and never reaches the device', async () => { @@ -150,7 +161,7 @@ test('shutdown on an unclaimed device succeeds and releases its transient claim' expect(response?.ok).toBe(true); expect(mockShutdownTargetRuntime).toHaveBeenCalledOnce(); - expect(inspectDeviceClaims({})).toEqual([]); + expect(inspectDeviceClaims({}, process.env)).toEqual([]); }); test('a failing shutdown still releases its transient claim', async () => { @@ -161,5 +172,5 @@ test('a failing shutdown still releases its transient claim', async () => { const response = await runStateCommand('shutdown', daemonB(stateDir)); expect(response?.ok).toBe(false); - expect(inspectDeviceClaims({})).toEqual([]); + expect(inspectDeviceClaims({}, process.env)).toEqual([]); }); diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-boot-warmup.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-boot-warmup.test.ts index dcd6bf05f2..723334b51a 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-boot-warmup.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-boot-warmup.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; import { makeSessionStore, @@ -9,6 +10,8 @@ import { expect, test } from 'vitest'; import { handleSessionCommands } from '../../../__tests__/support/handlers/session-command-harness.ts'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; +bindTestCommandHost(); + test('boot leaves Apple keep-hot policy inside the platform runtime', async () => { mockResolveTargetDevice.mockResolvedValue({ platform: 'apple', @@ -21,6 +24,7 @@ test('boot leaves Apple keep-hot policy inside the platform runtime', async () = }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts index fae6b779c1..71e6154ba0 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts @@ -21,12 +21,19 @@ import { mockEnsureReadyRuntime, mockInspectDeviceRuntimeFacts, } from '../../../__tests__/support/handlers/session-command-harness.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; +bindTestCommandHost(); + test('boot requires session or explicit selector', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -71,6 +78,7 @@ test('boot prefers explicit device selector over active session device', async ( mockResolveTargetDevice.mockResolvedValue(selectedDevice); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -117,6 +125,7 @@ test('boot --timeout forwards a startup deadline to bootTarget (#3004)', async ( const beforeMs = Date.now(); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -152,6 +161,7 @@ test('boot without --timeout leaves the startup deadline unset', async () => { mockResolveTargetDevice.mockResolvedValue(selectedDevice); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -189,6 +199,7 @@ test('boot --headless admits a stopped Android emulator through facts and binds booted: true, }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -235,6 +246,7 @@ test('boot rejects the macOS host boot cell after one facts inspection and befor mockResolveTargetDevice.mockResolvedValue(device); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -273,6 +285,7 @@ test('boot admits a stopped Android emulator through normal readiness', async () booted: true, }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -312,6 +325,7 @@ test('boot forwards Android serial admission policy to readiness', async () => { }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -348,6 +362,7 @@ test('boot --headless requires avd selector when device cannot be resolved', asy const sessionStore = makeSessionStore(); mockResolveTargetDevice.mockRejectedValue(new AppError('DEVICE_NOT_FOUND', 'No device found')); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -385,6 +400,7 @@ test('boot --headless rejects non-Android selectors', async () => { booted: false, }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -420,6 +436,7 @@ test('boot keeps --target validation before facts inspection', async () => { booted: false, }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -457,6 +474,7 @@ test('boot rejects --headless outside Android directly', async () => { }; mockResolveTargetDevice.mockResolvedValue(device); const response = await handleBootCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-command-replay.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-command-replay.test.ts index e653999fad..068e247150 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-command-replay.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-command-replay.test.ts @@ -7,7 +7,7 @@ import { handleSessionCommands } from '../../../__tests__/support/handlers/sessi import type { DaemonRequest } from '../../../core/daemon-request.ts'; import { replayScriptSourceBundleFor } from '../../../__tests__/support/replay-script-source-fixture.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; test('replay parses open --relaunch flag and replays open with relaunch semantics', async () => { @@ -18,6 +18,7 @@ test('replay parses open --relaunch flag and replays open with relaunch semantic const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -58,6 +59,7 @@ test('replay parses runtime set flags and replays runtime command', async () => const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -99,6 +101,7 @@ test('replay parses inline open runtime flags and replays open with runtime payl const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -145,6 +148,7 @@ test('replay delivers scroll --until and wait --raw flags to the dispatched step const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -182,6 +186,7 @@ test('replay inherits parent device selectors for each invoked step', async () = const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -223,6 +228,7 @@ test('replay inherits the parent web platform selector for each invoked step', a const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -268,6 +274,7 @@ test('test --platform web reports no matching scripts, typed or untyped, because fs.writeFileSync(path.join(root, '02-android.ad'), 'context platform=android\nopen "Demo"\n'); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts index b39b9f0c65..e08bd1ef5b 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts @@ -4,7 +4,13 @@ import fs from 'node:fs'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; import * as hostProcess from '@agent-device/host-kit/process'; -import { MACOS_DEVICE, mkdtempForTestSync, pinOwnProcessStartTime } from '@agent-device/test-kit'; +import { + MACOS_DEVICE, + mkdtempForTestSync, + pinOwnProcessStartTime, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { @@ -64,6 +70,8 @@ import { } from '../../../__tests__/support/application-lifecycle-runtime-harness.ts'; import { platformResourceCleanupFixture } from '../../../__tests__/support/platform-resource-cleanup-fixture.ts'; +bindTestCommandHost(); + const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); const mockPrepareApplicationOpen = vi.mocked(prepareApplicationOpenFixture); const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); @@ -140,6 +148,7 @@ test('failed local open before device setup rolls its device claim back', async await assert.rejects( async () => handleOpenCommand({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -155,7 +164,7 @@ test('failed local open before device setup rolls its device claim back', async }), (error: unknown) => error === rejectionError, ); - assert.deepEqual(inspectDeviceClaims({ serial: stoppedAvd.id }), []); + assert.deepEqual(inspectDeviceClaims({ serial: stoppedAvd.id }, process.env), []); }); test('failed local open after dispatch retains its device claim for recovery', async () => { @@ -169,6 +178,7 @@ test('failed local open after dispatch retains its device claim for recovery', a await assert.rejects( async () => handleOpenCommand({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -184,7 +194,7 @@ test('failed local open after dispatch retains its device claim for recovery', a }), (error: unknown) => error === rejectionError, ); - const retained = inspectDeviceClaims({ serial: android.id })[0]; + const retained = inspectDeviceClaims({ serial: android.id }, process.env)[0]; assert.equal(retained?.classification, 'live'); assert.equal(typeof retained?.claim?.abandonedAtMs, 'number'); }); @@ -201,6 +211,7 @@ test('failed local runtime-hint setup retains its device claim before open dispa await assert.rejects( async () => handleOpenCommand({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -220,7 +231,7 @@ test('failed local runtime-hint setup retains its device claim before open dispa assert.equal(mockApplyRuntimeHints.mock.calls.length, 1); assert.equal(mockDispatch.mock.calls.length, 0); - assert.equal(inspectDeviceClaims({ serial: android.id })[0]?.classification, 'live'); + assert.equal(inspectDeviceClaims({ serial: android.id }, process.env)[0]?.classification, 'live'); assert.equal(store.get('claim-runtime-hint-failure'), undefined); }); @@ -229,6 +240,7 @@ test('failed local open response rolls its device claim back', async () => { mockResolveTargetDevice.mockResolvedValue(android); const response = await handleOpenCommand({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -245,7 +257,7 @@ test('failed local open response rolls its device claim back', async () => { }); assert.equal(response.ok, false); - assert.deepEqual(inspectDeviceClaims({ serial: android.id }), []); + assert.deepEqual(inspectDeviceClaims({ serial: android.id }, process.env), []); }); test('a canceled attempt lets the next attempt of the same suite open the device', async () => { @@ -256,6 +268,7 @@ test('a canceled attempt lets the next attempt of the same suite open the device markRequestCanceled(requestId); try { const timedOut = await handleOpenCommand({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -277,6 +290,7 @@ test('a canceled attempt lets the next attempt of the same suite open the device assert.equal(store.get('suite:1-gesture-pan-duration:attempt-1'), undefined); const retry = await handleOpenCommand({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -292,7 +306,7 @@ test('a canceled attempt lets the next attempt of the same suite open the device }); assert.equal(retry.ok, true); - const claim = inspectDeviceClaims({ serial: android.id })[0]?.claim; + const claim = inspectDeviceClaims({ serial: android.id }, process.env)[0]?.claim; assert.equal(claim?.session, 'suite:1-gesture-pan-duration:attempt-2'); assert.equal(claim?.abandonedAtMs, undefined); assert.equal( @@ -310,6 +324,7 @@ test('cancellation after local device setup retains the device claim for recover try { const response = await handleOpenCommand({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -327,7 +342,7 @@ test('cancellation after local device setup retains the device claim for recover assert.equal(response.ok, false); assert.equal(mockDispatch.mock.calls.length, 1); - const retainedClaim = inspectDeviceClaims({ serial: android.id })[0]; + const retainedClaim = inspectDeviceClaims({ serial: android.id }, process.env)[0]; assert.equal(retainedClaim?.classification, 'live'); assert.equal(typeof retainedClaim?.claim?.abandonedAtMs, 'number'); assert.equal(store.get('claim-canceled-after-dispatch'), undefined); @@ -342,6 +357,7 @@ test('provider-owned open creates no host-local device claim from its selected o mockDispatch.mockResolvedValue(undefined); const response = await handleOpenCommand({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -361,7 +377,7 @@ test('provider-owned open creates no host-local device claim from its selected o bindDevice: bindProviderLifecycleRuntime, }); assert.equal(response.ok, true); - assert.deepEqual(inspectDeviceClaims({ serial: android.id }), []); + assert.deepEqual(inspectDeviceClaims({ serial: android.id }, process.env), []); assert.equal(store.get('remote-open')?.deviceClaim, undefined); }); @@ -371,6 +387,7 @@ test('a foreign live claim rejects open before platform preparation or mutation' const ownerStateDir = path.join(stateDir, 'foreign-owner'); fs.mkdirSync(ownerStateDir); const acquired = await acquireDeviceClaim({ + env: process.env, device: android, session: 'foreign-session', workspace: '/worktrees/foreign', @@ -380,6 +397,7 @@ test('a foreign live claim rejects open before platform preparation or mutation' if (acquired.status !== 'acquired') return; const response = await handleOpenCommand({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -419,6 +437,7 @@ test('a foreign live claim rejects open before platform preparation or mutation' test('local close clears its matching device claim after teardown', async () => { const { store, stateDir } = setup(); const acquired = await acquireDeviceClaim({ + env: process.env, device: android, session: 'close-claim', workspace: process.cwd(), @@ -436,6 +455,7 @@ test('local close clears its matching device claim after teardown', async () => mockDispatch.mockResolvedValue(undefined); const response = await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { command: 'close', token: 'test', session: 'close-claim', positionals: [], flags: {} }, sessionName: 'close-claim', @@ -444,12 +464,13 @@ test('local close clears its matching device claim after teardown', async () => leaseRegistry: new LeaseRegistry(), }); assert.equal(response.ok, true); - assert.deepEqual(inspectDeviceClaims({ serial: android.id }), []); + assert.deepEqual(inspectDeviceClaims({ serial: android.id }, process.env), []); }); test('#1391: a close-time script save failure still clears the device claim and deletes the session', async () => { const { store, stateDir } = setup(); const acquired = await acquireDeviceClaim({ + env: process.env, device: android, session: 'close-save-script-failure', workspace: process.cwd(), @@ -481,6 +502,7 @@ test('#1391: a close-time script save failure still clears the device claim and let thrown: unknown; try { await handleCloseCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { command: 'close', @@ -512,7 +534,7 @@ test('#1391: a close-time script save failure still clears the device claim and assert.equal(error.details?.reason, 'script_target_exists'); assert.equal(error.details?.path, targetPath); assert.equal(store.get('close-save-script-failure'), undefined); - assert.deepEqual(inspectDeviceClaims({ serial: android.id }), []); + assert.deepEqual(inspectDeviceClaims({ serial: android.id }, process.env), []); assert.equal(fs.readFileSync(targetPath, 'utf8'), 'pre-existing\n'); assert.deepEqual( @@ -533,6 +555,7 @@ test('an open under a macos-app lease holds only its app, beside other claims on const ownerStateDir = path.join(stateDir, 'host-user'); fs.mkdirSync(ownerStateDir); const foreign = await acquireDeviceClaim({ + env: process.env, device: MACOS_DEVICE, session: 'host-user-session', workspace: '/worktrees/host-user', @@ -555,6 +578,7 @@ test('an open under a macos-app lease holds only its app, beside other claims on }); const response = await handleOpenCommand({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -574,7 +598,9 @@ test('an open under a macos-app lease holds only its app, beside other claims on assert.equal(response.ok, true, JSON.stringify(response)); assert.equal(store.get('leased-app')?.deviceClaim, undefined); assert.deepEqual( - inspectDeviceClaims({ udid: MACOS_DEVICE.id }).map((entry) => entry.claim?.session), + inspectDeviceClaims({ udid: MACOS_DEVICE.id }, process.env).map( + (entry) => entry.claim?.session, + ), ['host-user-session'], ); }); diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-doctor-app-runtime.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-doctor-app-runtime.test.ts index 9f545d4173..c0e0b4251d 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-doctor-app-runtime.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-doctor-app-runtime.test.ts @@ -3,6 +3,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { LINUX_DEVICE, unavailableDeploymentSnapshotAndShutdownOperationFacts, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { makeSessionStore } from '../../../__tests__/support/store-factory.ts'; import type { @@ -47,6 +48,7 @@ test('doctor app checks retain runtime admission failures as a failed target-app }); const response = await handleDoctorCommand({ + host: createTestHostEnvironment(), hostDiagnostics: hostDiagnosticsFixture(), req: { token: 't', @@ -145,6 +147,7 @@ test('doctor preserves the legacy unsupported HarmonyOS target-app check', async }); const response = await handleDoctorCommand({ + host: createTestHostEnvironment(), hostDiagnostics: hostDiagnosticsFixture(), req: { token: 't', diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-install-source-ref-frame.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-install-source-ref-frame.test.ts index b5709b5f21..bf982b19f3 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-install-source-ref-frame.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-install-source-ref-frame.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import type { InspectDeviceRuntimeFacts } from '../../../core/request-runtime-binding.ts'; @@ -116,6 +117,7 @@ async function dispatchInstallSource(params: { meta: { installSource: { kind: 'path', path: '/tmp/App.apk' } }, }; return await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req, diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-push-ref-frame.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-push-ref-frame.test.ts index 5c764d9d58..4870cbbdde 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-push-ref-frame.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-push-ref-frame.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import type { InspectDeviceRuntimeFacts } from '../../../core/request-runtime-binding.ts'; @@ -13,6 +14,8 @@ import { import { activateCompleteRefFrame, refFrameState } from '../../../core/ref-frame.ts'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; +bindTestCommandHost(); + const invoke = async (): Promise => { throw new Error('push ref-frame tests must stay on the runtime route'); }; @@ -91,6 +94,7 @@ async function dispatchPush(params: { flags: {}, }; return await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req, diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-push.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-push.test.ts index 8d13e0ad26..5cfd1b40f0 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-push.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-push.test.ts @@ -10,7 +10,11 @@ import type { import type { DaemonRequest } from '../../../core/daemon-request.ts'; import { makeSessionStore } from '../../../__tests__/support/store-factory.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { handleSessionCommands, mockBindDeviceRuntime, @@ -20,6 +24,8 @@ import { } from '../../../__tests__/support/handlers/session-command-harness.ts'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; +bindTestCommandHost(); + const invoke = async (_req: DaemonRequest): Promise => { return { ok: false, @@ -30,6 +36,7 @@ const invoke = async (_req: DaemonRequest): Promise => { test('push requires active session or explicit device selector', async () => { const sessionStore = makeSessionStore('agent-device-session-push-'); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -69,6 +76,7 @@ test('push validates payload before runtime facts admission', async () => { await expect( handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -111,6 +119,7 @@ test('push runs readiness and notification through one admitted runtime binding' }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -178,6 +187,7 @@ test('push treats an existing brace-prefixed payload as a file before inline JSO try { const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -218,6 +228,7 @@ test('push stops at unavailable facts without binding', async () => { }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -274,6 +285,7 @@ test('push fails closed before binding when a provider owner lacks readiness', a }; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-reinstall.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-reinstall.test.ts index 0344a5c662..cad9c2019e 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-reinstall.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-reinstall.test.ts @@ -3,7 +3,11 @@ import { daemonPlatformServicesFixture } from '../../../__tests__/support/platfo import fs from 'node:fs'; import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { trackUploadedArtifact } from '../../../resources/artifact-tracking.ts'; import type { BindDeviceRuntime, @@ -28,6 +32,8 @@ import { mockInspectDeviceRuntimeFacts, } from '../../../__tests__/support/handlers/session-command-harness.ts'; +bindTestCommandHost(); + function makeStore(): SessionStore { return new SessionStore( path.join(mkdtempForTestSync('agent-device-session-app-deploy-'), 'sessions'), @@ -51,6 +57,7 @@ beforeEach(() => { test('reinstall requires active session or explicit device selector', async () => { const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -87,6 +94,7 @@ test('reinstall validates required args before runtime admission', async () => { }), ); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -125,6 +133,7 @@ test('install binds the deployment use exactly once after one facts admission', }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -190,6 +199,7 @@ test('install fails closed before binding when its provider owner lacks deployme }; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -236,6 +246,7 @@ test('reinstall cleans an uploaded artifact after the request-scoped operation', vi.mocked(resolveTargetDevice).mockResolvedValue(session.device); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -272,6 +283,7 @@ test('HarmonyOS reinstall updates the active session identity from the runtime r mockDeployAppRuntime.mockResolvedValue({ packageName: 'com.example.application' }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -323,6 +335,7 @@ test.each(['rebuild', 'retire'] as const)( return { packageName: 'com.example.updated' }; }); const running = handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-relaunch-guards.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-relaunch-guards.test.ts index 2759cb1aa6..e6b2e23e17 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-relaunch-guards.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-relaunch-guards.test.ts @@ -11,13 +11,14 @@ import { } from '../../../__tests__/support/handlers/session-test-harness.ts'; import { handleSessionCommands } from '../../../__tests__/support/handlers/session-command-harness.ts'; import { makeTestScreenRecordingResource } from '../../../__tests__/support/screen-recording-live-handle.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; import type { DaemonPlatformServices } from '../../../core/platform-services.ts'; test('open --relaunch fails without app when no session exists', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -64,6 +65,7 @@ test('open on in-use device returns DEVICE_IN_USE before readiness checks', asyn }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -120,6 +122,7 @@ test('open on device owned by recording session returns recording recovery hint' }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -156,6 +159,7 @@ async function openRelaunch(params: { flags: Record; }) { return await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture({ validateOpenRelaunchTarget: params.validateOpenRelaunchTarget, diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-replay-command.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-replay-command.test.ts index 1ded68f43f..ae2d6cd83d 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-replay-command.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-replay-command.test.ts @@ -12,7 +12,7 @@ import type { SessionCommandParams } from '../session-command-input.ts'; import { handleReplayTestCommand } from '../session-replay-command.ts'; import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; import { makeSessionStore } from '../../../__tests__/support/handlers/session-test-harness.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; import { withClientReplayScriptSources } from '../../../__tests__/support/replay-script-source-fixture.ts'; import { makeIosSession } from '../../../__tests__/support/session-factories.ts'; @@ -25,6 +25,7 @@ import type { EndedLeaseLedger } from '../../../core/request-ended-leases.ts'; test('replay test handler threads the complete video owner into the application command', async () => { const root = mkdtempForTestSync('agent-device-replay-handler-video-'); const params: SessionCommandParams = { + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, req: { token: 'token', @@ -81,6 +82,7 @@ test('replay test session cleanup reports the lease it ended on the test request meta: { cwd: root, requestId: 'replay-handler-ended-lease' }, }); const params: SessionCommandParams = { + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, req: { ...wireReq, internal: { endedLeases } }, sessionName: 'default', diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-runtime-command.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-runtime-command.test.ts index 80238053b4..91e1a49d55 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-runtime-command.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-runtime-command.test.ts @@ -21,7 +21,7 @@ import { gestureRuntimeSpies, } from '../../../__tests__/support/test-device-runtime-gateway.ts'; import { refFrameState } from '../../../core/ref-frame.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; test('runtime set/show/clear manages session-scoped runtime hints before open', async () => { @@ -32,6 +32,7 @@ test('runtime set/show/clear manages session-scoped runtime hints before open', } satisfies Pick; const setResponse = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -53,6 +54,7 @@ test('runtime set/show/clear manages session-scoped runtime hints before open', expect(setResponse?.ok).toBe(true); const showResponse = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -79,6 +81,7 @@ test('runtime set/show/clear manages session-scoped runtime hints before open', } const clearResponse = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -116,6 +119,7 @@ test('runtime clear removes applied transport hints for the active app', async ( }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -167,6 +171,7 @@ test('runtime clear expires the ref frame at the admitted hint mutation boundary }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -222,6 +227,7 @@ test('runtime clear rejects a false runtime-hints fact before its one implementa }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -274,6 +280,7 @@ test('runtime gesture-viewport admits and binds the exact viewport operation onc gestureRuntimeSpies.gestureViewport.mockClear(); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -344,6 +351,7 @@ for (const phase of ['admission', 'effect'] as const) { } await expect( handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -387,6 +395,7 @@ test('runtime clear expires and uses the latest matching record after admission' return lifecycleRuntimeFacts(target); }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { 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 47789f4e26..6b70c6e326 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-shutdown.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-shutdown.test.ts @@ -15,7 +15,7 @@ import { mockInspectDeviceRuntimeFacts, mockShutdownTargetRuntime, } from '../../../__tests__/support/handlers/session-command-harness.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; test('shutdown turns off selected iOS simulator', async () => { @@ -31,6 +31,7 @@ test('shutdown turns off selected iOS simulator', async () => { mockResolveTargetDevice.mockResolvedValue(selectedDevice); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -79,6 +80,7 @@ test('shutdown rejects active session device and points to close --shutdown', as mockResolveTargetDevice.mockResolvedValue(selectedDevice); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -120,6 +122,7 @@ test('shutdown turns off selected Android emulator', async () => { }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -169,6 +172,7 @@ test('shutdown rejects unsupported physical devices', async () => { }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -214,6 +218,7 @@ test('shutdown returns an error response when selected target shutdown fails', a }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-trigger.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-trigger.test.ts index 45e9c01767..0636da81bb 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-trigger.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-trigger.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; import { test, expect } from 'vitest'; @@ -17,6 +18,7 @@ const invoke = async (_req: DaemonRequest): Promise => { test('trigger-app-event requires active session or explicit device selector', async () => { const sessionStore = makeSessionStore('agent-device-session-trigger-'); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-uninstall.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-uninstall.test.ts index e8c512e0f7..684e0e8734 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-uninstall.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-uninstall.test.ts @@ -2,7 +2,12 @@ import { daemonPlatformServicesFixture } from '../../../__tests__/support/platfo import { expect, test } from 'vitest'; import type { SessionState } from '../../../core/session-state.ts'; import { makeSessionStore } from '../../../__tests__/support/store-factory.ts'; -import { ANDROID_EMULATOR, IOS_DEVICE, IOS_SIMULATOR } from '@agent-device/test-kit'; +import { + ANDROID_EMULATOR, + IOS_DEVICE, + IOS_SIMULATOR, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { handleSessionCommands, mockBindDeviceRuntime, @@ -14,6 +19,7 @@ async function uninstall(device: SessionState['device'], app: string) { const sessionStore = makeSessionStore('agent-device-session-uninstall-'); sessionStore.publish('default', { name: 'default', createdAt: Date.now(), actions: [], device }); return await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture({ appleSessionObservation: { diff --git a/packages/daemon/src/sessions/handlers/__tests__/session.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session.test.ts index 9ef7a5e7c2..ee5c1e3880 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session.test.ts @@ -3,7 +3,7 @@ import { expect, test } from 'vitest'; import * as path from 'node:path'; import { handleSessionCommands } from '../../../__tests__/support/handlers/session-command-harness.ts'; import { makeSessionStore } from '../../../__tests__/support/store-factory.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; import type { DaemonRequest } from '../../../core/daemon-request.ts'; @@ -13,6 +13,7 @@ function request(command: string): DaemonRequest { async function dispatch(command: string) { return await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: request(command), diff --git a/packages/daemon/src/sessions/handlers/__tests__/trace-runtime.test.ts b/packages/daemon/src/sessions/handlers/__tests__/trace-runtime.test.ts index 2e4855596a..fcc87825cf 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/trace-runtime.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/trace-runtime.test.ts @@ -1,12 +1,18 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { SessionStore } from '../../../core/session-store.ts'; import type { DaemonRequest } from '../../../core/daemon-request.ts'; import type { SessionState } from '../../../core/session-state.ts'; import { handleTraceCommand } from '../trace-runtime.ts'; +bindTestCommandHost(); + function fixture() { const root = mkdtempForTestSync('agent-device-trace-runtime-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); @@ -40,6 +46,7 @@ function request(positionals: string[], clientOutPath?: string): DaemonRequest { test('starts and stops one trace through the session-owned trace slot', () => { const { session, sessionStore } = fixture(); const start = handleTraceCommand({ + host: createTestHostEnvironment(), req: request(['start']), sessionName: session.name, sessionStore, @@ -49,6 +56,7 @@ test('starts and stops one trace through the session-owned trace slot', () => { expect(start).toMatchObject({ ok: true, data: { trace: 'started', outPath: startedPath } }); const stop = handleTraceCommand({ + host: createTestHostEnvironment(), req: request(['stop']), sessionName: session.name, sessionStore, @@ -63,12 +71,14 @@ test('relocates trace output and projects the client artifact path', () => { const relocated = path.join(root, 'relocated.trace'); const client = '/client/trace.json'; handleTraceCommand({ + host: createTestHostEnvironment(), req: request(['start', original]), sessionName: session.name, sessionStore, }); const stop = handleTraceCommand({ + host: createTestHostEnvironment(), req: request(['stop', relocated], client), sessionName: session.name, sessionStore, @@ -96,6 +106,7 @@ test('starts and stops one trace through the session-owned trace slot on a web s kind: 'device', }; const start = handleTraceCommand({ + host: createTestHostEnvironment(), req: request(['start']), sessionName: session.name, sessionStore, @@ -105,6 +116,7 @@ test('starts and stops one trace through the session-owned trace slot on a web s expect(start).toMatchObject({ ok: true, data: { trace: 'started', outPath: startedPath } }); const stop = handleTraceCommand({ + host: createTestHostEnvironment(), req: request(['stop']), sessionName: session.name, sessionStore, @@ -116,17 +128,42 @@ test('starts and stops one trace through the session-owned trace slot on a web s test('rejects invalid actions, missing sessions, and incoherent lifecycle transitions', () => { const { session, sessionStore } = fixture(); expect( - handleTraceCommand({ req: request(['pause']), sessionName: session.name, sessionStore }), + handleTraceCommand({ + host: createTestHostEnvironment(), + req: request(['pause']), + sessionName: session.name, + sessionStore, + }), ).toMatchObject({ ok: false, error: { code: 'INVALID_ARGS' } }); expect( - handleTraceCommand({ req: request(['start']), sessionName: 'missing', sessionStore }), + handleTraceCommand({ + host: createTestHostEnvironment(), + req: request(['start']), + sessionName: 'missing', + sessionStore, + }), ).toMatchObject({ ok: false, error: { code: 'SESSION_NOT_FOUND' } }); expect( - handleTraceCommand({ req: request(['stop']), sessionName: session.name, sessionStore }), + handleTraceCommand({ + host: createTestHostEnvironment(), + req: request(['stop']), + sessionName: session.name, + sessionStore, + }), ).toMatchObject({ ok: false, error: { message: 'no active trace' } }); - handleTraceCommand({ req: request(['start']), sessionName: session.name, sessionStore }); + handleTraceCommand({ + host: createTestHostEnvironment(), + req: request(['start']), + sessionName: session.name, + sessionStore, + }); expect( - handleTraceCommand({ req: request(['start']), sessionName: session.name, sessionStore }), + handleTraceCommand({ + host: createTestHostEnvironment(), + req: request(['start']), + sessionName: session.name, + sessionStore, + }), ).toMatchObject({ ok: false, error: { message: 'trace already in progress' } }); }); diff --git a/packages/daemon/src/sessions/handlers/lease-provider-credentials.test.ts b/packages/daemon/src/sessions/handlers/lease-provider-credentials.test.ts index 8e916be461..759072f1ed 100644 --- a/packages/daemon/src/sessions/handlers/lease-provider-credentials.test.ts +++ b/packages/daemon/src/sessions/handlers/lease-provider-credentials.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import type { DaemonProviderCredentials } from '@agent-device/daemon-contracts/provider-credentials'; @@ -43,6 +44,7 @@ async function allocateWithDaemonCredentials( let allocations = 0; try { await handleLeaseCommands({ + host: createTestHostEnvironment(), req, sessionName: req.session, sessionStore: makeSessionStore('agent-device-provider-credentials-'), diff --git a/packages/daemon/src/sessions/handlers/lease.ts b/packages/daemon/src/sessions/handlers/lease.ts index 244f7fabdb..672f5b9e69 100644 --- a/packages/daemon/src/sessions/handlers/lease.ts +++ b/packages/daemon/src/sessions/handlers/lease.ts @@ -1,3 +1,4 @@ +import type { 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 { @@ -43,6 +44,7 @@ import type { DaemonProviderCredentials } from '@agent-device/daemon-contracts/p import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; type LeaseHandlerArgs = { + host: HostEnvironment; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -104,7 +106,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { - const { req, sessionName, sessionStore, inspectFacts, bindDevice, platformServices } = params; + const { host, req, sessionName, sessionStore, inspectFacts, bindDevice, platformServices } = + params; if (req.command === 'install' || req.command === 'reinstall') { return await handleAppDeploymentCommand({ + host, req, command: req.command, sessionName, @@ -46,6 +50,7 @@ export async function handleSessionAppDeploymentCommand(params: { } if (req.command === 'uninstall') { return await handleUninstallCommand({ + host, req, sessionName, sessionStore, @@ -56,6 +61,7 @@ export async function handleSessionAppDeploymentCommand(params: { } if (req.command === 'push') { return await handlePushNotificationCommand({ + host, req, sessionName, sessionStore, diff --git a/packages/daemon/src/sessions/handlers/session-app-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-deployment.ts index 7290f6c5dd..393e08f256 100644 --- a/packages/daemon/src/sessions/handlers/session-app-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-deployment.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { DaemonPlatformServices } from '../../core/platform-services.ts'; import { expandSessionPath } from '@agent-device/host-kit/session-paths'; @@ -53,6 +54,7 @@ type DeployResult = Readonly<{ }>; export async function handleAppDeploymentCommand(params: { + host: HostEnvironment; req: DaemonRequest; command: DeployCommand; sessionName: string; @@ -74,7 +76,7 @@ export async function handleAppDeploymentCommand(params: { try { const appPath = uploadedArtifactId ? prepareUploadedArtifact(uploadedArtifactId, req.meta?.tenantId) - : expandSessionPath(target.appPathInput); + : expandSessionPath(target.appPathInput, params.host.cwd); if (!fs.existsSync(appPath)) { return errorResponse('INVALID_ARGS', `App binary not found: ${appPath}`); } @@ -128,7 +130,9 @@ export async function handlePushNotificationCommand( 'push requires ', ); } - const payload = await readNotificationPayload(resolvePushPayload(payloadArg, req.meta?.cwd)); + const payload = await readNotificationPayload( + resolvePushPayload(payloadArg, req.meta?.cwd ?? params.host.cwd), + ); const device = await resolveCommandDevice({ session, flags, @@ -296,11 +300,11 @@ function resolveDeployTarget( return { ok: true, app: first, appPathInput: second }; } -function resolvePushPayload(payloadArg: string, cwd?: string): string { +function resolvePushPayload(payloadArg: string, cwd: string): string { const resolved = resolvePayloadInput(payloadArg, { subject: 'Push payload', cwd, - expandPath: (value, currentCwd) => expandSessionPath(value, currentCwd), + expandPath: (value, currentCwd) => expandSessionPath(value, currentCwd ?? cwd), }); return resolved.kind === 'file' ? resolved.path : resolved.text; } diff --git a/packages/daemon/src/sessions/handlers/session-appstate-device-runtime.ts b/packages/daemon/src/sessions/handlers/session-appstate-device-runtime.ts index 0e120a9898..be0dec45ab 100644 --- a/packages/daemon/src/sessions/handlers/session-appstate-device-runtime.ts +++ b/packages/daemon/src/sessions/handlers/session-appstate-device-runtime.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { appStateUse } from '@agent-device/contracts/platform-runtime-operations'; import { isIosFamily, @@ -14,8 +15,11 @@ import { appleSessionRequiredRefusal, } from './session-appstate-apple-session.ts'; -function sortedAndroidSerialAllowlist(value: string | undefined): string[] | undefined { - const allowlist = resolveAndroidSerialAllowlist(value); +function sortedAndroidSerialAllowlist( + value: string | undefined, + env: HostEnvironment['env'], +): string[] | undefined { + const allowlist = resolveAndroidSerialAllowlist(value, env); return allowlist ? [...allowlist].sort() : undefined; } @@ -52,7 +56,10 @@ export async function queryDeviceRuntimeAppState( const runtime = admitted.runtime; await runtime.operations.ensureReady({ serial: flags.serial, - androidSerialAllowlist: sortedAndroidSerialAllowlist(flags.androidDeviceAllowlist), + androidSerialAllowlist: sortedAndroidSerialAllowlist( + flags.androidDeviceAllowlist, + params.host.env, + ), }); const state = await runtime.operations.appState(); return { diff --git a/packages/daemon/src/sessions/handlers/session-boot.ts b/packages/daemon/src/sessions/handlers/session-boot.ts index c71200b8a0..6a94f549f3 100644 --- a/packages/daemon/src/sessions/handlers/session-boot.ts +++ b/packages/daemon/src/sessions/handlers/session-boot.ts @@ -98,6 +98,7 @@ export async function handleBootCommand( const resolvedAndroidSerialAllowlist = resolveAndroidSerialAllowlist( flags.androidDeviceAllowlist, + params.host.env, ); const androidSerialAllowlist = resolvedAndroidSerialAllowlist ? [...resolvedAndroidSerialAllowlist].sort() diff --git a/packages/daemon/src/sessions/handlers/session-command-input.ts b/packages/daemon/src/sessions/handlers/session-command-input.ts index 3e2e61aca5..a0a60750ea 100644 --- a/packages/daemon/src/sessions/handlers/session-command-input.ts +++ b/packages/daemon/src/sessions/handlers/session-command-input.ts @@ -1,4 +1,5 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonPlatformServices } from '../../core/platform-services.ts'; import type { LeaseLifecycleProvider, ProviderAppCatalog } from '@agent-device/contracts/device'; import type { HostDiagnostics } from '@agent-device/contracts/host-diagnostics'; @@ -20,6 +21,7 @@ import type { import type { SessionStore } from '../../core/session-store.ts'; export type SessionCommandInput = { + host: HostEnvironment; req: DaemonRequest; sessionName: string; logPath: string; diff --git a/packages/daemon/src/sessions/handlers/session-doctor-output.ts b/packages/daemon/src/sessions/handlers/session-doctor-output.ts index 0ac3f02c12..7491d9a343 100644 --- a/packages/daemon/src/sessions/handlers/session-doctor-output.ts +++ b/packages/daemon/src/sessions/handlers/session-doctor-output.ts @@ -3,6 +3,7 @@ import { formatDoctorCheckDetailLines, formatDoctorCheckSummaryLine, } from '@agent-device/host-kit/doctor-lines'; +import { shouldColorStatusMarkers } from '@agent-device/host-kit/status-markers'; import type { DoctorCheck, DoctorStatus } from '@agent-device/contracts/observability'; export function summarizeDoctorStatus(checks: DoctorCheck[]): 'pass' | 'warn' | 'fail' { @@ -38,5 +39,10 @@ export function appendDoctorCheck(checks: DoctorCheck[], check: DoctorCheck): vo } function formatDoctorCheckProgressMessage(check: DoctorCheck): string { - return [formatDoctorCheckSummaryLine(check), ...formatDoctorCheckDetailLines(check)].join('\n'); + return [ + formatDoctorCheckSummaryLine(check, { + useColor: shouldColorStatusMarkers(process.env, Boolean(process.stderr.isTTY)), + }), + ...formatDoctorCheckDetailLines(check), + ].join('\n'); } diff --git a/packages/daemon/src/sessions/handlers/session-doctor-warmup.test.ts b/packages/daemon/src/sessions/handlers/session-doctor-warmup.test.ts index 0757554ceb..d17319a46d 100644 --- a/packages/daemon/src/sessions/handlers/session-doctor-warmup.test.ts +++ b/packages/daemon/src/sessions/handlers/session-doctor-warmup.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { beforeEach, expect, test, vi } from 'vitest'; import type { DoctorCheck } from '@agent-device/contracts/observability'; @@ -45,6 +46,7 @@ async function runDoctorWithSessionDevice(device: DeviceInfo): Promise await handleDoctorCommand({ + host: createTestHostEnvironment(), req: { token: 't', session: 'doctor-session', @@ -75,6 +77,7 @@ async function runSessionlessDoctor( inventory, async () => await handleDoctorCommand({ + host: createTestHostEnvironment(), req: { token: 't', session: 'default', command: 'doctor', positionals: [], flags }, sessionName: 'default', sessionStore: makeSessionStore('agent-device-doctor-warmup-'), diff --git a/packages/daemon/src/sessions/handlers/session-doctor.ts b/packages/daemon/src/sessions/handlers/session-doctor.ts index dbb5cbb0f0..90813af791 100644 --- a/packages/daemon/src/sessions/handlers/session-doctor.ts +++ b/packages/daemon/src/sessions/handlers/session-doctor.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import path from 'node:path'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; @@ -49,6 +50,7 @@ import type { import { ensureAppsRuntimeReady, listAppsFromRuntime } from '../../execution/apps-runtime.ts'; export async function handleDoctorCommand(params: { + host: HostEnvironment; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -99,6 +101,7 @@ export async function handleDoctorCommand(params: { inspectFacts, bindDevice, req, + env: params.host.env, }); const warmupDevice = session?.device ?? resolveHostWarmupDevice(inventory, appCheckDevice); if (warmupDevice) { @@ -171,6 +174,7 @@ async function appendLocalDoctorChecks(params: { inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; req: DaemonRequest; + env: HostEnvironment['env']; }): Promise { const { checks, @@ -182,6 +186,7 @@ async function appendLocalDoctorChecks(params: { inspectFacts, bindDevice, req, + env, } = params; const appCheckDevice = session?.device ?? resolveDoctorDeviceForAppCheck(checks, inventory, options.targetApp); @@ -195,6 +200,7 @@ async function appendLocalDoctorChecks(params: { inspectFacts, bindDevice, req, + env, }); } if (options.shouldProbeMetro) { @@ -215,9 +221,10 @@ async function appendDeviceScopedDoctorChecks( inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; req: DaemonRequest; + env: HostEnvironment['env']; }, ): Promise { - const { context, hostDiagnostics, device, options, session, inspectFacts, bindDevice, req } = + const { context, hostDiagnostics, device, options, session, inspectFacts, bindDevice, req, env } = params; let listInstalledApps: DoctorAppInventory | undefined; try { @@ -227,6 +234,7 @@ async function appendDeviceScopedDoctorChecks( targetApp: options.targetApp, inspectFacts, bindDevice, + env, }); } catch (error) { // Keep facts/bind/readiness failures inside appendAppChecks' accumulator path so doctor @@ -259,10 +267,11 @@ async function resolveDoctorAppInventory(params: { targetApp?: string; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; + env: HostEnvironment['env']; }): Promise< ((filter: 'all' | 'user-installed') => Promise) | undefined > { - const { device, req, targetApp, inspectFacts, bindDevice } = params; + const { device, req, targetApp, inspectFacts, bindDevice, env } = params; if (!targetApp || !inspectFacts || !bindDevice) { return undefined; } @@ -274,7 +283,10 @@ async function resolveDoctorAppInventory(params: { device, appsRuntimeUse, ); - const androidSerialAllowlist = resolveAndroidSerialAllowlist(req.flags?.androidDeviceAllowlist); + const androidSerialAllowlist = resolveAndroidSerialAllowlist( + req.flags?.androidDeviceAllowlist, + env, + ); const readyDevice = await ensureAppsRuntimeReady(runtime, { serial: req.flags?.serial, androidSerialAllowlist: androidSerialAllowlist ? [...androidSerialAllowlist].sort() : undefined, diff --git a/packages/daemon/src/sessions/handlers/session-progress.test.ts b/packages/daemon/src/sessions/handlers/session-progress.test.ts index ce17804738..d4116f290c 100644 --- a/packages/daemon/src/sessions/handlers/session-progress.test.ts +++ b/packages/daemon/src/sessions/handlers/session-progress.test.ts @@ -2,7 +2,7 @@ import type { RequestProgressEvent } from '@agent-device/contracts/progress'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { withTestDeviceInventoryProvider } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { withRequestProgressSink } from '@agent-device/host-kit/request'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; @@ -50,6 +50,7 @@ async function runSuite(params: { (event) => events.push(event), async () => await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/handlers/session-replay-command.ts b/packages/daemon/src/sessions/handlers/session-replay-command.ts index ee22ac21bf..2de5f945c0 100644 --- a/packages/daemon/src/sessions/handlers/session-replay-command.ts +++ b/packages/daemon/src/sessions/handlers/session-replay-command.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonPlatformServices } from '../../core/platform-services.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { DaemonRequest } from '../../core/daemon-request.ts'; @@ -50,6 +51,7 @@ export const handleReplayCommand: SessionCommandHandler = async ({ }); export const handleReplayTestCommand: SessionCommandHandler = async ({ + host, req, sessionName, logPath, @@ -92,6 +94,7 @@ export const handleReplayTestCommand: SessionCommandHandler = async ({ dependencies: replayDaemonDependencies, cleanupSession: async (testSessionName) => await closeReplayTestSession({ + host, req, sessionName: testSessionName, logPath, @@ -177,6 +180,7 @@ export function createReplaySession( } type ReplayTestSessionCleanupParams = Readonly<{ + host: HostEnvironment; req: DaemonRequest; sessionName: string; logPath: string; @@ -192,6 +196,7 @@ async function closeReplayTestSession(params: ReplayTestSessionCleanupParams): P const { req, sessionName, logPath, sessionStore } = params; if (!sessionStore.get(sessionName)) return; const closeResponse = await handleSessionCloseCommands({ + host: params.host, req: onDelegatingLedger( { token: req.token, diff --git a/packages/daemon/src/sessions/handlers/session.ts b/packages/daemon/src/sessions/handlers/session.ts index 176e226d55..e3dbd80e07 100644 --- a/packages/daemon/src/sessions/handlers/session.ts +++ b/packages/daemon/src/sessions/handlers/session.ts @@ -37,6 +37,7 @@ const handleSessionInventoryCommandGroup: SessionCommandHandler = ( const handleSessionOpenCommandGroup: SessionCommandHandler = (params) => handleSessionOpenCommands({ + host: params.host, req: params.req, createCommandSurface: params.createCommandSurface, sessionName: params.sessionName, @@ -50,6 +51,7 @@ const handleSessionOpenCommandGroup: SessionCommandHandler = (params) => const handleSessionCloseCommandGroup: SessionCommandHandler = (params) => handleSessionCloseCommands({ + host: params.host, req: params.req, sessionName: params.sessionName, logPath: params.logPath, @@ -63,6 +65,7 @@ const handleSessionCloseCommandGroup: SessionCommandHandler = (params) => } satisfies SessionCloseCommandInput); const handleSessionObservabilityCommandGroup: SessionCommandHandler = async ({ + host, req, sessionName, sessionStore, @@ -75,6 +78,7 @@ const handleSessionObservabilityCommandGroup: SessionCommandHandler = async ({ throwIfCanceled, }) => await handleSessionObservabilityCommands({ + host, req, sessionName, sessionStore, @@ -103,6 +107,7 @@ const SESSION_COMMAND_HANDLER_IMPLS = { capabilities: handleSessionInventoryCommandGroup, apps: handleSessionInventoryCommandGroup, doctor: async ({ + host, req, sessionName, sessionStore, @@ -112,6 +117,7 @@ const SESSION_COMMAND_HANDLER_IMPLS = { bindDevice, }) => await handleDoctorCommand({ + host, req, sessionName, sessionStore, @@ -204,6 +210,7 @@ export async function handleSessionCommands( params: SessionCommandInput, ): Promise { const { + host, req, sessionName, logPath, @@ -235,6 +242,7 @@ export async function handleSessionCommands( if (!handler) return null; return await handler({ + host, req, createCommandSurface: params.createCommandSurface, sessionName, diff --git a/packages/daemon/src/sessions/handlers/trace-runtime.ts b/packages/daemon/src/sessions/handlers/trace-runtime.ts index bbb4cb3319..54a6ddaf94 100644 --- a/packages/daemon/src/sessions/handlers/trace-runtime.ts +++ b/packages/daemon/src/sessions/handlers/trace-runtime.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import fs from 'node:fs'; @@ -10,6 +11,7 @@ import { recordSessionAction } from '../../core/session-action-recorder.ts'; import { errorResponse } from '@agent-device/kernel/contracts'; export function handleTraceCommand(params: { + host: HostEnvironment; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -21,18 +23,22 @@ export function handleTraceCommand(params: { const ref = params.sessionStore.lookup(params.sessionName); if (!ref) return errorResponse('SESSION_NOT_FOUND', 'No active session'); return action === 'start' - ? startTrace(params.req, params.sessionStore, ref) - : stopTrace(params.req, params.sessionStore, ref); + ? startTrace(params.host, params.req, params.sessionStore, ref) + : stopTrace(params.host, params.req, params.sessionStore, ref); } function startTrace( + host: HostEnvironment, req: DaemonRequest, sessionStore: SessionStore, ref: SessionRef, ): DaemonResponse { const session = sessionStore.requireCurrent(ref); if (session.trace) return errorResponse('INVALID_ARGS', 'trace already in progress'); - const outPath = expandSessionPath(req.positionals?.[1] ?? sessionStore.defaultTracePath(session)); + const outPath = expandSessionPath( + req.positionals?.[1] ?? sessionStore.defaultTracePath(session), + host.cwd, + ); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.appendFileSync(outPath, ''); session.trace = { outPath, startedAt: Date.now() }; @@ -44,13 +50,14 @@ function startTrace( } function stopTrace( + host: HostEnvironment, req: DaemonRequest, sessionStore: SessionStore, ref: SessionRef, ): DaemonResponse { const session = sessionStore.requireCurrent(ref); if (!session.trace) return errorResponse('INVALID_ARGS', 'no active trace'); - const outPath = relocateTraceOutput(session.trace.outPath, req.positionals?.[1]); + const outPath = relocateTraceOutput(host, session.trace.outPath, req.positionals?.[1]); session.trace = undefined; recordSessionAction(sessionStore, ref, req, req.command, { action: 'stop', outPath }); const clientOutPath = req.meta?.clientArtifactPaths?.outPath ?? outPath; @@ -72,9 +79,13 @@ function stopTrace( }; } -function relocateTraceOutput(currentPath: string, requestedPath: string | undefined): string { +function relocateTraceOutput( + host: HostEnvironment, + currentPath: string, + requestedPath: string | undefined, +): string { if (!requestedPath) return currentPath; - const resolved = expandSessionPath(requestedPath); + const resolved = expandSessionPath(requestedPath, host.cwd); fs.mkdirSync(path.dirname(resolved), { recursive: true }); if (fs.existsSync(currentPath)) fs.renameSync(currentPath, resolved); else fs.appendFileSync(resolved, ''); diff --git a/packages/daemon/src/sessions/lease-lifecycle.ts b/packages/daemon/src/sessions/lease-lifecycle.ts index c9c45757a4..2f44339e88 100644 --- a/packages/daemon/src/sessions/lease-lifecycle.ts +++ b/packages/daemon/src/sessions/lease-lifecycle.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { leaseScopeToReleaseRequest } from '@agent-device/contracts/lease-scope'; import { clearDeviceClaim } from '@agent-device/daemon-contracts/device-claims'; @@ -54,6 +55,7 @@ export async function releaseExpiredProviderLease( } export async function cleanupExpiredLeasedSession(params: { + env: HostEnvironment['env']; sessionName: string; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; @@ -63,7 +65,13 @@ export async function cleanupExpiredLeasedSession(params: { const lease = ref?.session.lease; if (!ref || !lease) return false; if (!params.leaseRegistry.consumeExpiredLease(lease.leaseId)) return false; - await endLeasedSession(ref, params.sessionStore, params.teardownSession, 'LEASE_EXPIRED'); + await endLeasedSession( + ref, + params.sessionStore, + params.teardownSession, + 'LEASE_EXPIRED', + params.env, + ); return true; } @@ -76,6 +84,7 @@ export async function endLeasedSession( sessionStore: SessionStore, teardownSession: SessionTeardown, reason: LeaseEndReason, + env: HostEnvironment['env'], ): Promise { const session = ref.session; const leaseId = session.lease?.leaseId; @@ -91,7 +100,7 @@ export async function endLeasedSession( data: { reason, leaseId, session: session.name, error: errorMessage(error) }, }); }); - await clearDeviceClaim(session.deviceClaim).catch((error) => { + await clearDeviceClaim(session.deviceClaim, env).catch((error) => { emitDiagnostic({ level: 'warn', phase: 'leased_session_end_device_claim_clear_failed', @@ -107,6 +116,7 @@ export async function endLeasedSession( } export function admitRequestLeaseForLockedScope(params: { + env: HostEnvironment['env']; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -118,6 +128,7 @@ export function admitRequestLeaseForLockedScope(params: { const ref = sessionStore.lookup(sessionName); const existingSession = ref?.session; const activeLease = assertRequestLeaseAdmission(params.req, leaseRegistry, existingSession, { + env: params.env, providerAppCatalog: params.providerAppCatalog, daemonPolicy: params.daemonPolicy, }); diff --git a/packages/daemon/src/sessions/request-admission.ts b/packages/daemon/src/sessions/request-admission.ts index 88bbed63d8..0a49b49a98 100644 --- a/packages/daemon/src/sessions/request-admission.ts +++ b/packages/daemon/src/sessions/request-admission.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { AppError } from '@agent-device/kernel/errors'; import { isTenantOwnedSessionName, normalizeTenantId } from '@agent-device/contracts/daemon-http'; import { resolveSessionIsolationMode } from '../core/config.ts'; @@ -69,11 +70,12 @@ export function scopeRequestSession(req: DaemonRequest): DaemonRequest { export function assertRequestLeaseAdmission( req: DaemonRequest, leaseRegistry: LeaseRegistry, - session?: SessionState, + session: SessionState | undefined, options: Readonly<{ + env: HostEnvironment['env']; providerAppCatalog?: ProviderAppCatalog; daemonPolicy?: DaemonPolicy; - }> = {}, + }>, ): DeviceLease | undefined { if (isExemptFromLeaseAdmission(req, leaseRegistry, session, options.daemonPolicy)) { return undefined; @@ -83,7 +85,7 @@ export function assertRequestLeaseAdmission( const sessionLease = session?.lease; if (options.daemonPolicy?.requiredLeaseBackend) { const leased = Boolean(resolveRequestOrSessionLeaseScope(req, session).leaseId); - const lease = leased ? admitLease(req, leaseRegistry, session) : undefined; + const lease = leased ? admitLease(req, leaseRegistry, session, options.env) : undefined; assertDaemonPolicyAdmitsLease(options.daemonPolicy, lease); return lease; } @@ -104,13 +106,14 @@ export function assertRequestLeaseAdmission( if (!requestLeaseScope.leaseId) return undefined; if (!requestLeaseScope.tenantId && !requestLeaseScope.runId) return undefined; } - return admitLease(req, leaseRegistry, session); + return admitLease(req, leaseRegistry, session, options.env); } function admitLease( req: DaemonRequest, leaseRegistry: LeaseRegistry, session: SessionState | undefined, + env: HostEnvironment['env'], ): DeviceLease { assertRequestSessionLeaseMatches(resolveLeaseScope(req), session?.lease); const leaseScope = resolveRequestOrSessionLeaseScope(req, session); @@ -119,7 +122,7 @@ function admitLease( // Naming a proxy-specific default here used to shorten every lease allocated above it — a client // that rented a device for longer than the default lost it on the next admitted command (#2946). const lease = leaseRegistry.heartbeatLease(leaseScopeToHeartbeatRequest(leaseScope)); - assertMacOsAppLeaseAdmitsRequest(lease, req, session); + assertMacOsAppLeaseAdmitsRequest(lease, req, session, env); if (isHumanControlMutation(req)) leaseRegistry.assertHumanControlAdmission(lease); return lease; } diff --git a/packages/daemon/src/sessions/request-handler-chain-routing.test.ts b/packages/daemon/src/sessions/request-handler-chain-routing.test.ts index 9e0c980177..df2308a163 100644 --- a/packages/daemon/src/sessions/request-handler-chain-routing.test.ts +++ b/packages/daemon/src/sessions/request-handler-chain-routing.test.ts @@ -9,7 +9,12 @@ import { LeaseRegistry } from '../resources/lease-registry.ts'; import { runRequestHandlerChain } from './request-handler-chain.ts'; import { getDaemonRouteOwnerFiles } from '@agent-device/command-registry/owner-files'; import type { DaemonRequest } from '../index.ts'; -import { eagerClosureOf, LINUX_DEVICE } from '@agent-device/test-kit'; +import { + bindTestCommandHost, + eagerClosureOf, + LINUX_DEVICE, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { buildGesturePlan, singlePointerPlanEndpoints } from '@agent-device/contracts/gesture-plan'; import { makeIosSession, makeSession } from '../__tests__/support/session-factories.ts'; import { makeSessionStore } from '../__tests__/support/store-factory.ts'; @@ -31,6 +36,8 @@ import { createAudioProbeAdmissionLedger } from '@agent-device/capture-kit/audio import { createPerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; import { createTouchCommandSurface } from '../__tests__/support/touch-command-surface-fake.ts'; + +bindTestCommandHost(); const testCreateCommandSurface = createTouchCommandSurface(); function makeRequest(command: string, positionals: string[] = []): DaemonRequest { @@ -48,6 +55,7 @@ function makeChainParams(req: DaemonRequest) { const sessionStore = makeSessionStore('agent-device-request-chain-'); sessionStore.publish('chain-test', makeIosSession('chain-test')); return { + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, req, sessionName: 'chain-test', diff --git a/packages/daemon/src/sessions/request-handler-chain.ts b/packages/daemon/src/sessions/request-handler-chain.ts index 11d240e844..8337337398 100644 --- a/packages/daemon/src/sessions/request-handler-chain.ts +++ b/packages/daemon/src/sessions/request-handler-chain.ts @@ -26,11 +26,14 @@ import type { HostDiagnostics } from '@agent-device/contracts/host-diagnostics'; import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import type { RequestPlatformProviderScope } from '@agent-device/contracts/platform-providers'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { CreateDaemonCommandSurface } from '@agent-device/contracts/daemon-command-surface'; import type { PlatformResourceCleanup } from '../core/platform-resource-cleanup.ts'; import type { DaemonProviderCredentials } from '@agent-device/daemon-contracts/provider-credentials'; type RequestHandlerChainParams = { + /** The request's bound host: the environment and working directory its routes read configuration from. */ + host: HostEnvironment; req: DaemonRequest; sessionName: string; logPath: string; @@ -153,6 +156,7 @@ async function runLeaseHandler( params.req.command, 'lease', await handleLeaseCommands({ + host: params.host, req: params.req, sessionName: params.sessionName, sessionStore: params.sessionStore, @@ -175,6 +179,7 @@ async function runSessionHandler( params.req.command, 'session', await handleSessionCommands({ + host: params.host, req: params.req, sessionName: params.sessionName, logPath: params.logPath, @@ -257,6 +262,7 @@ async function runRecordTraceHandler( params.req.command, 'record-trace', await handleRecordTraceCommands({ + host: params.host, req: params.req, sessionName: params.sessionName, sessionStore: params.sessionStore, diff --git a/packages/daemon/src/sessions/session-lifecycle/__tests__/application.test.ts b/packages/daemon/src/sessions/session-lifecycle/__tests__/application.test.ts index b977f6a7c8..bc5f36a645 100644 --- a/packages/daemon/src/sessions/session-lifecycle/__tests__/application.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/__tests__/application.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; @@ -37,6 +38,8 @@ import { handleSessionCloseCommands } from '../internal/session-close.ts'; import { handleSessionInventoryCommands } from '../internal/inventory.ts'; import { handleSessionOpenCommands } from '../internal/session-open.ts'; +bindTestCommandHost(); + const mockHandleSessionCloseCommands = vi.mocked(handleSessionCloseCommands); const mockHandleSessionInventoryCommands = vi.mocked(handleSessionInventoryCommands); const mockHandleSessionOpenCommands = vi.mocked(handleSessionOpenCommands); @@ -59,6 +62,7 @@ function request(command: DaemonRequest['command']): DaemonRequest { async function run(command: DaemonRequest['command']): Promise { return await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: request(command), @@ -118,6 +122,7 @@ test('open routes only its lifecycle input through its owner', async () => { expect(Object.keys(forwarded ?? {}).sort()).toEqual( [ 'bindDevice', + 'host', 'createCommandSurface', 'inspectFacts', 'logPath', @@ -145,6 +150,7 @@ test('close routes only its lifecycle input through its owner', async () => { expect(Object.keys(forwarded ?? {}).sort()).toEqual( [ 'bindDevice', + 'host', 'inspectFacts', 'leaseLifecycleProvider', 'leaseRegistry', diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts index 8041eb4b7d..4d61e38cd3 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts @@ -11,7 +11,7 @@ import type { } from '../../../../core/request-runtime-binding.ts'; import { createCapabilitiesAdmissionRuntime } from './session-capabilities.fixtures.ts'; import { handleSessionCommands } from '../../../../__tests__/support/handlers/session-command-harness.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; test('capabilities projects the install family from exactly one facts inspection', async () => { @@ -157,6 +157,7 @@ async function dispatchCapabilities(params: { inspectFacts: InspectDeviceRuntimeFacts; }) { return await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities.test.ts index cff49618f7..37ee0e5680 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities.test.ts @@ -8,6 +8,7 @@ import { mkdtempForTestSync, unavailableDeploymentSnapshotAndShutdownOperationFacts, WEB_DESKTOP_DEVICE, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { makeAndroidSession, @@ -62,6 +63,7 @@ async function projectAndroidCapabilities(sessionName: string) { providerMode: 'local', }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -146,6 +148,7 @@ test('capabilities excludes logs from an unavailable provider-mode XCTest runtim }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -191,6 +194,7 @@ test('capabilities excludes network when the runtime fact is unavailable', async }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -237,6 +241,7 @@ test('capabilities includes apps for the available HarmonyOS runtime fact', asyn async (request) => (request.platform === 'harmonyos' ? [harmonyDevice] : []), async () => await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -317,6 +322,7 @@ test.each(APPS_UNAVAILABLE_CAPABILITY_CASES)( }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -353,6 +359,7 @@ test('capabilities excludes appstate when its runtime fact is unavailable', asyn }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -388,6 +395,7 @@ test('capabilities excludes appstate when its readiness fact is unavailable', as }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -465,6 +473,7 @@ test.each([ async () => [device], async () => await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -503,6 +512,7 @@ test('capabilities accepts a stopped Android AVD placeholder for explicit platfo async (request) => (request.platform === 'android' ? [stoppedAvd] : []), async () => await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts index 57171f40c6..bf60a6b655 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts @@ -14,10 +14,16 @@ import { sessionCloseShutdownFixture, type SessionState, } from './session-close-shutdown.fixtures.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { encodeHostAudioProbeDescriptor } from '@agent-device/capture-kit/audio-probe-descriptor-fixtures'; import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; +bindTestCommandHost(); + const { handleSessionCommands, localRuntimeOwner, @@ -53,6 +59,7 @@ const IOS_SIM: SessionState['device'] = { async function closeAtAddress(sessionStore: ReturnType) { return await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: ADDRESS, command: 'close', positionals: [], flags: {} }, diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-ended-lease.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-ended-lease.test.ts index b672f2f402..3964932f3e 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-ended-lease.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-ended-lease.test.ts @@ -2,7 +2,11 @@ import { daemonPlatformServicesFixture } from '../../../../__tests__/support/pla import { beforeEach, expect, test, vi } from 'vitest'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { SessionStore } from '../../../../core/session-store.ts'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { DaemonRequest } from '../../../../core/daemon-request.ts'; @@ -13,6 +17,8 @@ import { handleSessionCommands } from '../../../../__tests__/support/handlers/se import { dispatchApplicationLifecycleEffect } from '../../../../__tests__/support/application-lifecycle-runtime-fixture.ts'; import type { EndedLeaseLedger } from '../../../../core/request-ended-leases.ts'; +bindTestCommandHost(); + const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {} }); const SESSION_NAME = 'close-ended-lease'; @@ -71,6 +77,7 @@ function close( sessionStore.publish(SESSION_NAME, session); const endedLeases: EndedLeaseLedger = []; const response = handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts index 975a8d06ca..fc62f46eac 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../../../__tests__/support/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import { @@ -7,6 +8,8 @@ import { import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; import { lifecycleOperationsSpy } from '../../../../__tests__/support/lifecycle-operations-spy.ts'; +bindTestCommandHost(); + const { acquireDeviceClaim, AppError, @@ -48,6 +51,7 @@ test('a failed platform close retains the device claim and reports it', async () booted: true, }; const acquired = await acquireDeviceClaim({ + env: process.env, device, session: sessionName, workspace: process.cwd(), @@ -77,6 +81,7 @@ test('a failed platform close retains the device claim and reports it', async () let caught: unknown; try { await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -105,7 +110,7 @@ test('a failed platform close retains the device claim and reports it', async () expect(sessionStore.get(sessionName)).toBeUndefined(); // The claim itself was NOT cleared: it is still live, still naming the deleted session. - const claimState = inspectDeviceClaims({ serial: device.id })[0]; + const claimState = inspectDeviceClaims({ serial: device.id }, process.env)[0]; expect(claimState?.classification).toBe('live'); expect(claimState?.claim?.session).toBe(sessionName); @@ -150,6 +155,7 @@ test('a failing best-effort cleanup also retains the device claim and reports it booted: true, }; const acquired = await acquireDeviceClaim({ + env: process.env, device, session: sessionName, workspace: process.cwd(), @@ -192,6 +198,7 @@ test('a failing best-effort cleanup also retains the device claim and reports it let caught: unknown; try { await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -222,7 +229,7 @@ test('a failing best-effort cleanup also retains the device claim and reports it }); expect(sessionStore.get(sessionName)).toBeUndefined(); - const claimState = inspectDeviceClaims({ serial: device.id })[0]; + const claimState = inspectDeviceClaims({ serial: device.id }, process.env)[0]; expect(claimState?.classification).toBe('live'); expect(claimState?.claim?.session).toBe(sessionName); @@ -260,6 +267,7 @@ test('a successful close clears the device claim', async () => { booted: true, }; const acquired = await acquireDeviceClaim({ + env: process.env, device, session: sessionName, workspace: process.cwd(), @@ -279,6 +287,7 @@ test('a successful close clears the device claim', async () => { sessionStore.publish(sessionName, session); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -296,7 +305,7 @@ test('a successful close clears the device claim', async () => { expect(response?.ok).toBe(true); expect(sessionStore.get(sessionName)).toBeUndefined(); - expect(inspectDeviceClaims({ serial: device.id })).toEqual([]); + expect(inspectDeviceClaims({ serial: device.id }, process.env)).toEqual([]); } finally { if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; @@ -327,6 +336,7 @@ test('targeted close preserves the platform-close AppError and still runs later await expect( handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), lifecycleOperationsLoader: loader, @@ -381,6 +391,7 @@ test('targeted close surfaces a required pre-close failure from the lifecycle po await expect( handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), lifecycleOperationsLoader: loader, diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts index 06dcce1e51..506a5ad03b 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts @@ -24,7 +24,13 @@ import { clearRuntimeHintsFixture, dispatchApplicationLifecycleEffect, } from '../../../../__tests__/support/application-lifecycle-runtime-fixture.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; + +bindTestCommandHost(); const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {} }); @@ -61,6 +67,7 @@ async function close(params: { bindDevice?: Parameters[0]['bindDevice']; }) { return await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: closeRequest(params.sessionName, params.positionals ?? [], params.internal), diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts index d3eb9e1c6a..77f31eda9d 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts @@ -7,11 +7,17 @@ import { sessionCloseShutdownFixture, type SessionState, } from './session-close-shutdown.fixtures.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { encodeHostAudioProbeDescriptor } from '@agent-device/capture-kit/audio-probe-descriptor-fixtures'; import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; import { platformResourceCleanupFixture } from '../../../../__tests__/support/platform-resource-cleanup-fixture.ts'; +bindTestCommandHost(); + const { AppError, handleSessionCommands, @@ -45,6 +51,7 @@ test('close stops Android snapshot helper session before deleting session', asyn const platformCleanup = platformResourceCleanupFixture(); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), platformResourceCleanup: platformCleanup, @@ -143,6 +150,7 @@ test('close stops active host audio probe before deleting session', async () => ); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -169,6 +177,7 @@ test('close dispatches web session cleanup without a positional target', async ( sessionStore.publish(sessionName, makeSession(sessionName, WEB_DESKTOP_DEVICE)); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -222,6 +231,7 @@ test('close preserves the session and lease when provider release fails so it ca let releaseAttempts = 0; const request = { + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, req: { token: 't', @@ -302,6 +312,7 @@ test('close cannot retire a replacement session while its provider release waits return { releasedBy: 'provider' }; }); const closing = handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: address, command: 'close', positionals: [], flags: {} }, diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-save-script.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-save-script.test.ts index cee7a01b44..3893c4451c 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-save-script.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-save-script.test.ts @@ -1,10 +1,16 @@ import { daemonPlatformServicesFixture } from '../../../../__tests__/support/platform-services-fixture.ts'; import { beforeEach, expect, test } from 'vitest'; import { sessionCloseShutdownFixture } from './session-close-shutdown.fixtures.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; import { lifecycleOperationsSpy } from '../../../../__tests__/support/lifecycle-operations-spy.ts'; +bindTestCommandHost(); + const { handleSessionCommands, fs, @@ -40,6 +46,7 @@ test('close --save-script on a session with an active .ad repair transaction is sessionStore.publish(sessionName, session); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -77,6 +84,7 @@ test('close --save-script on a never-armed session is rejected before teardown, await expect( handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), lifecycleOperationsLoader: loader, @@ -107,6 +115,7 @@ test('close --save-script on a never-armed session is rejected before teardown, expect(finalizeApplicationClose).not.toHaveBeenCalled(); const plainClose = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), lifecycleOperationsLoader: loader, diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts index eb964dfbaf..ab0c93dedb 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts @@ -5,10 +5,16 @@ import { type DeviceBinding, type PlatformRuntimeOperations, } from './session-close-shutdown.fixtures.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; import { lifecycleOperationsSpy } from '../../../../__tests__/support/lifecycle-operations-spy.ts'; +bindTestCommandHost(); + const { handleSessionCommands, lifecycleRuntimeFacts, @@ -74,6 +80,7 @@ test('close --shutdown calls shutdownSimulator for iOS simulator and includes re }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -141,6 +148,7 @@ test('close --shutdown keeps a selected provider-owned iOS simulator off local s }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -185,6 +193,7 @@ test('close --shutdown calls shutdownAndroidEmulator for Android emulator and in }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -238,6 +247,7 @@ test('close --shutdown returns success and failure payload when shutdownAndroidE }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -280,6 +290,7 @@ test('close --shutdown returns success and failure payload when shutdownSimulato }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -329,6 +340,7 @@ test.each([ const { loader, finalizeApplicationClose } = lifecycleOperationsSpy(); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), lifecycleOperationsLoader: loader, diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-without-session-mode.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-without-session-mode.test.ts index 6d4bd0655d..43cef9c985 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-without-session-mode.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-without-session-mode.test.ts @@ -8,7 +8,7 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { DaemonRequest } from '../../../../core/daemon-request.ts'; import { SessionStore } from '../../../../core/session-store.ts'; import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; const mockResolveTargetDevice = vi.hoisted(() => vi.fn()); @@ -48,6 +48,7 @@ test('an app-only kill close with no stored session carries mode kill to the pla const seenInputs: CloseApplicationInput[] = []; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts index 992119acef..29b07f682d 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts @@ -25,7 +25,13 @@ import type { DaemonRequest } from '../../../../core/daemon-request.ts'; import { withTestDeviceInventory } from '@agent-device/device-selection/inventory-gateways-fixtures'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; + +bindTestCommandHost(); const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, @@ -42,6 +48,7 @@ async function runDevices( { local: async () => inventory }, async () => await handleSessionInventoryCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', command: 'devices', positionals: [], flags }, sessionName: 'default', @@ -393,6 +400,7 @@ test('close clears retained materialized install paths bound to the session', as mockCleanupRetainedMaterializedPaths.mockImplementation(realCleanup); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, command: 'close', positionals: [], flags: {} }, diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-appleos.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-appleos.test.ts index 087d9c39df..6d3e5d4d64 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-appleos.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-appleos.test.ts @@ -26,8 +26,12 @@ import { MACOS_DEVICE, TVOS_SIMULATOR, VISIONOS_SIMULATOR, + bindTestCommandHost, + createTestHostEnvironment, } from '@agent-device/test-kit'; +bindTestCommandHost(); + const mockListDeviceInventory = vi.mocked(listDeviceInventory); beforeEach(() => { @@ -45,6 +49,7 @@ async function runDevices(): Promise { flags: {}, }; return handleSessionInventoryCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req, sessionName: 'default', diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts index aacc216d59..4168e3a738 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts @@ -13,7 +13,11 @@ import { narrowDeviceBinding, } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; -import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '@agent-device/test-kit'; +import { + unavailableDeploymentSnapshotAndShutdownOperationFacts, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts, @@ -23,6 +27,8 @@ import { registerRequestAbort, } from '@agent-device/host-kit/request'; +bindTestCommandHost(); + const MACOS_DEVICE = { platform: 'apple' as const, appleOs: 'macos' as const, @@ -118,6 +124,7 @@ test('macOS apps consumes generic readiness and app inventory through one runtim }; const response = await handleSessionInventoryCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req, sessionName, @@ -148,6 +155,7 @@ test('deferred provider apps returns uploaded assets without resolving a device' }; const response = await handleSessionInventoryCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req, sessionName: req.session, @@ -196,6 +204,7 @@ test('deferred provider apps forwards request cancellation to the catalog', asyn try { const response = handleSessionInventoryCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req, sessionName: req.session, @@ -224,6 +233,7 @@ test('deferred provider apps forwards public daemon access to the catalog', asyn }; await handleSessionInventoryCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req, sessionName: req.session, diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts index 80633c230f..8e1630d42a 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts @@ -14,12 +14,18 @@ import { narrowDeviceBinding, } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; -import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '@agent-device/test-kit'; +import { + unavailableDeploymentSnapshotAndShutdownOperationFacts, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts, } from '../../../../core/request-runtime-binding.ts'; +bindTestCommandHost(); + const HARMONY_DEVICE = { platform: 'harmonyos' as const, id: 'harmony-1', @@ -112,6 +118,7 @@ async function listApps(appsFilter: 'all' | 'user-installed'): Promise { flags: {}, }; return await handleSessionInventoryCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req, sessionName: SCOPED_KEY, @@ -87,6 +88,7 @@ test('session list returns the caller cwd and local named sessions without cross }); const response = await handleSessionInventoryCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req, sessionName: callerSessionAddress, @@ -130,6 +132,7 @@ test('session list returns only sessions owned by the requesting tenant', async }; const response = await handleSessionInventoryCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req, sessionName: req.session, diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index c5d6f38468..ac1f6b1763 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -77,7 +77,13 @@ import { makeSessionStore, noopInvoke, } from './session-open-runtime.fixtures.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; + +bindTestCommandHost(); const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsFixture); @@ -132,6 +138,7 @@ test('open runtime payload replaces stored session runtime atomically', async () }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -213,6 +220,7 @@ test('open runtime payload clears stale applied transport hints before launch', }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -266,6 +274,7 @@ test('open runtime payload rejects invalid metro port before app launch', async }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -308,6 +317,7 @@ test('open runtime payload rejects malformed runtime objects without mutating se mockResolveTargetDevice.mockResolvedValue(makeAndroidEmulator()); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -353,6 +363,7 @@ test('open runtime payload does not persist replacement when launch fails', asyn await expect( handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -396,6 +407,7 @@ test('a first open keeps both positionals so the deep link still reaches the app }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -442,6 +454,7 @@ test('open reports the launch confirmation its platform answered', async () => { }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -495,6 +508,7 @@ function invokeHeldOpen( req?: Partial, ) { return handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -679,6 +693,7 @@ async function openIosApp(params: { mockResolveTargetDevice.mockResolvedValue(makeAppleSimulator()); const { loader } = lifecycleOperationsSpy({ openApplication: params.openApplication }); return await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), lifecycleOperationsLoader: loader, @@ -707,6 +722,7 @@ async function openAndroidApp(params: { ); const { loader } = lifecycleOperationsSpy({ openApplication }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), lifecycleOperationsLoader: loader, @@ -834,6 +850,7 @@ test.each([ const { loader } = lifecycleOperationsSpy({ prepareApplicationOpen }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), lifecycleOperationsLoader: loader, @@ -879,6 +896,7 @@ async function openReportingObservation( }; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), lifecycleOperationsLoader: loader, diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts index a2864df527..cd06a4cddd 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-existing.test.ts @@ -15,7 +15,7 @@ import { import { resolveOpenTargetFixture } from '../../../../__tests__/support/application-lifecycle-runtime-fixture.ts'; import type { SessionState } from '../../../../core/session-state.ts'; import { handleSessionCommands } from '../../../../__tests__/support/handlers/session-command-harness.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; const mockResolveOpenTarget = vi.mocked(resolveOpenTargetFixture); @@ -59,6 +59,7 @@ test('open on existing iOS session refreshes unavailable simulator by name', asy const response = await withMockedPlatform('darwin', async () => handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -119,6 +120,7 @@ test('open on existing Android session preserves a comparable freshness baseline mockDispatch.mockResolvedValue(undefined); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -228,6 +230,7 @@ test.each([ mockResolveOpenTarget.mockResolvedValueOnce(resolved); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -286,6 +289,7 @@ test('open app and URL on existing iOS device session keeps app context', async }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -324,6 +328,7 @@ test('open reports a platform surface refusal as an INVALID_ARGS response', asyn }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture({ resolveRequestedOpenSurface }), req: { @@ -373,6 +378,7 @@ test('open on existing macOS frontmost-app session carries the stored surface wi }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { 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 d934f5640f..2b814379ea 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 @@ -67,7 +67,13 @@ import { makeSessionStore, noopInvoke, } from './session-open-runtime.fixtures.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; + +bindTestCommandHost(); const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsFixture); @@ -115,6 +121,7 @@ test('open applies stored runtime launchUrl and reports runtime hints', async () }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -180,6 +187,7 @@ test('open rejects a false runtime-hints fact before its one implementation bind }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -233,6 +241,7 @@ test('open applies launch-only flags only to the direct app launch before runtim }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -277,6 +286,7 @@ test('open --metro-port alone defaults the host to 10.0.2.2 on an Android emulat }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -310,6 +320,7 @@ test('open --metro-port alone defaults the host to 127.0.0.1 on an iOS simulator }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -343,6 +354,7 @@ test('open --metro-port alone stays host-ambiguous on a physical Android device' }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -377,6 +389,7 @@ test('open --relaunch allows Android package names ending with apk-like suffix', }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts index 36c4a92bce..e772f7c6f3 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { beforeEach, expect, test, vi } from 'vitest'; import { sessionCloseShutdownFixture, @@ -8,6 +9,8 @@ import { daemonPlatformServicesFixture } from '../../../../__tests__/support/pla import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; import { lifecycleOperationsSpy } from '../../../../__tests__/support/lifecycle-operations-spy.ts'; +bindTestCommandHost(); + const { AppError, handleSessionCommands, @@ -196,6 +199,7 @@ test('close finalizes an active iOS simulator recording before deleting the sess const { loader, finalizeApplicationClose } = lifecycleOperationsSpy(); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), platformResourceCleanup, @@ -232,6 +236,7 @@ test('close surfaces a recording finalization failure through the cleanup-failur await expect( handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), lifecycleOperationsLoader: loader, diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts b/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts index d83e36c570..ad01c3bd5b 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { resolveSessionRunnerLogPath } from '@agent-device/daemon-contracts/session-artifact-paths'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { DaemonPlatformServices } from '../../../core/platform-services.ts'; @@ -60,6 +61,7 @@ import { resolveLeaseScope } from '../../../core/lease-context.ts'; import { getRequestSignal } from '@agent-device/host-kit/request'; export type SessionInventoryCommandInput = Readonly<{ + host: HostEnvironment; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -77,7 +79,7 @@ export async function handleSessionInventoryCommands( case 'session_list': return sessionListInventoryResponse(req, sessionStore); case 'devices': - return await devicesInventoryResponse(req); + return await devicesInventoryResponse(req, params.host.env); case 'capabilities': return await capabilitiesInventoryResponse({ req, @@ -88,6 +90,7 @@ export async function handleSessionInventoryCommands( }); case 'apps': return await handleAppsInventory({ + env: params.host.env, req, sessionName, sessionStore, @@ -142,13 +145,16 @@ function publicSessionInfo({ address, session }: SessionRef, sessionStore: Sessi }; } -async function devicesInventoryResponse(req: DaemonRequest): Promise { +async function devicesInventoryResponse( + req: DaemonRequest, + env: HostEnvironment['env'], +): Promise { try { - const blockingClaims = blockingClaimOwnersByDevice(); + const blockingClaims = blockingClaimOwnersByDevice(env); return { ok: true, data: { - devices: (await resolveInventoryDevices(req)).map((device) => ({ + devices: (await resolveInventoryDevices(req, env)).map((device) => ({ ...publicDeviceInfo(device), ...claimedByProjection(device, blockingClaims), })), @@ -167,9 +173,11 @@ async function devicesInventoryResponse(req: DaemonRequest): Promise { +function blockingClaimOwnersByDevice( + env: HostEnvironment['env'], +): Map { const owners = new Map(); - for (const entry of inspectDeviceClaims({})) { + for (const entry of inspectDeviceClaims({}, env)) { const claim = entry.claim; if (!claim || deviceClaimOwnerCannotRelease(entry.classification)) continue; owners.set(claim.deviceKey, { @@ -188,13 +196,20 @@ function claimedByProjection( return owner ? { claimedBy: owner } : {}; } -async function resolveInventoryDevices(req: DaemonRequest): Promise { +async function resolveInventoryDevices( + req: DaemonRequest, + env: HostEnvironment['env'], +): Promise { const requestedPlatform = req.flags?.platform; - const devices = await listDeviceInventory(inventoryDeviceQuery(req, requestedPlatform)); + const devices = await listDeviceInventory(inventoryDeviceQuery(req, requestedPlatform, env)); return filterInventoryDevices(devices, req.flags?.platform, req.flags?.target); } -function inventoryDeviceQuery(req: DaemonRequest, platform: PlatformSelector | undefined) { +function inventoryDeviceQuery( + req: DaemonRequest, + platform: PlatformSelector | undefined, + env: HostEnvironment['env'], +) { const flags = req.flags; return { platform, @@ -206,8 +221,9 @@ function inventoryDeviceQuery(req: DaemonRequest, platform: PlatformSelector | u flags?.iosSimulatorDeviceSet, platform, flags?.target, + env, ), - androidSerialAllowlist: inventoryAndroidSerialAllowlist(flags?.androidDeviceAllowlist), + androidSerialAllowlist: inventoryAndroidSerialAllowlist(flags?.androidDeviceAllowlist, env), }; } @@ -215,16 +231,21 @@ function inventoryIosSimulatorSetPath( configuredPath: string | undefined, platform: PlatformSelector | undefined, target: DeviceInfo['target'], + env: HostEnvironment['env'], ) { return resolveRequestAppleSimulatorSetPath({ requestedSetPath: configuredPath, platform, target, + env, }); } -function inventoryAndroidSerialAllowlist(configuredAllowlist: string | undefined) { - const allowlist = resolveAndroidSerialAllowlist(configuredAllowlist); +function inventoryAndroidSerialAllowlist( + configuredAllowlist: string | undefined, + env: HostEnvironment['env'], +) { + const allowlist = resolveAndroidSerialAllowlist(configuredAllowlist, env); return allowlist ? Array.from(allowlist).sort() : undefined; } @@ -335,6 +356,7 @@ function hasAvailableFact( } async function handleAppsInventory(params: { + env: HostEnvironment['env']; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -362,6 +384,7 @@ async function handleAppsInventory(params: { const resolvedAndroidSerialAllowlist = resolveAndroidSerialAllowlist( req.flags?.androidDeviceAllowlist, + params.env, ); const runtimeResolution = await resolveAppsRuntime({ device, diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/session-close-lifecycle-teardown.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/session-close-lifecycle-teardown.test.ts index d922ce82dd..25ff157843 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-close-lifecycle-teardown.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-close-lifecycle-teardown.test.ts @@ -3,7 +3,11 @@ import { beforeEach, expect, test, vi } from 'vitest'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { CloseApplicationFinalizationInput } from '@agent-device/contracts/application-lifecycle-runtime'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { SessionStore } from '../../../core/session-store.ts'; import { LeaseRegistry } from '../../../resources/lease-registry.ts'; import type { DaemonRequest } from '../../../core/daemon-request.ts'; @@ -17,6 +21,8 @@ import { mockBindDeviceRuntime, } from '../../../__tests__/support/handlers/session-command-harness.ts'; +bindTestCommandHost(); + const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {} }); @@ -88,6 +94,7 @@ async function closeSession(params: { }; }; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), platformResourceCleanup, 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 c44d1e71c5..369d9f508d 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts @@ -1,3 +1,4 @@ +import type { 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'; @@ -43,6 +44,7 @@ import type { PlatformResourceCleanup } from '../../../core/platform-resource-cl import { errorResponse } from '@agent-device/kernel/contracts'; export type SessionCloseCommandInput = Readonly<{ + host: HostEnvironment; req: DaemonRequest; sessionName: string; logPath: string; @@ -259,6 +261,7 @@ export async function handleSessionCloseCommands( }); if ('response' in repair) return repair.response; const closed = await runCloseTeardownAndRelease({ + env: params.host.env, req, ref, logPath, @@ -293,6 +296,7 @@ type SessionCloseFinalization = // once both are known) the device-claim clear and session delete. A failed // lease release keeps the session retryable instead (`{kind:'response'}`). async function runCloseTeardownAndRelease(params: { + env: HostEnvironment['env']; req: DaemonRequest; ref: SessionRef; logPath: string; @@ -352,7 +356,7 @@ async function runCloseTeardownAndRelease(params: { }); } } else { - await clearDeviceClaim(session.deviceClaim); + await clearDeviceClaim(session.deviceClaim, params.env); } sessionStore.retire(ref); if (deviceClaimBlockingError) throw deviceClaimBlockingError; diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution-relaunch.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution-relaunch.test.ts index fafbdc4e8f..d3a979b82d 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution-relaunch.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution-relaunch.test.ts @@ -3,7 +3,11 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { OpenApplicationInput } from '@agent-device/contracts/application-lifecycle-runtime'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { SessionStore } from '../../../core/session-store.ts'; import type { DaemonRequest } from '../../../core/daemon-request.ts'; import type { SessionState } from '../../../core/session-state.ts'; @@ -16,6 +20,8 @@ import { mockBindDeviceRuntime, } from '../../../__tests__/support/handlers/session-command-harness.ts'; +bindTestCommandHost(); + const mockResolveTargetDevice = vi.hoisted(() => vi.fn()); vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { @@ -115,6 +121,7 @@ async function openRelaunch(params: { }; }; const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution.ts b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution.ts index 5cd67bf7ef..dc40ee0405 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { resolveSessionRequestLogPath, resolveSessionRunnerLogPath, @@ -146,9 +147,10 @@ function buildStartupPerfSample( export async function renewOpenSessionClaim( device: DeviceInfo, ownership: DeviceClaimSessionOwnership | undefined, + env: HostEnvironment['env'], ): Promise { if (!ownership) return undefined; - const renewal = await renewDeviceClaim(ownership); + const renewal = await renewDeviceClaim(ownership, env); if (renewal.status === 'renewed') return undefined; if (renewal.conflict) return buildDeviceClaimConflictError(device, renewal.conflict); return errorResponse( @@ -404,6 +406,7 @@ function findNewSessionDeviceConflict(params: { } async function acquireDeviceClaimForOwner(params: { + host: HostEnvironment; req: DaemonRequest; device: DeviceInfo; owner: OpenApplicationRuntime['owner']; @@ -428,8 +431,9 @@ async function acquireDeviceClaimForOwner(params: { return await acquireDeviceClaim({ device, session: sessionName, - workspace: req.meta?.cwd ?? process.cwd(), + workspace: req.meta?.cwd ?? params.host.cwd, stateDir: sessionStore.resolveDaemonStateDir(), + env: params.host.env, reconcileOrphanedDeviceClaim, observeDeviceBoot: platformServices.deviceBootObservation, }); @@ -437,6 +441,7 @@ async function acquireDeviceClaimForOwner(params: { } export async function openNewSessionWithDeviceClaim(params: { + host: HostEnvironment; req: DaemonRequest; sessionName: string; logPath: string; @@ -473,6 +478,7 @@ export async function openNewSessionWithDeviceClaim(params: { if (conflict) return { type: 'response', response: conflict }; const ownerClaim = await acquireDeviceClaimForOwner({ + host: params.host, req, device, owner: lifecycle.owner, @@ -495,6 +501,7 @@ export async function openNewSessionWithDeviceClaim(params: { effects, sessionName, sessionStore, + env: params.host.env, }); try { requireOpenSessionAdmission(sessionStore, sessionName, undefined); @@ -516,7 +523,7 @@ export async function openNewSessionWithDeviceClaim(params: { // Stamping here is what covers a boot preparation caused for this very open; from // `completeOpenCommand` on, a relaunch-close or a runtime-hint write may already have touched the // app, so a failure from that point cannot prove ownership was never established. - const reclaimed = await renewOpenSessionClaim(device, deviceClaim); + const reclaimed = await renewOpenSessionClaim(device, deviceClaim, params.host.env); if (reclaimed) return { type: 'response', response: reclaimed }; effects.mayHaveStarted = true; const requestedPositionals = req.positionals ?? []; @@ -560,15 +567,16 @@ async function rollbackNewSessionClaim(params: { effects: NewSessionOpenEffects; sessionName: string; sessionStore: SessionStore; + env: HostEnvironment['env']; }): Promise { - const { ownership, effects, sessionName, sessionStore } = params; + const { ownership, effects, sessionName, sessionStore, env } = params; if (!ownership) return; if (!effects.mayHaveStarted) { - await clearDeviceClaim(ownership); + await clearDeviceClaim(ownership, env); return; } if (sessionStore.get(sessionName)?.deviceClaim?.ownerToken === ownership.ownerToken) return; - const outcome = await abandonDeviceClaim(ownership); + const outcome = await abandonDeviceClaim(ownership, env); emitDiagnostic({ level: 'warn', phase: 'device_claim_open_effects_unconfirmed', 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 03be15c5e5..56e366da14 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-open.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-open.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { DaemonPlatformServices } from '../../../core/platform-services.ts'; import { resolveTargetDeviceSelection } from '@agent-device/device-selection/dispatch-resolve'; @@ -54,6 +55,7 @@ import { errorResponse } from '@agent-device/kernel/contracts'; import type { CreateDaemonCommandSurface } from '@agent-device/contracts/daemon-command-surface'; export type SessionOpenCommandInput = Readonly<{ + host: HostEnvironment; createCommandSurface: CreateDaemonCommandSurface; req: DaemonRequest; sessionName: string; @@ -221,6 +223,7 @@ async function handleOpenCommand(params: SessionOpenCommandInput): Promise await openNewSessionWithDeviceClaim({ + host: params.host, req, sessionName, logPath, diff --git a/packages/daemon/src/sessions/session-observability/__tests__/application.test.ts b/packages/daemon/src/sessions/session-observability/__tests__/application.test.ts index 858dca2149..a053b2c761 100644 --- a/packages/daemon/src/sessions/session-observability/__tests__/application.test.ts +++ b/packages/daemon/src/sessions/session-observability/__tests__/application.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; @@ -34,6 +35,7 @@ function request(command: DaemonRequest['command']): DaemonRequest { async function run(command: DaemonRequest['command']): Promise { return await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: request(command), @@ -60,6 +62,7 @@ test('observability commands retain their route response and narrow facade input 'appLogAdmissionLedger', 'audioProbeAdmissionLedger', 'bindDevice', + 'host', 'inspectFacts', 'perfCaptureAdmissionLedger', 'platformServices', diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts index 16b8c9bfc6..ae717ea6b0 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-logs.test.ts @@ -14,6 +14,7 @@ import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform import { createTestAppLogLiveHandle, unavailableDeploymentSnapshotAndShutdownOperationFacts, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -300,6 +301,7 @@ async function runLogs( throwIfCanceled?: () => void, ) { return await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, command: 'logs', positionals, flags }, diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts index 0863d49176..f49cb291d5 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../../../__tests__/support/platform-services-fixture.ts'; import path from 'node:path'; import { expect, test } from 'vitest'; @@ -64,6 +65,7 @@ test.each(NETWORK_RUNTIME_PROJECTION_PARITY)( const runtime = createNetworkRuntime(session.device, async () => result); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -119,6 +121,7 @@ test('network admission remains fail-closed before parsing invalid input', async }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -218,6 +221,7 @@ async function runNetwork( bindDevice: BindDeviceRuntime, ) { return await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network.test.ts index 73e7b8926d..e68915308b 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../../../__tests__/support/platform-services-fixture.ts'; import path from 'node:path'; import { expect, test } from 'vitest'; @@ -17,6 +18,7 @@ test('network requires an active session before requesting a runtime binding', a const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -52,6 +54,7 @@ test('network validates the legacy limit after runtime-fact admission', async () ); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-observability.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-observability.test.ts index 20852df66a..b84fe26681 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-observability.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-observability.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../../../__tests__/support/platform-services-fixture.ts'; import assert from 'node:assert/strict'; import { test } from 'vitest'; @@ -17,6 +18,7 @@ test('network dump validates include mode directly', async () => { emptyAppLogResult('android', input), ); const response = await handleSessionObservabilityCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -45,6 +47,7 @@ test('network dump accepts explicit include flag and rejects conflicting values' emptyAppLogResult('android', input), ); const okResponse = await handleSessionObservabilityCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -61,6 +64,7 @@ test('network dump accepts explicit include flag and rejects conflicting values' if (okResponse?.ok) assert.equal(okResponse.data?.include, 'headers'); const conflictResponse = await handleSessionObservabilityCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -95,6 +99,7 @@ test.each(REMOVED_AGGREGATE_PERF_POSITIONALS.map((positionals) => [positionals] async (positionals) => { const sessionStore = makeAndroidStore(); const response = await handleSessionObservabilityCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'android', command: 'perf', positionals, flags: {} }, sessionName: 'android', diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts index 372f12de2a..64286246b2 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts @@ -19,7 +19,11 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { deviceIdentity } from '@agent-device/kernel/device'; import { makeAndroidSession } from '../../../../__tests__/support/session-factories.ts'; import { makeSessionStore } from '../../../../__tests__/support/store-factory.ts'; -import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '@agent-device/test-kit'; +import { + unavailableDeploymentSnapshotAndShutdownOperationFacts, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { createPerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; import type { BindDeviceRuntime, @@ -27,6 +31,8 @@ import type { } from '../../../../core/request-runtime-binding.ts'; import { handleSessionObservabilityCommands } from '../session-observability.ts'; +bindTestCommandHost(); + const available = Object.freeze({ available: true as const }); const unavailable = Object.freeze({ available: false as const, @@ -36,6 +42,7 @@ const unavailable = Object.freeze({ test('perf requires an active session', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionObservabilityCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -64,6 +71,7 @@ test('perf frames admits and binds only the selected runtime operation', async ( const runtime = createPerfRuntime({ perfFrames }); const response = await handleSessionObservabilityCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'android', command: 'perf', positionals: ['frames'] }, sessionName: 'android', @@ -94,6 +102,7 @@ test('perf memory projects an unavailable provider metric without failing the re const runtime = createPerfRuntime({ perfMemorySample }); const response = await handleSessionObservabilityCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -125,6 +134,7 @@ test('perf refuses from owner facts before binding', async () => { const sessionStore = makeStore(); const runtime = createPerfRuntime({}, unavailable); const response = await handleSessionObservabilityCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'android', command: 'perf', positionals: ['memory', 'sample'] }, sessionName: 'android', @@ -192,6 +202,7 @@ test('perf native capture is adopted durably and stop uses the live handle witho }; const startResponse = await handleSessionObservabilityCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), ...common, req: { @@ -210,6 +221,7 @@ test('perf native capture is adopted durably and stop uses the live handle witho ); const wrongModeResponse = await handleSessionObservabilityCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), ...common, req: { @@ -231,6 +243,7 @@ test('perf native capture is adopted durably and stop uses the live handle witho assert.equal(sessionStore.get('android')?.actions.length, 1); const stopResponse = await handleSessionObservabilityCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), ...common, req: { @@ -264,6 +277,7 @@ test.each(['shutdown', 'retire'] as const)( return bound; }; const response = await handleSessionObservabilityCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -370,6 +384,7 @@ test('a perf result does not record into a successor occupying the same address' }); const runtime = createPerfRuntime({ perfFrames }); const response = await handleSessionObservabilityCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'android', command: 'perf', positionals: ['frames'] }, sessionName: 'android', diff --git a/packages/daemon/src/sessions/session-observability/internal/session-observability.ts b/packages/daemon/src/sessions/session-observability/internal/session-observability.ts index d3246f601f..d19ce34029 100644 --- a/packages/daemon/src/sessions/session-observability/internal/session-observability.ts +++ b/packages/daemon/src/sessions/session-observability/internal/session-observability.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { appLogResourceStore } from '@agent-device/capture-kit/app-log-durable-resource'; import type { DaemonPlatformServices } from '../../../core/platform-services.ts'; import type { LogBackend } from '@agent-device/contracts/observability'; @@ -44,6 +45,7 @@ import { } from '@agent-device/kernel/contracts'; export type SessionObservabilityCommandInput = { + host: HostEnvironment; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; diff --git a/packages/daemon/src/sessions/session-observability/internal/session-perf-runtime.ts b/packages/daemon/src/sessions/session-observability/internal/session-perf-runtime.ts index ffbc844541..b9b96666d2 100644 --- a/packages/daemon/src/sessions/session-observability/internal/session-perf-runtime.ts +++ b/packages/daemon/src/sessions/session-observability/internal/session-perf-runtime.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import path from 'node:path'; @@ -46,6 +47,7 @@ import { import { errorResponse } from '@agent-device/kernel/contracts'; export type PerfRuntimeHandlerParams = Readonly<{ + host: HostEnvironment; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -143,7 +145,7 @@ async function executeAdmittedPerfPlan( appId: session.appBundleId, kind: plan.request.kind, outPath: plan.request.outPath - ? expandSessionPath(plan.request.outPath, params.req.meta?.cwd) + ? expandSessionPath(plan.request.outPath, params.req.meta?.cwd ?? params.host.cwd) : undefined, artifactsDir: path.join( params.sessionStore.ensureSessionDir(params.sessionName), @@ -176,7 +178,7 @@ async function executeAdmittedPerfPlan( const data = await runtime.operations.perfProfileReport({ appId: session.appBundleId, kind: plan.request.kind, - tracePath: expandSessionPath(tracePath, params.req.meta?.cwd), + tracePath: expandSessionPath(tracePath, params.req.meta?.cwd ?? params.host.cwd), outPath, template: plan.request.template ?? (last?.kind === 'xctrace' ? last.template : undefined), profile: last, @@ -262,7 +264,9 @@ async function stopPerfCapture( const mismatchMessage = perfCaptureStopMismatch(snapshot, request); if (mismatchMessage) return errorResponse('INVALID_ARGS', mismatchMessage); if (request.outPath) { - capture.handle.setOutputPath(expandSessionPath(request.outPath, params.req.meta?.cwd)); + capture.handle.setOutputPath( + expandSessionPath(request.outPath, params.req.meta?.cwd ?? params.host.cwd), + ); } const completion = await finishLivePerfCapture({ intent: 'capture', @@ -393,7 +397,8 @@ function resolveNativeOutPath( requestedPath: string | undefined, fallbackFileName: string, ): string { - if (requestedPath) return expandSessionPath(requestedPath, params.req.meta?.cwd); + if (requestedPath) + return expandSessionPath(requestedPath, params.req.meta?.cwd ?? params.host.cwd); return path.join( params.sessionStore.ensureSessionDir(params.sessionName), `${timestampToken()}-${fallbackFileName}`, diff --git a/packages/daemon/test/nightly/concurrency-torture/real-scope-serialization.ts b/packages/daemon/test/nightly/concurrency-torture/real-scope-serialization.ts index 9b58374a1f..dd938daa1d 100644 --- a/packages/daemon/test/nightly/concurrency-torture/real-scope-serialization.ts +++ b/packages/daemon/test/nightly/concurrency-torture/real-scope-serialization.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; // Real-scope same-device serialization guard for the concurrency torture lane import type { CommandFlags, DaemonWireRequest } from '@agent-device/contracts/command'; @@ -69,6 +70,7 @@ export async function measureRealScopeMaxOverlap(concurrency: number): Promise createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: openRequest(`real-${i}`, device.id), diff --git a/packages/device-selection/src/dispatch-resolve.ts b/packages/device-selection/src/dispatch-resolve.ts index 3378153e0e..4d04881d67 100644 --- a/packages/device-selection/src/dispatch-resolve.ts +++ b/packages/device-selection/src/dispatch-resolve.ts @@ -215,8 +215,12 @@ export function buildDeviceInventoryRequestFromFlags( requestedSetPath: flags.iosSimulatorDeviceSet, platform, target: flags.target, + env: process.env, }); - const androidSerialAllowlist = resolveAndroidSerialAllowlist(flags.androidDeviceAllowlist); + const androidSerialAllowlist = resolveAndroidSerialAllowlist( + flags.androidDeviceAllowlist, + process.env, + ); return { platform, target: flags.target, diff --git a/packages/host-kit/src/command.ts b/packages/host-kit/src/command.ts index 031846e8d8..73b8aeb4f3 100644 --- a/packages/host-kit/src/command.ts +++ b/packages/host-kit/src/command.ts @@ -1,5 +1,6 @@ export { coerceExecResult, + commandHostEnvironment, commandDeveloperDir, type CommandExecutorOverride, type ExecBackgroundOptions, @@ -22,6 +23,6 @@ export { runCmdSync, whichCmd, withCommandExecutorOverride, - withRequestCommandEnv, + withCommandHost, withoutCommandExecutorOverride, } from './internal/exec.ts'; diff --git a/packages/host-kit/src/doctor-lines.ts b/packages/host-kit/src/doctor-lines.ts index c22bef6444..310531dd04 100644 --- a/packages/host-kit/src/doctor-lines.ts +++ b/packages/host-kit/src/doctor-lines.ts @@ -2,8 +2,11 @@ import { formatCliStatusMarker, type CliStatusMarkerStatus } from './status-mark type DoctorLineCheck = Partial>; -export function formatDoctorCheckSummaryLine(check: DoctorLineCheck): string { - const statusMarker = formatCliStatusMarker(doctorStatusMarker(check.status)); +export function formatDoctorCheckSummaryLine( + check: DoctorLineCheck, + options: { useColor: boolean }, +): string { + const statusMarker = formatCliStatusMarker(doctorStatusMarker(check.status), options); return `${statusMarker} ${formatDoctorCheckLabel(check)}`; } diff --git a/packages/host-kit/src/internal/exec.test.ts b/packages/host-kit/src/internal/exec.test.ts index 20bb597104..dee9c69a41 100644 --- a/packages/host-kit/src/internal/exec.test.ts +++ b/packages/host-kit/src/internal/exec.test.ts @@ -6,6 +6,7 @@ import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from './diagnosti import { coerceExecResult, commandDeveloperDir, + commandHostEnvironment, isCommandTimeoutError, requireExecSuccess, runCmd, @@ -14,10 +15,11 @@ import { runCmdStreaming, runCmdSync, whichCmd, - withRequestCommandEnv, + withCommandHost, type ExecResult, } from './exec.ts'; import { AppError } from '@agent-device/kernel/errors'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { mkdtempForTestSync } from './tmp-dir.fixtures.ts'; test('runCmd enforces timeoutMs and rejects with COMMAND_FAILED', async () => { @@ -161,29 +163,24 @@ test('runCmdBackground can leave output streams to the caller', async () => { assert.equal(stderr, 'err'); }); -test('runCmd stays silent when exec tracing is not enabled', { concurrent: false }, async () => { - const previousTraceEnv = process.env.AGENT_DEVICE_EXEC_TRACE; - delete process.env.AGENT_DEVICE_EXEC_TRACE; - - try { - const diagnosticsPath = await withDiagnosticsScope( - { - session: 'exec-silent', - requestId: 'exec-silent-1', - command: 'home', - }, - async () => { - await runCmd(process.execPath, ['-e', 'process.stdout.write("ok")']); - return flushDiagnosticsToSessionFile()?.path ?? null; - }, - ); +test('runCmd stays silent when exec tracing is not enabled', async () => { + const diagnosticsPath = await withCommandHost( + { host: hostWith({ AGENT_DEVICE_EXEC_TRACE: undefined }), requestEnv: undefined }, + async () => + await withDiagnosticsScope( + { + session: 'exec-silent', + requestId: 'exec-silent-1', + command: 'home', + }, + async () => { + await runCmd(process.execPath, ['-e', 'process.stdout.write("ok")']); + return flushDiagnosticsToSessionFile()?.path ?? null; + }, + ), + ); - assert.equal(diagnosticsPath, null); - } finally { - if (previousTraceEnv !== undefined) { - process.env.AGENT_DEVICE_EXEC_TRACE = previousTraceEnv; - } - } + assert.equal(diagnosticsPath, null); }); test('runCmdBackground aborts with request cancellation details', async () => { @@ -197,7 +194,7 @@ test('runCmdBackground aborts with request cancellation details', async () => { }); test('whichCmd resolves absolute executable paths without invoking a shell', async () => { - assert.equal(await whichCmd(process.execPath), true); + assert.equal(await whichCmd(process.execPath, process.env), true); }); async function assertRejectsRequestCanceled(promise: Promise): Promise { @@ -212,7 +209,7 @@ async function assertRejectsRequestCanceled(promise: Promise): Promise< } test('whichCmd resolves bare commands from PATH', async () => { - assert.equal(await whichCmd('node'), true); + assert.equal(await whichCmd('node', process.env), true); }); test.runIf(process.platform !== 'win32')( @@ -281,29 +278,22 @@ test.runIf(process.platform !== 'win32')( ); test('whichCmd rejects suspicious command strings', async () => { - assert.equal(await whichCmd('node; rm -rf /'), false); - assert.equal(await whichCmd('./node'), false); + assert.equal(await whichCmd('node; rm -rf /', process.env), false); + assert.equal(await whichCmd('./node', process.env), false); }); -test( - 'whichCmd ignores directories that match a command name in PATH', - { concurrent: false }, - async () => { - const root = mkdtempForTestSync('agent-device-whichcmd-'); - const fakeCommandDir = path.join(root, 'fake-tool'); - fs.mkdirSync(fakeCommandDir); - - const previousPath = process.env.PATH; - process.env.PATH = `${root}${path.delimiter}${previousPath ?? ''}`; +test('whichCmd ignores directories that match a command name in PATH', async () => { + const root = mkdtempForTestSync('agent-device-whichcmd-'); + const fakeCommandDir = path.join(root, 'fake-tool'); + fs.mkdirSync(fakeCommandDir); - try { - assert.equal(await whichCmd('fake-tool'), false); - } finally { - process.env.PATH = previousPath; - fs.rmSync(root, { recursive: true, force: true }); - } - }, -); + try { + const env = { PATH: `${root}${path.delimiter}${process.env.PATH ?? ''}` }; + assert.equal(await whichCmd('fake-tool', env), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); function readExecDiagnosticEvents(diagnosticsPath: string | null): Array<{ level?: string; @@ -338,21 +328,10 @@ function readExecDiagnosticEvent(diagnosticsPath: string | null): { } async function withExecTraceEnv(fn: () => Promise): Promise { - const previousTraceEnv = process.env.AGENT_DEVICE_EXEC_TRACE; - process.env.AGENT_DEVICE_EXEC_TRACE = '1'; - try { - return await fn(); - } finally { - restoreOptionalEnv('AGENT_DEVICE_EXEC_TRACE', previousTraceEnv); - } -} - -function restoreOptionalEnv(key: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[key]; - return; - } - process.env[key] = value; + return await withCommandHost( + { host: hostWith({ AGENT_DEVICE_EXEC_TRACE: '1' }), requestEnv: undefined }, + fn, + ); } function assertBackgroundExecTraceEvents( @@ -472,25 +451,33 @@ test('isCommandTimeoutError reads the structured timeout, not the message text', const PRINT_DEVELOPER_DIR = ['-e', 'process.stdout.write(process.env.DEVELOPER_DIR ?? "")']; -test('a request command env reaches spawned commands; outside it they inherit the daemon env', async () => { - const daemonEnv = { ...process.env, DEVELOPER_DIR: '/daemon/Developer' }; - const inherited = await runCmd(process.execPath, PRINT_DEVELOPER_DIR, { env: daemonEnv }); +function hostWith(env: Record): HostEnvironment { + return { env: { ...process.env, ...env }, cwd: process.cwd() }; +} + +test('a request command env reaches spawned commands; outside it they inherit the host env', async () => { + const host = hostWith({ DEVELOPER_DIR: '/daemon/Developer' }); + const inherited = await runCmd(process.execPath, PRINT_DEVELOPER_DIR, { env: host.env }); assert.equal(inherited.stdout, '/daemon/Developer'); - const [first, second, unscoped] = await Promise.all([ - withRequestCommandEnv({ DEVELOPER_DIR: '/a/Developer' }, async () => - runCmd(process.execPath, PRINT_DEVELOPER_DIR, { env: daemonEnv }), + const [first, second, unscoped, hostOnly] = await Promise.all([ + withCommandHost({ host, requestEnv: { DEVELOPER_DIR: '/a/Developer' } }, async () => + runCmd(process.execPath, PRINT_DEVELOPER_DIR, { env: host.env }), ), - withRequestCommandEnv({ DEVELOPER_DIR: '/b/Developer' }, async () => + withCommandHost({ host, requestEnv: { DEVELOPER_DIR: '/b/Developer' } }, async () => runCmdSync(process.execPath, PRINT_DEVELOPER_DIR), ), - withRequestCommandEnv(undefined, async () => - runCmd(process.execPath, PRINT_DEVELOPER_DIR, { env: daemonEnv }), + withCommandHost({ host, requestEnv: undefined }, async () => + runCmd(process.execPath, PRINT_DEVELOPER_DIR, { env: host.env }), + ), + withCommandHost({ host, requestEnv: undefined }, async () => + runCmd(process.execPath, PRINT_DEVELOPER_DIR), ), ]); assert.equal(first.stdout, '/a/Developer'); assert.equal(second.stdout, '/b/Developer'); assert.equal(unscoped.stdout, '/daemon/Developer'); + assert.equal(hostOnly.stdout, '/daemon/Developer'); }); test.runIf(process.platform !== 'win32')( @@ -504,8 +491,12 @@ test.runIf(process.platform !== 'win32')( { mode: 0o755 }, ); await assert.rejects( - withRequestCommandEnv({ DEVELOPER_DIR: '/Library/Developer/CommandLineTools' }, async () => - runCmd(xcrun, ['simctl', 'list']), + withCommandHost( + { + host: hostWith({}), + requestEnv: { DEVELOPER_DIR: '/Library/Developer/CommandLineTools' }, + }, + async () => runCmd(xcrun, ['simctl', 'list']), ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -530,12 +521,17 @@ const XCRUN_DEVICECTL_NOT_FOUND = { test('an allowFailure xcrun result guarded by requireExecSuccess gets the toolchain hint over the caller hint', async () => { await assert.rejects( - withRequestCommandEnv({ DEVELOPER_DIR: '/Library/Developer/CommandLineTools' }, async () => - requireExecSuccess(XCRUN_DEVICECTL_NOT_FOUND, 'Failed to install app', { - cmd: 'xcrun', - args: ['devicectl', 'device', 'install', 'app'], - hint: 'Ensure the iOS device is unlocked, trusted, and available in Xcode > Devices, then retry.', - }), + withCommandHost( + { + host: hostWith({}), + requestEnv: { DEVELOPER_DIR: '/Library/Developer/CommandLineTools' }, + }, + async () => + requireExecSuccess(XCRUN_DEVICECTL_NOT_FOUND, 'Failed to install app', { + cmd: 'xcrun', + args: ['devicectl', 'device', 'install', 'app'], + hint: 'Ensure the iOS device is unlocked, trusted, and available in Xcode > Devices, then retry.', + }), ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -550,13 +546,10 @@ test('an allowFailure xcrun result guarded by requireExecSuccess gets the toolch ); }); -test( - 'without a developer dir the toolchain hint points at xcode-select', - { concurrent: false }, - () => { - const saved = process.env.DEVELOPER_DIR; - delete process.env.DEVELOPER_DIR; - try { +test('without a developer dir the toolchain hint points at xcode-select', async () => { + await withCommandHost( + { host: hostWith({ DEVELOPER_DIR: undefined }), requestEnv: undefined }, + async () => { // No cmd/args: the call site only had the result, so xcrun's own stderr identifies the tool. assert.throws( () => requireExecSuccess(XCRUN_DEVICECTL_NOT_FOUND, 'Failed to list devices'), @@ -570,43 +563,33 @@ test( return true; }, ); - } finally { - if (saved !== undefined) process.env.DEVELOPER_DIR = saved; - } - }, -); + }, + ); +}); -test( - 'a request that clears DEVELOPER_DIR is not reported under the daemon one', - { concurrent: false }, - async () => { - const saved = process.env.DEVELOPER_DIR; - process.env.DEVELOPER_DIR = '/daemon/Developer'; - try { - // The child runs without a usable DEVELOPER_DIR (xcode-select decides), so the key and report - // must too, whether the request clears it or carries it unset. - for (const requestEnv of [{ DEVELOPER_DIR: '' }, { DEVELOPER_DIR: undefined }]) { - await withRequestCommandEnv(requestEnv, async () => { - assert.equal((await runCmd(process.execPath, PRINT_DEVELOPER_DIR)).stdout, ''); - assert.equal(commandDeveloperDir(), undefined); - assert.throws( - () => requireExecSuccess(XCRUN_DEVICECTL_NOT_FOUND, 'Failed to list devices'), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.details?.developerDir, null); - assert.match(String(error.details?.hint), /selected by xcode-select/); - return true; - }, - ); - }); - } - assert.equal(commandDeveloperDir(), '/daemon/Developer'); - } finally { - if (saved === undefined) delete process.env.DEVELOPER_DIR; - else process.env.DEVELOPER_DIR = saved; - } - }, -); +test('a request that clears DEVELOPER_DIR is not reported under the daemon one', async () => { + const host = hostWith({ DEVELOPER_DIR: '/daemon/Developer' }); + // The child runs without a usable DEVELOPER_DIR (xcode-select decides), so the key and report + // must too, whether the request clears it or carries it unset. + for (const requestEnv of [{ DEVELOPER_DIR: '' }, { DEVELOPER_DIR: undefined }]) { + await withCommandHost({ host, requestEnv }, async () => { + assert.equal((await runCmd(process.execPath, PRINT_DEVELOPER_DIR)).stdout, ''); + assert.equal(commandDeveloperDir(), undefined); + assert.throws( + () => requireExecSuccess(XCRUN_DEVICECTL_NOT_FOUND, 'Failed to list devices'), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.developerDir, null); + assert.match(String(error.details?.hint), /selected by xcode-select/); + return true; + }, + ); + }); + } + await withCommandHost({ host, requestEnv: undefined }, async () => { + assert.equal(commandDeveloperDir(), '/daemon/Developer'); + }); +}); test('a non-xcrun failure keeps the caller hint', () => { assert.throws( @@ -645,3 +628,19 @@ test('runCmdStreaming can forward output without retaining it in the result', as assert.equal(stderr, 'err'); assert.deepEqual(result, { stdout: '', stderr: '', exitCode: 7, stdoutBuffer: undefined }); }); + +test('commandHostEnvironment names the enclosing host and refuses outside one', async () => { + assert.throws( + () => commandHostEnvironment(), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, 'host_environment_unscoped'); + return true; + }, + ); + + const host = hostWith({ AGENT_DEVICE_TEST_HOST: 'yes' }); + await withCommandHost({ host, requestEnv: undefined }, async () => { + assert.equal(commandHostEnvironment(), host); + }); +}); diff --git a/packages/host-kit/src/internal/exec.ts b/packages/host-kit/src/internal/exec.ts index 0df5318cde..71785d2c98 100644 --- a/packages/host-kit/src/internal/exec.ts +++ b/packages/host-kit/src/internal/exec.ts @@ -7,7 +7,11 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; import { emitDiagnostic, getDiagnosticsMeta, updateDiagnosticsScope } from './diagnostics.ts'; -import { parseBooleanLiteral } from '@agent-device/kernel/source-value'; +import { + parseBooleanLiteral, + type EnvMap, + type HostEnvironment, +} from '@agent-device/kernel/source-value'; export type ExecResult = { stdout: string; @@ -106,30 +110,56 @@ export async function withoutCommandExecutorOverride(fn: () => Promise): P return await commandExecutorOverrideScope.run(undefined, fn); } -const requestCommandEnvScope = new AsyncLocalStorage(); +type CommandHostScope = Readonly<{ + host: HostEnvironment; + requestEnv: EnvMap | undefined; +}>; -/** Overlays request environment values on spawned commands without changing `process.env`. */ -export async function withRequestCommandEnv( - env: NodeJS.ProcessEnv | undefined, +const commandHostScope = new AsyncLocalStorage(); + +/** + * Runs `fn` as the host that owns the commands it spawns. A command without its own `env` starts + * from `host.env`, then takes `requestEnv` on top, so neither the process's environment nor a + * concurrent request's values reach it. Outside a scope a command without `env` inherits the process's. + */ +export async function withCommandHost( + scope: CommandHostScope, fn: () => Promise, ): Promise { - if (!env) return await fn(); - return await requestCommandEnvScope.run(env, fn); + // A nested scope of the same host that names no request env keeps the enclosing request's. + const enclosing = commandHostScope.getStore(); + const requestEnv = + scope.requestEnv ?? (enclosing?.host === scope.host ? enclosing.requestEnv : undefined); + return await commandHostScope.run({ host: scope.host, requestEnv }, fn); +} + +/** + * The host of the enclosing {@link withCommandHost} scope, for code that has no host of its own. + * Outside a scope there is no host to answer with, so it refuses rather than guess. + */ +export function commandHostEnvironment(): HostEnvironment { + const scope = commandHostScope.getStore(); + if (!scope) { + throw new AppError('COMMAND_FAILED', 'No host environment is bound to this command.', { + reason: 'host_environment_unscoped', + }); + } + return scope.host; } /** Effective toolchain selection; an explicitly empty request value selects `xcode-select`. */ export function commandDeveloperDir(): string | undefined { - const requestEnv = requestCommandEnvScope.getStore(); - if (requestEnv && Object.hasOwn(requestEnv, 'DEVELOPER_DIR')) { - return requestEnv.DEVELOPER_DIR || undefined; + const scope = commandHostScope.getStore(); + if (scope?.requestEnv && Object.hasOwn(scope.requestEnv, 'DEVELOPER_DIR')) { + return scope.requestEnv.DEVELOPER_DIR || undefined; } - return process.env.DEVELOPER_DIR || undefined; + return scope?.host.env.DEVELOPER_DIR || undefined; } function resolveSpawnEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv | undefined { - const requestEnv = requestCommandEnvScope.getStore(); - if (!requestEnv) return env; - return { ...(env ?? process.env), ...requestEnv }; + const scope = commandHostScope.getStore(); + if (!scope) return env; + return { ...(env ?? scope.host.env), ...scope.requestEnv }; } export async function runCmd( @@ -288,7 +318,7 @@ function runSpawnedCommand( }); } -export async function whichCmd(cmd: string): Promise { +export async function whichCmd(cmd: string, env: EnvMap): Promise { const candidate = normalizeExecutableLookup(cmd); if (!candidate) return false; @@ -296,9 +326,9 @@ export async function whichCmd(cmd: string): Promise { return isExecutablePath(candidate); } - const pathValue = process.env.PATH; + const pathValue = readPathVariable(env, 'PATH'); if (!pathValue) return false; - const pathExtensions = resolvePathExtensions(); + const pathExtensions = resolvePathExtensions(env); for (const directory of pathValue.split(path.delimiter)) { const trimmedDirectory = directory.trim(); if (!trimmedDirectory) continue; @@ -529,7 +559,9 @@ type ExecTraceContext = { function createExecTraceContext(): ExecTraceContext { const diagnosticsMeta = getDiagnosticsMeta(); const diagnosticsDebugEnabled = diagnosticsMeta.debug === true; - const envTraceEnabled = parseBooleanLiteral(process.env.AGENT_DEVICE_EXEC_TRACE ?? '') === true; + const envTraceEnabled = + parseBooleanLiteral(commandHostScope.getStore()?.host.env.AGENT_DEVICE_EXEC_TRACE ?? '') === + true; if (!diagnosticsDebugEnabled && !envTraceEnabled) { return createDisabledExecTraceContext(); } @@ -811,9 +843,16 @@ function normalizeExecutableLookup(cmd: string): string | null { return BARE_COMMAND_RE.test(candidate) ? candidate : null; } -function resolvePathExtensions(): string[] { +/** Windows names an environment variable in any case; a captured copy no longer folds it. */ +function readPathVariable(env: EnvMap, name: 'PATH' | 'PATHEXT'): string | undefined { + if (process.platform !== 'win32') return env[name]; + const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name); + return key === undefined ? undefined : env[key]; +} + +function resolvePathExtensions(env: EnvMap): string[] { if (process.platform !== 'win32') return ['']; - const rawPathExt = process.env.PATHEXT; + const rawPathExt = readPathVariable(env, 'PATHEXT'); if (!rawPathExt) return WINDOWS_PATH_EXTENSIONS; const extensions = rawPathExt .split(';') diff --git a/packages/host-kit/src/internal/host-process.test.ts b/packages/host-kit/src/internal/host-process.test.ts index 887e494b1e..274fb24fdb 100644 --- a/packages/host-kit/src/internal/host-process.test.ts +++ b/packages/host-kit/src/internal/host-process.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { test, vi } from 'vitest'; import { + captureHostEnvironment, expandProcessTree, isProcessAlive, isProcessGroupAlive, @@ -179,3 +180,18 @@ test('pid escalation returns immediately for an empty pid set', async () => { vi.useRealTimers(); } }); + +test('captureHostEnvironment copies the environment and reads the working directory once', () => { + const source = { + env: { HOME: '/home/a', EMPTY: undefined } as Record, + cwd: vi.fn(() => '/work'), + }; + const host = captureHostEnvironment(source); + + source.env.HOME = '/home/b'; + source.env.ADDED = 'late'; + + assert.deepEqual(host, { env: { HOME: '/home/a', EMPTY: undefined }, cwd: '/work' }); + assert.notEqual(host.env, source.env); + assert.equal(source.cwd.mock.calls.length, 1); +}); diff --git a/packages/host-kit/src/internal/host-process.ts b/packages/host-kit/src/internal/host-process.ts index fdde3acb22..47e7234c5a 100644 --- a/packages/host-kit/src/internal/host-process.ts +++ b/packages/host-kit/src/internal/host-process.ts @@ -1,6 +1,7 @@ import type { ExecOptions, ExecResult } from './exec.ts'; import { runCmd, runCmdSync, withoutCommandExecutorOverride } from './exec.ts'; import { sleep } from './retry.ts'; +import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; const PS_TIMEOUT_MS = 1_000; // The one binary behind every POSIX probe: pinning it keeps a hostile PATH from @@ -42,22 +43,24 @@ type HostProcessRunCommand = ( options: ExecOptions, ) => Promise; -export function hostEnvironment(): NodeJS.ProcessEnv { - return process.env; -} +/** The process-shaped object an entry point captures: Node's `process` satisfies it. */ +export type HostEnvironmentSource = Readonly<{ + env: EnvMap; + cwd(): string; +}>; -export function readHostEnvironmentVariable(name: string): string | undefined { - return hostEnvironment()[name]; +/** + * Snapshots the environment and working directory of `source`, which an entry point passes as + * `process`. The environment is copied, so later changes to the source never reach the capture. + */ +export function captureHostEnvironment(source: HostEnvironmentSource): HostEnvironment { + return { env: { ...source.env }, cwd: source.cwd() }; } export function writeHostStderr(value: string): void { process.stderr.write(value); } -export function hostCurrentWorkingDirectory(): string { - return process.cwd(); -} - export function hostNodeExecutablePath(): string { return process.execPath; } diff --git a/packages/host-kit/src/internal/path-resolution.ts b/packages/host-kit/src/internal/path-resolution.ts index e702589c53..6ee7f20265 100644 --- a/packages/host-kit/src/internal/path-resolution.ts +++ b/packages/host-kit/src/internal/path-resolution.ts @@ -20,10 +20,13 @@ export function expandUserHomePath(inputPath: string, options: PathResolutionOpt return inputPath; } -export function resolveUserPath(inputPath: string, options: PathResolutionOptions = {}): string { +export function resolveUserPath( + inputPath: string, + options: PathResolutionOptions & { cwd: string }, +): string { const expandedPath = expandUserHomePath(inputPath, options); if (path.isAbsolute(expandedPath)) return expandedPath; - return path.resolve(options.cwd ?? process.cwd(), expandedPath); + return path.resolve(options.cwd, expandedPath); } /** diff --git a/packages/host-kit/src/process.ts b/packages/host-kit/src/process.ts index 0b62bce639..aaf0448df1 100644 --- a/packages/host-kit/src/process.ts +++ b/packages/host-kit/src/process.ts @@ -1,11 +1,11 @@ export { + captureHostEnvironment, + type HostEnvironmentSource, type ConfirmedProcessStop, confirmedProcessStopDetails, type ConfirmedProcessStopDetails, describeUnconfirmedProcessStop, expandProcessTree, - hostCurrentWorkingDirectory, - hostEnvironment, hostNodeExecutablePath, hostNodeVersion, hostPlatform, @@ -23,7 +23,6 @@ export { readConfirmedProcessStopDetails, readHostCpuArch, readHostCpuArchSync, - readHostEnvironmentVariable, readHostProcessIdentityObservations, readHostProcessIdentityObservationsAsync, readProcessCommand, diff --git a/packages/host-kit/src/session-paths.test.ts b/packages/host-kit/src/session-paths.test.ts index 0b443d3452..b7a29885d0 100644 --- a/packages/host-kit/src/session-paths.test.ts +++ b/packages/host-kit/src/session-paths.test.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import { expandSessionPath } from './session-paths.ts'; test('expandSessionPath resolves tilde, relative-with-cwd, and absolute paths', () => { - const homePath = expandSessionPath('~/flows/replay.ad'); + const homePath = expandSessionPath('~/flows/replay.ad', process.cwd()); assert.equal(homePath, path.join(os.homedir(), 'flows', 'replay.ad')); const relativePath = expandSessionPath('workflows/replay.ad', '/tmp/agent-device-cwd'); diff --git a/packages/host-kit/src/session-paths.ts b/packages/host-kit/src/session-paths.ts index 2ba0789a94..c3f076d9fd 100644 --- a/packages/host-kit/src/session-paths.ts +++ b/packages/host-kit/src/session-paths.ts @@ -18,6 +18,6 @@ export function isSafeSessionSegment(name: string): boolean { return safe.length > 0 && safe !== '.' && safe !== '..'; } -export function expandSessionPath(filePath: string, cwd?: string): string { +export function expandSessionPath(filePath: string, cwd: string): string { return resolveUserPath(filePath, { cwd }); } diff --git a/packages/host-kit/src/status-markers.ts b/packages/host-kit/src/status-markers.ts index 8cff31e570..b20f971e04 100644 --- a/packages/host-kit/src/status-markers.ts +++ b/packages/host-kit/src/status-markers.ts @@ -1,15 +1,20 @@ import { styleText } from 'node:util'; +import type { EnvMap } from '@agent-device/kernel/source-value'; export type CliStatusMarkerStatus = 'pass' | 'fail' | 'warn' | 'skip'; +/** `FORCE_COLOR` decides when set; otherwise color needs a TTY and no `NO_COLOR`. */ +export function shouldColorStatusMarkers(env: EnvMap, stderrIsTTY: boolean): boolean { + return env.FORCE_COLOR !== undefined + ? env.FORCE_COLOR !== '0' + : env.NO_COLOR === undefined && stderrIsTTY; +} + export function formatCliStatusMarker( status: CliStatusMarkerStatus, - options: { passFormat?: 'green' | 'yellow' } = {}, + options: { useColor: boolean; passFormat?: 'green' | 'yellow' }, ): string { - const useColor = - process.env.FORCE_COLOR !== undefined - ? process.env.FORCE_COLOR !== '0' - : process.env.NO_COLOR === undefined && Boolean(process.stderr.isTTY); + const { useColor } = options; if (status === 'pass') { const format = options.passFormat ?? 'green'; return useColor ? styleText(format, '✓', { validateStream: false }) : '✓'; diff --git a/packages/kernel/src/device-isolation.ts b/packages/kernel/src/device-isolation.ts index c4c9674035..7b69b44985 100644 --- a/packages/kernel/src/device-isolation.ts +++ b/packages/kernel/src/device-isolation.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from './source-value.ts'; function normalizeNonEmpty(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; @@ -20,7 +21,7 @@ export function parseSerialAllowlist(value: string): Set { export function resolveAndroidSerialAllowlist( flagValue: string | undefined, - env: NodeJS.ProcessEnv = process.env, + env: EnvMap, ): ReadonlySet | undefined { const configured = normalizeNonEmpty(flagValue) ?? normalizeNonEmpty(env.AGENT_DEVICE_ANDROID_DEVICE_ALLOWLIST); diff --git a/packages/kernel/src/device.ts b/packages/kernel/src/device.ts index 4561ab86fc..217d2dd8e2 100644 --- a/packages/kernel/src/device.ts +++ b/packages/kernel/src/device.ts @@ -1,5 +1,6 @@ import { AppError } from './errors.ts'; import type { DeviceIdentity } from './device-identity.ts'; +import type { EnvMap } from './source-value.ts'; export { deviceIdentity, @@ -422,9 +423,9 @@ export function resolveRequestAppleSimulatorSetPath(params: { requestedSetPath?: string; platform?: PlatformSelector; target?: DeviceTarget; - env?: NodeJS.ProcessEnv; + env: EnvMap; }): string | undefined { - const { platform, target, env = process.env } = params; + const { platform, target, env } = params; if (platform === 'macos' || target === 'desktop') return undefined; const requested = params.requestedSetPath?.trim() || undefined; if (!supportsAppleSimulatorSelection(platform)) return requested; diff --git a/packages/kernel/src/source-value.ts b/packages/kernel/src/source-value.ts index 98a18d6c8f..d6a0f87d10 100644 --- a/packages/kernel/src/source-value.ts +++ b/packages/kernel/src/source-value.ts @@ -139,3 +139,12 @@ function parseEnumSetValue( } export type EnvMap = Record; + +/** + * The process-level inputs a host hands to the code it runs: the environment and working + * directory. An entry point captures it once; everything below receives it. + */ +export type HostEnvironment = Readonly<{ + env: Readonly; + cwd: string; +}>; diff --git a/packages/platform-android/src/app-deployment.ts b/packages/platform-android/src/app-deployment.ts index 710c5b78c5..729a6a91d3 100644 --- a/packages/platform-android/src/app-deployment.ts +++ b/packages/platform-android/src/app-deployment.ts @@ -30,7 +30,7 @@ async function resolveBundletoolInvocation( return cachedBundletoolInvocation.invocation; } - if (await whichCmd('bundletool')) { + if (await whichCmd('bundletool', environment)) { const invocation = { cmd: 'bundletool', prefixArgs: [] } as const; cachedBundletoolInvocation = { key: cacheKey, invocation }; return invocation; @@ -57,7 +57,7 @@ async function runBundletool( environment: AndroidAdbEnvironment, ): Promise { const invocation = await resolveBundletoolInvocation(environment); - await runCmd(invocation.cmd, [...invocation.prefixArgs, ...args], { signal }); + await runCmd(invocation.cmd, [...invocation.prefixArgs, ...args], { signal, env: environment }); } function isAndroidAppBundlePath(appPath: string): boolean { diff --git a/packages/platform-apple/src/__tests__/index-runner-requests.test.ts b/packages/platform-apple/src/__tests__/index-runner-requests.test.ts index ebd939a40a..b1c960a62d 100644 --- a/packages/platform-apple/src/__tests__/index-runner-requests.test.ts +++ b/packages/platform-apple/src/__tests__/index-runner-requests.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; @@ -24,6 +25,8 @@ import { } from './recording-runner-provider.ts'; import { mkdtempForTest } from './tmp-dir.ts'; +bindTestCommandHost(); + // Every Apple runner request site outside `runner/`, driven through its production entry point and // pinned to contracts/fixtures/runner-requests.json. The drives never write a request themselves; // runner/__tests__/index-runner-requests.test.ts reads this source to keep it that way. diff --git a/packages/platform-apple/src/__tests__/interactor-macos-surface.test.ts b/packages/platform-apple/src/__tests__/interactor-macos-surface.test.ts index b0f5dc97b8..51cfc9bf92 100644 --- a/packages/platform-apple/src/__tests__/interactor-macos-surface.test.ts +++ b/packages/platform-apple/src/__tests__/interactor-macos-surface.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { beforeEach, expect, test, vi } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { SCREENSHOT_FULLSCREEN_REASONS } from '@agent-device/contracts/capture'; @@ -51,6 +52,8 @@ import { import { captureScreenshotViaRunner, screenshotIos } from '../core/screenshot.ts'; import { runAppleRunnerCommand } from '../core/runner-client.ts'; +bindTestCommandHost(); + const macOsDevice: DeviceInfo = { platform: 'apple', appleOs: 'macos', diff --git a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts index 2002ab4d74..852b759597 100644 --- a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts +++ b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import type { Interactor, RunnerContext, @@ -17,6 +18,8 @@ import { type RecordedRunnerCall, } from './recording-runner-provider.ts'; +bindTestCommandHost(); + function presentedSnapshot(result: SnapshotRuntimeResult): SnapshotResult { if ('stage' in result) throw new Error('Apple runner snapshot must be presented'); return result; diff --git a/packages/platform-apple/src/__tests__/interactor-snapshot-viewport.test.ts b/packages/platform-apple/src/__tests__/interactor-snapshot-viewport.test.ts index d01d84257c..e55ebe643b 100644 --- a/packages/platform-apple/src/__tests__/interactor-snapshot-viewport.test.ts +++ b/packages/platform-apple/src/__tests__/interactor-snapshot-viewport.test.ts @@ -1,9 +1,12 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { IOS_SIMULATOR, MACOS_DEVICE } from './device-fixtures.ts'; import { createAppleInteractor } from '../interactor.ts'; import { runnerResultFor } from './recording-runner-provider.ts'; +bindTestCommandHost(); + // #3182: an Apple capture publishes the box its rects are measured in — the app-window evidence the // presentation fold already resolves — without the runner growing a second wire key for it. diff --git a/packages/platform-apple/src/core/__tests__/app-launch-close-quit.test.ts b/packages/platform-apple/src/core/__tests__/app-launch-close-quit.test.ts index 74a6522ecc..02465cef9a 100644 --- a/packages/platform-apple/src/core/__tests__/app-launch-close-quit.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-launch-close-quit.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; @@ -24,6 +25,8 @@ import { runCmd } from '@agent-device/host-kit/command'; import { withMockedMacOsHelper } from './macos-helper-test-utils.ts'; import { IOS_TEST_SIMULATOR, MACOS_TEST_DEVICE } from './apple-core-stub-helpers.ts'; +bindTestCommandHost(); + const mockRunCmd = vi.mocked(runCmd); const mockEnsureBootedSimulator = vi.mocked(ensureBootedSimulator); diff --git a/packages/platform-apple/src/core/__tests__/app-settings.test.ts b/packages/platform-apple/src/core/__tests__/app-settings.test.ts index 8f07385ba7..31d4a82120 100644 --- a/packages/platform-apple/src/core/__tests__/app-settings.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-settings.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; @@ -39,6 +40,8 @@ import { assertRejectsAppError } from '../../__tests__/app-error.ts'; import { withFakeAppleTool, type FakeAppleToolResponse } from '../../__tests__/fake-apple-tool.ts'; import { IOS_TEST_SIMULATOR, MACOS_TEST_DEVICE } from './apple-core-stub-helpers.ts'; +bindTestCommandHost(); + const mockRunCmd = vi.mocked(runCmd); const mockRetryWithPolicy = vi.mocked(retryWithPolicy); const mockEnsureBootedSimulator = vi.mocked(ensureBootedSimulator); diff --git a/packages/platform-apple/src/core/__tests__/debug-symbols.test.ts b/packages/platform-apple/src/core/__tests__/debug-symbols.test.ts index 51b1d0f8c9..a12116f14b 100644 --- a/packages/platform-apple/src/core/__tests__/debug-symbols.test.ts +++ b/packages/platform-apple/src/core/__tests__/debug-symbols.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; @@ -7,6 +8,8 @@ import { AppError } from '@agent-device/kernel/errors'; import { withCommandExecutorOverride } from '@agent-device/host-kit/command'; import { mkdtempForTest } from '../../__tests__/tmp-dir.ts'; +bindTestCommandHost(); + const UUID = 'ABCDEFAB-CDEF-ABCD-EFAB-CDEFABCDEFAB'; const NORMALIZED_UUID = 'ABCDEFABCDEFABCDEFABCDEFABCDEFAB'; const OTHER_UUID = '11111111-2222-3333-4444-555555555555'; diff --git a/packages/platform-apple/src/core/debug-symbols.ts b/packages/platform-apple/src/core/debug-symbols.ts index b70a0251ad..717df68b4d 100644 --- a/packages/platform-apple/src/core/debug-symbols.ts +++ b/packages/platform-apple/src/core/debug-symbols.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import path from 'node:path'; import { ensureHostDirectory, @@ -5,7 +6,6 @@ import { readHostTextFile, writeHostTextFile, } from '@agent-device/host-kit/host-file'; -import { hostCurrentWorkingDirectory } from '@agent-device/host-kit/process'; import { readAppleCrashArtifact } from './debug-symbols/crash-artifact.ts'; import { matchImagesToDsyms, readDsymPaths, readDsymSlices } from './debug-symbols/dsym.ts'; import { summarizeCrashArtifact } from './debug-symbols/report.ts'; @@ -26,7 +26,7 @@ export async function symbolicateCrashArtifact( hint: 'Use debug symbols --artifact --dsym or --search-path --out .', }); } - const cwd = options.cwd ?? hostCurrentWorkingDirectory(); + const cwd = options.cwd ?? commandHostEnvironment().cwd; const artifactPath = resolvePath(cwd, options.artifact); const outPath = resolvePath(cwd, options.out ?? defaultOutPath(artifactPath)); const artifactText = await readTextFile(artifactPath, 'crash artifact'); diff --git a/packages/platform-apple/src/core/tool-provider.ts b/packages/platform-apple/src/core/tool-provider.ts index 8e0667e32b..aed371460c 100644 --- a/packages/platform-apple/src/core/tool-provider.ts +++ b/packages/platform-apple/src/core/tool-provider.ts @@ -4,6 +4,7 @@ import { whichCmd, type ExecOptions, type ExecResult, + commandHostEnvironment, } from '@agent-device/host-kit/command'; import type { ScopedSimctlArgs } from '@agent-device/contracts/platform-runtime-host'; import { createScopedProvider } from '@agent-device/kernel/scoped-provider'; @@ -76,7 +77,7 @@ const localAppleToolProvider: AppleToolProvider = { runCmd, async (plistPath) => await readPlistJsonWithCommand(runCmd, plistPath), ), - whichCommand: whichCmd, + whichCommand: async (cmd) => await whichCmd(cmd, commandHostEnvironment().env), }; const appleToolProviderScope = createScopedProvider( diff --git a/packages/platform-apple/src/gesture-facts.test.ts b/packages/platform-apple/src/gesture-facts.test.ts index 987398271f..69694ae447 100644 --- a/packages/platform-apple/src/gesture-facts.test.ts +++ b/packages/platform-apple/src/gesture-facts.test.ts @@ -1,8 +1,11 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { expect, test } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createApplePlatformRuntime } from './runtime.ts'; import { platformRuntimeHostFixture } from './runtime.fixtures.ts'; +bindTestCommandHost(); + function appleDevice(overrides: Partial = {}): DeviceInfo { return { platform: 'apple', diff --git a/packages/platform-apple/src/os/macos/app-backend.test.ts b/packages/platform-apple/src/os/macos/app-backend.test.ts index 939e1f8415..8adfc7a804 100644 --- a/packages/platform-apple/src/os/macos/app-backend.test.ts +++ b/packages/platform-apple/src/os/macos/app-backend.test.ts @@ -1,6 +1,9 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { afterEach, expect, test, vi } from 'vitest'; import { hostMacOsAppBackend } from './app-backend.ts'; +bindTestCommandHost(); + afterEach(() => { vi.unstubAllEnvs(); }); diff --git a/packages/platform-apple/src/os/macos/app-backend.ts b/packages/platform-apple/src/os/macos/app-backend.ts index 5f8d6974a4..7e452a3328 100644 --- a/packages/platform-apple/src/os/macos/app-backend.ts +++ b/packages/platform-apple/src/os/macos/app-backend.ts @@ -1,7 +1,7 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { readMacOsAppBackend, type MacOsAppBackend } from '@agent-device/contracts/session'; -import { readHostEnvironmentVariable } from '@agent-device/host-kit/process'; /** The app-session backend the host selected through `AGENT_DEVICE_MACOS_APP_BACKEND`. */ export function hostMacOsAppBackend(): MacOsAppBackend { - return readMacOsAppBackend(readHostEnvironmentVariable); + return readMacOsAppBackend((name) => commandHostEnvironment().env[name]); } diff --git a/packages/platform-apple/src/os/macos/helper-build.test.ts b/packages/platform-apple/src/os/macos/helper-build.test.ts index e8004f3c47..70262dcb6f 100644 --- a/packages/platform-apple/src/os/macos/helper-build.test.ts +++ b/packages/platform-apple/src/os/macos/helper-build.test.ts @@ -1,9 +1,12 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; import { afterEach, beforeEach, test, vi } from 'vitest'; import { mkdtempForTest } from '../../__tests__/tmp-dir.ts'; +bindTestCommandHost(); + // Getting to the macOS helper binary means answering two host facts first: where the Swift package // sits relative to this module, and under which home the install directory lives. A Linux coverage // runner cannot answer either the way a macOS checkout does, so those two lookups are declared diff --git a/packages/platform-apple/src/os/macos/helper-resolution.test.ts b/packages/platform-apple/src/os/macos/helper-resolution.test.ts index 5955cd544a..ae0bf903ce 100644 --- a/packages/platform-apple/src/os/macos/helper-resolution.test.ts +++ b/packages/platform-apple/src/os/macos/helper-resolution.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; @@ -5,6 +6,8 @@ import path from 'node:path'; import { mkdtempForTest } from '../../__tests__/tmp-dir.ts'; import { quitMacOsApp, resolveMacOsHelperPackageRootFrom } from './helper.ts'; +bindTestCommandHost(); + test('resolveMacOsHelperPackageRootFrom finds helper package from source and dist-like paths', async () => { const repoRoot = await mkdtempForTest('agent-device-helper-root-'); const helperRoot = path.join(repoRoot, 'apple', 'macos-helper'); diff --git a/packages/platform-apple/src/os/macos/helper.ts b/packages/platform-apple/src/os/macos/helper.ts index 97cc51f146..cfae68348c 100644 --- a/packages/platform-apple/src/os/macos/helper.ts +++ b/packages/platform-apple/src/os/macos/helper.ts @@ -15,15 +15,12 @@ import { renameHostPath, writeHostTextFile, } from '@agent-device/host-kit/host-file'; -import { - hostPlatform, - readHostEnvironmentVariable, - writeHostStderr, -} from '@agent-device/host-kit/process'; +import { hostPlatform, writeHostStderr } from '@agent-device/host-kit/process'; import { resolveExecutableOverridePath, runCmdBackground, type ExecBackgroundResult, + commandHostEnvironment, } from '@agent-device/host-kit/command'; import type { MacOsHelperSurface, SessionSurface } from '@agent-device/contracts/session'; import { @@ -120,7 +117,7 @@ const MACOS_GHOST_CURSOR_ENV = 'AGENT_DEVICE_MACOS_GHOST_CURSOR'; */ function appendGhostCursorArg(args: string[], surface: SessionSurface | undefined): void { if (surface !== 'app') return; - if (readHostEnvironmentVariable(MACOS_GHOST_CURSOR_ENV)?.trim() === '0') return; + if (commandHostEnvironment().env[MACOS_GHOST_CURSOR_ENV]?.trim() === '0') return; args.push('--ghost-cursor'); } @@ -235,7 +232,7 @@ async function readInstalledMacOsHelperFingerprint(): Promise { async function ensureMacOsHelperBinary(): Promise { const configuredPath = await resolveExecutableOverridePath( - readHostEnvironmentVariable(MACOS_HELPER_ENV_PATH), + commandHostEnvironment().env[MACOS_HELPER_ENV_PATH], MACOS_HELPER_ENV_PATH, ); if (configuredPath) { @@ -274,7 +271,7 @@ async function ensureMacOsHelperBinary(): Promise { } async function resolveMacOsHelperCommandPath(): Promise { - const configuredPath = readHostEnvironmentVariable(MACOS_HELPER_ENV_PATH)?.trim(); + const configuredPath = commandHostEnvironment().env[MACOS_HELPER_ENV_PATH]?.trim(); if (configuredPath) { const resolvedPath = await resolveExecutableOverridePath(configuredPath, MACOS_HELPER_ENV_PATH); if (resolvedPath) return resolvedPath; diff --git a/packages/platform-apple/src/os/macos/native-app-interactor.test.ts b/packages/platform-apple/src/os/macos/native-app-interactor.test.ts index e72ab77afe..1e6a8ce33f 100644 --- a/packages/platform-apple/src/os/macos/native-app-interactor.test.ts +++ b/packages/platform-apple/src/os/macos/native-app-interactor.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { afterEach, beforeEach, test, vi } from 'vitest'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; @@ -5,6 +6,8 @@ import { AppError } from '@agent-device/kernel/errors'; import { createLocalAppleToolProvider, withAppleToolProvider } from '../../core/tool-provider.ts'; import { macOsNativeAppInteractor } from './native-app-interactor.ts'; +bindTestCommandHost(); + const context: RunnerContext = { appBundleId: 'com.apple.TextEdit' }; // The drawn cursor is the default; pin it so an operator's opt-out does not change the argv. diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index 7e61078a51..f1fdb5444b 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { expect, test, vi } from 'vitest'; vi.mock('./core/app-resolution.ts', async (importOriginal) => ({ @@ -19,6 +20,8 @@ import type { AppleOS, DeviceInfo } from '@agent-device/kernel/device'; import { createApplePlatformRuntime } from './runtime.ts'; import { platformRuntimeHostFixture } from './runtime.fixtures.ts'; +bindTestCommandHost(); + const presentIosAcquisition: SnapshotRuntimeHost['presentIosAcquisition'] = async () => ({ backend: 'xctest', producer: 'appium-source', diff --git a/packages/platform-harmonyos/package.json b/packages/platform-harmonyos/package.json index 71bbf78c78..8c8a69d019 100644 --- a/packages/platform-harmonyos/package.json +++ b/packages/platform-harmonyos/package.json @@ -11,6 +11,9 @@ "@agent-device/kernel": "workspace:*", "@agent-device/provision-kit": "workspace:*" }, + "devDependencies": { + "@agent-device/test-kit": "workspace:*" + }, "exports": { ".": { "types": "./src/index.ts", diff --git a/packages/platform-harmonyos/src/__tests__/hdc.test.ts b/packages/platform-harmonyos/src/__tests__/hdc.test.ts index 06a396aacd..f4e9ab8a35 100644 --- a/packages/platform-harmonyos/src/__tests__/hdc.test.ts +++ b/packages/platform-harmonyos/src/__tests__/hdc.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; import path from 'node:path'; @@ -7,13 +8,8 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runCmd: vi.fn() }; }); -vi.mock('@agent-device/host-kit/process', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, hostEnvironment: vi.fn() }; -}); import { runCmd } from '@agent-device/host-kit/command'; -import { hostEnvironment } from '@agent-device/host-kit/process'; import { DEFAULT_HARMONY_HDC_TIMEOUT_MS, ensureHarmonyToolchainPathConfigured, @@ -22,6 +18,8 @@ import { } from '../hdc.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; +bindTestCommandHost(); + const mockRunCmd = vi.mocked(runCmd); const device = { id: '127.0.0.1:5555' }; @@ -143,10 +141,10 @@ test('ensureHarmonyToolchainPathConfigured creates PATH and dedupes a root alrea test('ensureHarmonyToolchainPathConfigured defaults to the host environment', async () => { const toolchains = await seedDirectToolchain(mkdtempForTestSync('agent-device-hdc-host-')); - const hostEnv: NodeJS.ProcessEnv = { PATH: '/usr/bin', HDC_SDK_PATH: toolchains }; - vi.mocked(hostEnvironment).mockReturnValue(hostEnv); + vi.stubEnv('PATH', '/usr/bin'); + vi.stubEnv('HDC_SDK_PATH', toolchains); await ensureHarmonyToolchainPathConfigured(); - assert.equal(hostEnv.PATH, [toolchains, '/usr/bin'].join(path.delimiter)); + assert.equal(process.env.PATH, [toolchains, '/usr/bin'].join(path.delimiter)); }); diff --git a/packages/platform-harmonyos/src/hdc.ts b/packages/platform-harmonyos/src/hdc.ts index 550797de52..09cefcee82 100644 --- a/packages/platform-harmonyos/src/hdc.ts +++ b/packages/platform-harmonyos/src/hdc.ts @@ -10,8 +10,8 @@ import { runCmd, type ExecOptions, type ExecResult, + commandHostEnvironment, } from '@agent-device/host-kit/command'; -import { hostEnvironment } from '@agent-device/host-kit/process'; export type HarmonyHdcOptions = Pick< ExecOptions, @@ -50,7 +50,7 @@ export async function runHarmonyShell( * Honor the documented roots so the daemon sees the same HDC binary as a shell. */ export async function ensureHarmonyToolchainPathConfigured( - env: NodeJS.ProcessEnv = hostEnvironment(), + env: NodeJS.ProcessEnv = commandHostEnvironment().env, ): Promise { const toolchainRoots = [ env.HDC_SDK_PATH, diff --git a/packages/platform-harmonyos/tsconfig.json b/packages/platform-harmonyos/tsconfig.json index ea5df312b1..45051401b5 100644 --- a/packages/platform-harmonyos/tsconfig.json +++ b/packages/platform-harmonyos/tsconfig.json @@ -15,6 +15,7 @@ { "path": "../contracts" }, { "path": "../host-kit" }, { "path": "../kernel" }, - { "path": "../provision-kit" } + { "path": "../provision-kit" }, + { "path": "../test-kit" } ] } diff --git a/packages/platform-linux/package.json b/packages/platform-linux/package.json index 283d45b657..b03161f395 100644 --- a/packages/platform-linux/package.json +++ b/packages/platform-linux/package.json @@ -10,6 +10,9 @@ "@agent-device/host-kit": "workspace:*", "@agent-device/kernel": "workspace:*" }, + "devDependencies": { + "@agent-device/test-kit": "workspace:*" + }, "exports": { ".": { "types": "./src/index.ts", diff --git a/packages/platform-linux/src/__tests__/atspi-bridge.test.ts b/packages/platform-linux/src/__tests__/atspi-bridge.test.ts index 49745ccd3a..d043a4d446 100644 --- a/packages/platform-linux/src/__tests__/atspi-bridge.test.ts +++ b/packages/platform-linux/src/__tests__/atspi-bridge.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { afterAll, beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; @@ -10,6 +11,8 @@ import { captureAccessibilityTree } from '../atspi-bridge.ts'; import { runCmd, whichCmd } from '@agent-device/host-kit/command'; import { AppError } from '@agent-device/kernel/errors'; +bindTestCommandHost(); + const mockRunCmd = vi.mocked(runCmd); const mockWhichCmd = vi.mocked(whichCmd); diff --git a/packages/platform-linux/src/__tests__/clipboard.test.ts b/packages/platform-linux/src/__tests__/clipboard.test.ts index 3d41659b87..ea76377172 100644 --- a/packages/platform-linux/src/__tests__/clipboard.test.ts +++ b/packages/platform-linux/src/__tests__/clipboard.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { afterAll, beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; @@ -10,6 +11,8 @@ import { runCmd, whichCmd } from '@agent-device/host-kit/command'; import { readLinuxClipboard, writeLinuxClipboard } from '../clipboard.ts'; import { AppError } from '@agent-device/kernel/errors'; +bindTestCommandHost(); + const mockRunCmd = vi.mocked(runCmd); const mockWhichCmd = vi.mocked(whichCmd); diff --git a/packages/platform-linux/src/__tests__/input-actions.test.ts b/packages/platform-linux/src/__tests__/input-actions.test.ts index 43c53c9dc8..1f5ce950be 100644 --- a/packages/platform-linux/src/__tests__/input-actions.test.ts +++ b/packages/platform-linux/src/__tests__/input-actions.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { afterAll, beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; @@ -10,6 +11,8 @@ import { runCmd, whichCmd } from '@agent-device/host-kit/command'; import { resetInputToolCache } from '../linux-env.ts'; import { fillLinux, pressLinux, scrollLinux, typeLinux, sendKey } from '../input-actions.ts'; +bindTestCommandHost(); + const mockRunCmd = vi.mocked(runCmd); const mockWhichCmd = vi.mocked(whichCmd); diff --git a/packages/platform-linux/src/__tests__/tool-provider.test.ts b/packages/platform-linux/src/__tests__/tool-provider.test.ts index 1731549dd6..95f0c654c4 100644 --- a/packages/platform-linux/src/__tests__/tool-provider.test.ts +++ b/packages/platform-linux/src/__tests__/tool-provider.test.ts @@ -1,9 +1,12 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { afterAll, beforeEach, test } from 'vitest'; import assert from 'node:assert/strict'; import { pressLinux } from '../input-actions.ts'; import { resetInputToolCache } from '../linux-env.ts'; import { createLocalLinuxToolProvider, withLinuxToolProvider } from '../tool-provider.ts'; +bindTestCommandHost(); + const originalPlatform = process.platform; const originalEnv = { ...process.env }; diff --git a/packages/platform-linux/src/linux-env.ts b/packages/platform-linux/src/linux-env.ts index 24afee8704..3a86d3df4c 100644 --- a/packages/platform-linux/src/linux-env.ts +++ b/packages/platform-linux/src/linux-env.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; /** * Shared Linux environment detection — display server and input tool. * @@ -6,14 +7,13 @@ */ import { AppError } from '@agent-device/kernel/errors'; -import { hostEnvironment } from '@agent-device/host-kit/process'; import { resolveLinuxToolProvider, type LinuxToolProvider } from './tool-provider.ts'; export type DisplayServer = 'wayland' | 'x11'; export type InputTool = 'xdotool' | 'ydotool'; function detectDisplayServer(): DisplayServer { - const environment = hostEnvironment(); + const environment = commandHostEnvironment().env; if (environment['WAYLAND_DISPLAY']) return 'wayland'; if (environment['XDG_SESSION_TYPE'] === 'wayland') return 'wayland'; return 'x11'; diff --git a/packages/platform-linux/src/tool-provider.ts b/packages/platform-linux/src/tool-provider.ts index 43f1adca60..2ac2038408 100644 --- a/packages/platform-linux/src/tool-provider.ts +++ b/packages/platform-linux/src/tool-provider.ts @@ -6,9 +6,9 @@ import { whichCmd, type ExecOptions, type ExecResult, + commandHostEnvironment, } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { hostEnvironment } from '@agent-device/host-kit/process'; import { sleep } from '@agent-device/host-kit/retry'; import { createScopedProvider } from '@agent-device/kernel/scoped-provider'; @@ -79,12 +79,15 @@ export type LinuxToolProvider = { input?: LinuxInputProvider; }; +const whichOnPath = async (cmd: string): Promise => + await whichCmd(cmd, commandHostEnvironment().env); + const localLinuxToolProvider: LinuxToolProvider = { runCommand: runCmd, - whichCommand: whichCmd, - desktop: createLocalLinuxDesktopProvider(runCmd, whichCmd), - clipboard: createLocalLinuxClipboardProvider(runCmd, whichCmd), - screenshot: createLocalLinuxScreenshotProvider(runCmd, whichCmd), + whichCommand: whichOnPath, + desktop: createLocalLinuxDesktopProvider(runCmd, whichOnPath), + clipboard: createLocalLinuxClipboardProvider(runCmd, whichOnPath), + screenshot: createLocalLinuxScreenshotProvider(runCmd, whichOnPath), }; const linuxToolProviderScope = createScopedProvider( @@ -307,6 +310,6 @@ async function resolveLocalLinuxTool( } function isLinuxWayland(): boolean { - const environment = hostEnvironment(); + const environment = commandHostEnvironment().env; return Boolean(environment['WAYLAND_DISPLAY']) || environment['XDG_SESSION_TYPE'] === 'wayland'; } diff --git a/packages/platform-linux/tsconfig.json b/packages/platform-linux/tsconfig.json index 3431c632a3..30ac931c55 100644 --- a/packages/platform-linux/tsconfig.json +++ b/packages/platform-linux/tsconfig.json @@ -14,6 +14,7 @@ { "path": "../capture-kit" }, { "path": "../contracts" }, { "path": "../host-kit" }, - { "path": "../kernel" } + { "path": "../kernel" }, + { "path": "../test-kit" } ] } diff --git a/packages/platform-vega/src/tool-provider.ts b/packages/platform-vega/src/tool-provider.ts index 4601b266e5..9ee64547c3 100644 --- a/packages/platform-vega/src/tool-provider.ts +++ b/packages/platform-vega/src/tool-provider.ts @@ -6,6 +6,7 @@ import { whichCmd, type ExecOptions, type ExecResult, + commandHostEnvironment, } from '@agent-device/host-kit/command'; import { hostHomeDirectory } from '@agent-device/host-kit/host-file'; import { createScopedProvider } from '@agent-device/kernel/scoped-provider'; @@ -93,7 +94,7 @@ let cachedVegaCliExecutable: string | undefined; async function resolveVegaCliExecutable(): Promise { if (cachedVegaCliExecutable) return cachedVegaCliExecutable; - if (await whichCmd('vega')) { + if (await whichCmd('vega', commandHostEnvironment().env)) { cachedVegaCliExecutable = 'vega'; return cachedVegaCliExecutable; } diff --git a/packages/platform-web/src/__tests__/agent-browser-provider-boundary-faults.test.ts b/packages/platform-web/src/__tests__/agent-browser-provider-boundary-faults.test.ts index 78a88e3f1b..6221138b17 100644 --- a/packages/platform-web/src/__tests__/agent-browser-provider-boundary-faults.test.ts +++ b/packages/platform-web/src/__tests__/agent-browser-provider-boundary-faults.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { test } from 'vitest'; @@ -10,6 +11,8 @@ import { import { withCommandExecutorOverride } from '@agent-device/host-kit/command'; import { createAgentBrowserWebProvider } from '../agent-browser-provider.ts'; +bindTestCommandHost(); + const COMMAND_CLASS_OPERATIONS = { mutation: (provider: ReturnType) => provider.click(10, 20), read: async (provider: ReturnType) => { diff --git a/packages/platform-web/src/__tests__/agent-browser-provider-dispatch-disclosure.test.ts b/packages/platform-web/src/__tests__/agent-browser-provider-dispatch-disclosure.test.ts index 28ed1bd15f..ac379a5d9e 100644 --- a/packages/platform-web/src/__tests__/agent-browser-provider-dispatch-disclosure.test.ts +++ b/packages/platform-web/src/__tests__/agent-browser-provider-dispatch-disclosure.test.ts @@ -10,6 +10,7 @@ import { assertDispatchDisclosureDriversMatchRows, DISPATCH_DISCLOSURE_TABLE_PATH, dispatchDisclosureRowsOwnedBy, + bindTestCommandHost, } from '@agent-device/test-kit'; import { AppError } from '@agent-device/kernel/errors'; import { withCommandExecutorOverride, type ExecResult } from '@agent-device/host-kit/command'; @@ -21,6 +22,8 @@ import { import { createAgentBrowserWebProvider } from '../agent-browser-provider.ts'; import type { WebProvider } from '../provider.ts'; +bindTestCommandHost(); + /** The managed install exists, but every CLI call is answered by `answer`. */ async function withScriptedBackend( answer: (cliArgs: readonly string[]) => ExecResult, diff --git a/packages/platform-web/src/__tests__/agent-browser-provider-record-refresh.test.ts b/packages/platform-web/src/__tests__/agent-browser-provider-record-refresh.test.ts index 995a9a12e1..d8d87dd41e 100644 --- a/packages/platform-web/src/__tests__/agent-browser-provider-record-refresh.test.ts +++ b/packages/platform-web/src/__tests__/agent-browser-provider-record-refresh.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; // agent-browser-provider's owned-process record gate: which agent-browser calls pay the // process-table scan that rewrites the managed fleet record. The scan exists so the startup // reaper and the leak oracle can see the fleet; re-deriving it after every command does not make @@ -13,6 +14,8 @@ import { withManagedAgentBrowserProvider, } from './test-utils.ts'; +bindTestCommandHost(); + const { processRecordScanMock, fleetRecordLiveMock } = vi.hoisted(() => ({ processRecordScanMock: vi.fn(async () => ({ count: 0, pids: [], processes: [] })), fleetRecordLiveMock: vi.fn(async () => false), diff --git a/packages/platform-web/src/__tests__/agent-browser-provider-record-retention.test.ts b/packages/platform-web/src/__tests__/agent-browser-provider-record-retention.test.ts index aaeb6bc951..90004955cd 100644 --- a/packages/platform-web/src/__tests__/agent-browser-provider-record-retention.test.ts +++ b/packages/platform-web/src/__tests__/agent-browser-provider-record-retention.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; // The owned-process record gate end to end over one real record file: the provider's gate, a real // daemon-scoped record store, and the real same-pid identity probe asking the host about real // processes. Only the process-table scan is stood in for, which is what makes what is claimed here @@ -25,6 +26,8 @@ import { mkdtempForTestSync, } from './test-utils.ts'; +bindTestCommandHost(); + const { processRecordScanMock } = vi.hoisted(() => ({ processRecordScanMock: vi.fn(async () => ({ count: 0, pids: [], processes: [] })), })); diff --git a/packages/platform-web/src/agent-browser-install.ts b/packages/platform-web/src/agent-browser-install.ts index 8b4093ba30..056b5d1665 100644 --- a/packages/platform-web/src/agent-browser-install.ts +++ b/packages/platform-web/src/agent-browser-install.ts @@ -1,13 +1,12 @@ import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; -import { runCmd } from '@agent-device/host-kit/command'; +import { runCmd, commandHostEnvironment } from '@agent-device/host-kit/command'; import { ensureHostDirectorySync, hostFileStatSync, writeHostTextFileSync, } from '@agent-device/host-kit/host-file'; import { - hostEnvironment, hostNodeExecutablePath, hostNodeVersion, hostPlatform, @@ -35,7 +34,10 @@ export async function installManagedAgentBrowserPackage(params: { '--no-save', params.packageSpec, ]); - await runCmd(npm.command, npm.args, { env: hostEnvironment(), timeoutMs: params.timeoutMs }); + await runCmd(npm.command, npm.args, { + env: commandHostEnvironment().env, + timeoutMs: params.timeoutMs, + }); } export function writeManagedAgentBrowserManifest(params: { @@ -66,7 +68,7 @@ export function writeManagedAgentBrowserManifest(params: { */ function npmCommand(args: string[]): { command: string; args: string[] } { if (hostPlatform() !== 'win32') return { command: 'npm', args }; - const npmCliScript = resolveWindowsNpmCliScript(hostEnvironment()); + const npmCliScript = resolveWindowsNpmCliScript(commandHostEnvironment().env); if (!npmCliScript) { throw new AppError('TOOL_MISSING', 'npm not found in PATH', { nodeExecPath: hostNodeExecutablePath(), diff --git a/packages/platform-web/src/agent-browser-lifecycle.test.ts b/packages/platform-web/src/agent-browser-lifecycle.test.ts index 6ad2c00494..ea74bc7d1f 100644 --- a/packages/platform-web/src/agent-browser-lifecycle.test.ts +++ b/packages/platform-web/src/agent-browser-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -37,6 +38,8 @@ import { managedInstallPaths } from './agent-browser-install-paths.ts'; import { recordManagedAgentBrowserProcesses } from './agent-browser-process-record.ts'; import { getManagedAgentBrowserStatus } from './agent-browser-tool.ts'; +bindTestCommandHost(); + const mockRunCmd = vi.mocked(runCmdMock); beforeEach(() => { diff --git a/packages/platform-web/src/agent-browser-lifecycle.ts b/packages/platform-web/src/agent-browser-lifecycle.ts index 43c3a63e59..20d1ca450e 100644 --- a/packages/platform-web/src/agent-browser-lifecycle.ts +++ b/packages/platform-web/src/agent-browser-lifecycle.ts @@ -1,9 +1,9 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import path from 'node:path'; import { uniqueStrings } from '@agent-device/kernel/collections'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { hostFileStatSync, readHostDirectorySync } from '@agent-device/host-kit/host-file'; import { - hostEnvironment, type OwnedProcessRecordStore, reapOwnedProcessRecordsAtStartup, } from '@agent-device/host-kit/process'; @@ -63,7 +63,7 @@ export async function cleanupManagedAgentBrowserOrphans( return skippedCleanupResult('open-web-session', { openWebSessionNames }); } - const idleTimeoutMs = resolveAgentBrowserIdleTimeoutMs(hostEnvironment()); + const idleTimeoutMs = resolveAgentBrowserIdleTimeoutMs(commandHostEnvironment().env); const latestActivityMs = readLatestManagedBrowserActivityMs(status); if (latestActivityMs !== undefined && Date.now() - latestActivityMs < idleTimeoutMs) { return skippedCleanupResult('recent-browser-activity', { idleTimeoutMs, latestActivityMs }); diff --git a/packages/platform-web/src/agent-browser-provider-rects.test.ts b/packages/platform-web/src/agent-browser-provider-rects.test.ts index 68f63bdca0..55cafe1aa3 100644 --- a/packages/platform-web/src/agent-browser-provider-rects.test.ts +++ b/packages/platform-web/src/agent-browser-provider-rects.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { test, vi } from 'vitest'; @@ -22,6 +23,8 @@ import type { WebSnapshotResult } from './provider.ts'; import { withCommandExecutorOverride, type ExecResult } from '@agent-device/host-kit/command'; import { AppError } from '@agent-device/kernel/errors'; +bindTestCommandHost(); + /** * One backend call, with the text the provider wrote to its stdin — which is how `batch` receives * the work it runs inside a single process. diff --git a/packages/platform-web/src/agent-browser-provider.test.ts b/packages/platform-web/src/agent-browser-provider.test.ts index db9836016b..57fde3ce26 100644 --- a/packages/platform-web/src/agent-browser-provider.test.ts +++ b/packages/platform-web/src/agent-browser-provider.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { beforeEach, test, vi } from 'vitest'; @@ -28,6 +29,8 @@ import { AppError } from '@agent-device/kernel/errors'; import { buildSelectorChainForNode, resolveRecordedTarget } from '@agent-device/selectors'; import { attachRefs } from '@agent-device/kernel/snapshot'; +bindTestCommandHost(); + type AgentBrowserCall = { cmd: string; args: readonly string[]; diff --git a/packages/platform-web/src/agent-browser-tool.test.ts b/packages/platform-web/src/agent-browser-tool.test.ts index 9530f12125..314b9120db 100644 --- a/packages/platform-web/src/agent-browser-tool.test.ts +++ b/packages/platform-web/src/agent-browser-tool.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -17,6 +18,8 @@ import { import { AppError } from '@agent-device/kernel/errors'; import { withCommandExecutorOverride } from '@agent-device/host-kit/command'; +bindTestCommandHost(); + type SpawnedCommand = { cmd: string; args: readonly string[] }; test('managed agent-browser reports actionable guidance when install is missing', async () => { diff --git a/packages/platform-web/src/agent-browser-tool.ts b/packages/platform-web/src/agent-browser-tool.ts index e00003d72b..d6ef392fef 100644 --- a/packages/platform-web/src/agent-browser-tool.ts +++ b/packages/platform-web/src/agent-browser-tool.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { runCmd, type ExecResult } from '@agent-device/host-kit/command'; +import { runCmd, type ExecResult, commandHostEnvironment } from '@agent-device/host-kit/command'; import { acquireProcessLock, withProcessLock } from '@agent-device/host-kit/file'; import { createHostDirectoryLinkSync, @@ -12,8 +12,6 @@ import { removeHostFileSync, } from '@agent-device/host-kit/host-file'; import { - hostCurrentWorkingDirectory, - hostEnvironment, hostNodeExecutablePath, hostNodeVersion, hostPlatform, @@ -127,7 +125,7 @@ export function getManagedAgentBrowserStatus(options: { stateDir?: string; }): AgentBrowserToolStatus { const stateDir = - options.stateDir ?? hostEnvironment().AGENT_DEVICE_STATE_DIR ?? defaultStateDir(); + options.stateDir ?? commandHostEnvironment().env.AGENT_DEVICE_STATE_DIR ?? defaultStateDir(); const { installDir, homeDir, runtimeHomeDir, socketDir } = managedInstallPaths( stateDir, MANAGED_AGENT_BROWSER_VERSION, @@ -163,7 +161,7 @@ async function spawnManagedAgentBrowser( if (!status.entryScript) throw missingManagedToolError(status); return await runCmd(hostNodeExecutablePath(), [status.entryScript, ...args], { allowFailure: options.allowFailure, - env: managedAgentBrowserEnv(status, hostEnvironment()), + env: managedAgentBrowserEnv(status, commandHostEnvironment().env), timeoutMs: options.timeoutMs, signal: options.signal, stdin: options.stdin, @@ -290,7 +288,10 @@ function assertWebNodeSupported(nodeMajor: number): void { } function defaultStateDir(): string { - return path.join(hostEnvironment().HOME ?? hostCurrentWorkingDirectory(), '.agent-device'); + return path.join( + commandHostEnvironment().env.HOME ?? commandHostEnvironment().cwd, + '.agent-device', + ); } export function mapManagedAgentBrowserError(error: unknown): AppError { diff --git a/packages/provider-webdriver/src/webdriver-utils.test.ts b/packages/provider-webdriver/src/webdriver-utils.test.ts index f9a31231bb..9073922b1c 100644 --- a/packages/provider-webdriver/src/webdriver-utils.test.ts +++ b/packages/provider-webdriver/src/webdriver-utils.test.ts @@ -141,6 +141,27 @@ test('the hub app resolver passes references through, uploads local files, and p } }); +test('the hub app resolver refuses a relative app path when the request names no working directory', async () => { + const uploadFile = vi.fn(async () => 'hub://never'); + + await assert.rejects( + resolveHubAppReference({ + service: 'Hub', + app: 'App.apk', + referenceLabel: 'a hub:// app id', + ...hubReferenceGrammar, + uploadFile, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.equal(error.details?.reason, 'provider_app_cwd_missing'); + return true; + }, + ); + assert.equal(uploadFile.mock.calls.length, 0); +}); + test('appFileUploadForm carries a regular app file and refuses anything else typed', async () => { const tempDir = await mkdtempForTest('agent-device-upload-form-'); try { diff --git a/packages/provider-webdriver/src/webdriver-utils.ts b/packages/provider-webdriver/src/webdriver-utils.ts index b2babf7728..d10669977c 100644 --- a/packages/provider-webdriver/src/webdriver-utils.ts +++ b/packages/provider-webdriver/src/webdriver-utils.ts @@ -165,7 +165,7 @@ export async function resolveHubAppReference(options: { const reference = options.parseReference(app); if (reference !== undefined) return reference; if (/^https?:\/\//i.test(app)) return app; - const appPath = path.resolve(options.cwd ?? process.cwd(), app); + const appPath = path.isAbsolute(app) ? app : path.resolve(requireAppCwd(options), app); if (!fs.existsSync(appPath)) { throw new AppError( 'INVALID_ARGS', @@ -176,6 +176,17 @@ export async function resolveHubAppReference(options: { return await options.uploadFile(appPath, options.signal); } +// A relative app path needs the working directory of the client that named it; the daemon fills it +// from the request, and no ambient directory stands in for a request that names none. +function requireAppCwd(options: { service: string; app: string; cwd?: string }): string { + if (options.cwd !== undefined) return options.cwd; + throw new AppError( + 'INVALID_ARGS', + `${options.service} --provider-app ${options.app} is a relative path and the request names no working directory; pass an absolute path.`, + { providerApp: options.app, reason: 'provider_app_cwd_missing' }, + ); +} + const PROVIDER_API_TIMEOUT_MS = 15_000; /** Service name and remediation hints a verification failure carries. */ diff --git a/packages/react-native/src/metro/metro-session-hints.ts b/packages/react-native/src/metro/metro-session-hints.ts index cd2576c44b..ae3c13c047 100644 --- a/packages/react-native/src/metro/metro-session-hints.ts +++ b/packages/react-native/src/metro/metro-session-hints.ts @@ -18,7 +18,9 @@ export type MetroSessionOptions = { stateDir?: string; session: string }; function resolveMetroSessionScope(options: MetroSessionOptions): MetroSessionScope { return { - stateDir: resolveDaemonPaths(options.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR).baseDir, + stateDir: resolveDaemonPaths(options.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR, { + cwd: process.cwd(), + }).baseDir, session: options.session, }; } diff --git a/packages/remote/src/__tests__/daemon-artifacts-save-script.test.ts b/packages/remote/src/__tests__/daemon-artifacts-save-script.test.ts index 137217d2ba..5cb0b64a60 100644 --- a/packages/remote/src/__tests__/daemon-artifacts-save-script.test.ts +++ b/packages/remote/src/__tests__/daemon-artifacts-save-script.test.ts @@ -7,6 +7,7 @@ * remote request that would produce a file the caller can never see is refused here instead of * appearing to succeed. */ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; @@ -28,7 +29,13 @@ function replayRequest(saveScript?: boolean | string) { test('a remote daemon refuses --save-script, naming the caller/daemon split', async () => { await assert.rejects( - async () => await prepareRemoteRequestArtifacts(replayRequest(true), REMOTE, NO_CANCELLATION), + async () => + await prepareRemoteRequestArtifacts( + replayRequest(true), + REMOTE, + NO_CANCELLATION, + createTestHostEnvironment(), + ), (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS' && @@ -43,20 +50,31 @@ test('a remote daemon refuses an explicit --save-script output path too', async replayRequest('./flows/login.healed.ad'), REMOTE, NO_CANCELLATION, + createTestHostEnvironment(), ), (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS', ); }); test('a local daemon leaves --save-script untouched', async () => { - const prepared = await prepareRemoteRequestArtifacts(replayRequest(true), LOCAL, NO_CANCELLATION); + const prepared = await prepareRemoteRequestArtifacts( + replayRequest(true), + LOCAL, + NO_CANCELLATION, + createTestHostEnvironment(), + ); assert.equal(prepared.flags?.saveScript, true); assert.deepEqual(prepared.positionals, ['./flows/login.ad']); }); test('a remote replay without --save-script passes through unchanged', async () => { - const prepared = await prepareRemoteRequestArtifacts(replayRequest(), REMOTE, NO_CANCELLATION); + const prepared = await prepareRemoteRequestArtifacts( + replayRequest(), + REMOTE, + NO_CANCELLATION, + createTestHostEnvironment(), + ); assert.deepEqual(prepared.positionals, ['./flows/login.ad']); assert.equal(prepared.uploadedArtifactId, undefined); diff --git a/packages/remote/src/__tests__/daemon-artifacts-test-command.test.ts b/packages/remote/src/__tests__/daemon-artifacts-test-command.test.ts index 898ecc3543..a672274891 100644 --- a/packages/remote/src/__tests__/daemon-artifacts-test-command.test.ts +++ b/packages/remote/src/__tests__/daemon-artifacts-test-command.test.ts @@ -5,6 +5,7 @@ * command: the client resolves the real local artifacts root itself and redirects the daemon to * a temp path it owns, exactly as it already does for `screenshot`/`record start`. */ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import path from 'node:path'; import { test } from 'vitest'; @@ -29,6 +30,7 @@ test('a remote daemon redirects an explicit --artifacts-dir to a temp path it ow testRequest('remote-device-artifacts/ad-test'), REMOTE, NO_CANCELLATION, + createTestHostEnvironment(), ); const redirected = (prepared.flags as Record | undefined)?.artifactsDir; @@ -45,6 +47,7 @@ test('a remote daemon redirects the default artifacts directory too', async () = testRequest(undefined), REMOTE, NO_CANCELLATION, + createTestHostEnvironment(), ); const redirected = (prepared.flags as Record | undefined)?.artifactsDir; @@ -61,6 +64,7 @@ test('a remote daemon leaves an already-absolute --artifacts-dir as the download testRequest('/ci/artifacts/ad-test'), REMOTE, NO_CANCELLATION, + createTestHostEnvironment(), ); const redirected = (prepared.flags as Record | undefined)?.artifactsDir; @@ -73,6 +77,7 @@ test('a local daemon leaves --artifacts-dir untouched', async () => { testRequest('remote-device-artifacts/ad-test'), LOCAL, NO_CANCELLATION, + createTestHostEnvironment(), ); assert.equal( diff --git a/packages/remote/src/__tests__/upload-client-cancellation.test.ts b/packages/remote/src/__tests__/upload-client-cancellation.test.ts index 4c961d8b64..a2949e1ffb 100644 --- a/packages/remote/src/__tests__/upload-client-cancellation.test.ts +++ b/packages/remote/src/__tests__/upload-client-cancellation.test.ts @@ -62,6 +62,7 @@ test('an aborted signal ends a legacy upload mid-stream instead of finishing the await assert.rejects( async () => await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -92,6 +93,7 @@ test('an aborted signal before preflight refuses to ask the daemon for a ticket' await assert.rejects( async () => await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -129,6 +131,7 @@ test('an upload with no signal behaves exactly as before', async () => { try { const uploadId = await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, diff --git a/packages/remote/src/__tests__/upload-client.test.ts b/packages/remote/src/__tests__/upload-client.test.ts index 4d782b0739..0b57ce1fdb 100644 --- a/packages/remote/src/__tests__/upload-client.test.ts +++ b/packages/remote/src/__tests__/upload-client.test.ts @@ -4,7 +4,10 @@ import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs'; import http, { type IncomingMessage, type ServerResponse } from 'node:http'; import path from 'node:path'; -import { uploadArtifact } from '../upload-client.ts'; +import { uploadArtifact as uploadArtifactOnHost } from '../upload-client.ts'; + +const uploadArtifact = (options: Omit[0], 'env'>) => + uploadArtifactOnHost({ ...options, env: process.env }); import type { UploadProgressEvent } from '../upload-progress.ts'; import { runCmdSync, withCommandExecutorOverride } from '@agent-device/host-kit/command'; import { closeLoopbackServer, listenOnLoopback, mkdtempForTestSync } from '@agent-device/test-kit'; @@ -917,7 +920,12 @@ test.each([ const localPath = createTempFile('app.apk', `${flow}-payload`); for (const leaseId of ['lease-1', undefined]) { leases = new Map(); - await uploadArtifact({ localPath, baseUrl: server.baseUrl, token: TEST_TOKEN, leaseId }); + await uploadArtifact({ + localPath, + baseUrl: server.baseUrl, + token: TEST_TOKEN, + leaseId, + }); const expected = routes.map((route) => [route, route === unleased ? undefined : leaseId]); assert.deepEqual([...leases], expected); } diff --git a/packages/remote/src/__tests__/upload-progress.test.ts b/packages/remote/src/__tests__/upload-progress.test.ts index 91423c4342..adb6e7c7c5 100644 --- a/packages/remote/src/__tests__/upload-progress.test.ts +++ b/packages/remote/src/__tests__/upload-progress.test.ts @@ -15,7 +15,7 @@ function renderEvents(events: UploadProgressEvent[]): string { const isTTYDescriptor = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY'); Object.defineProperty(process.stderr, 'isTTY', { configurable: true, value: true }); try { - const sink = createStderrUploadProgressReporter(); + const sink = createStderrUploadProgressReporter(process.env); assert.equal(sink !== undefined, true); for (const event of events) sink!(event); } finally { diff --git a/packages/remote/src/daemon-artifacts.ts b/packages/remote/src/daemon-artifacts.ts index f0537ec3ed..5c80b18365 100644 --- a/packages/remote/src/daemon-artifacts.ts +++ b/packages/remote/src/daemon-artifacts.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; import type { DaemonArtifact, DaemonResponse } from '@agent-device/kernel/contracts'; import fs from 'node:fs'; @@ -38,13 +39,14 @@ export async function prepareRemoteRequestArtifacts( req: Omit, info: DaemonArtifactEndpoint, signal: AbortSignal, + host: HostEnvironment, ): Promise { const positionals = [...(req.positionals ?? [])]; let flags = req.flags ? { ...req.flags } : undefined; let installSource = req.meta?.installSource; const clientArtifactPaths: Record = {}; let uploadedArtifactId: string | undefined; - const uploadProgress = createStderrUploadProgressReporter(); + const uploadProgress = createStderrUploadProgressReporter(host.env); if (!isRemoteDaemon(info)) { return createPreparedRemoteRequest({ @@ -57,8 +59,14 @@ export async function prepareRemoteRequestArtifacts( } assertRemoteDaemonSupportsSaveScript(req); - flags = applyRemoteArtifactCommand(req, positionals, flags, clientArtifactPaths); - const remoteInstallSource = await prepareRemoteInstallSource(req, info, uploadProgress, signal); + flags = applyRemoteArtifactCommand(req, positionals, flags, clientArtifactPaths, host); + const remoteInstallSource = await prepareRemoteInstallSource( + req, + info, + uploadProgress, + signal, + host, + ); if (remoteInstallSource) { installSource = remoteInstallSource.installSource; uploadedArtifactId = remoteInstallSource.uploadedArtifactId ?? uploadedArtifactId; @@ -80,6 +88,7 @@ export async function prepareRemoteRequestArtifacts( positionals, uploadProgress, signal, + host, ); uploadedArtifactId = installPackageResult ?? uploadedArtifactId; return baseResult(); @@ -112,6 +121,7 @@ async function prepareRemoteInstallPackage( positionals: string[], onProgress: UploadProgressSink | undefined, signal: AbortSignal, + host: HostEnvironment, ): Promise { const pathIndex = positionals.length === 1 ? 0 : 1; const rawPath = positionals[pathIndex]; @@ -121,10 +131,11 @@ async function prepareRemoteInstallPackage( return undefined; } - const localPath = resolveLocalInstallPath(rawPath, req.meta?.cwd); + const localPath = resolveLocalInstallPath(rawPath, req.meta?.cwd, host); if (!localPath) return undefined; return await uploadArtifact({ + env: host.env, localPath, baseUrl: info.baseUrl!, token: info.token, @@ -140,8 +151,9 @@ function applyRemoteArtifactCommand( positionals: string[], flags: DaemonWireRequest['flags'] | undefined, clientArtifactPaths: Record, + host: HostEnvironment, ): DaemonWireRequest['flags'] | undefined { - const remoteArtifact = prepareRemoteArtifactCommand(req, positionals); + const remoteArtifact = prepareRemoteArtifactCommand(req, positionals, host); if (!remoteArtifact) return flags; if (remoteArtifact.positionalIndex !== undefined && remoteArtifact.positionalPath !== undefined) { positionals[remoteArtifact.positionalIndex] = remoteArtifact.positionalPath; @@ -164,10 +176,12 @@ function applyRemoteArtifactFlag( return { ...(flags ?? {}), [flagKey]: flagPath }; } -function resolveLocalInstallPath(rawPath: string, cwd: string | undefined): string | undefined { - const localPath = path.isAbsolute(rawPath) - ? rawPath - : path.resolve(cwd ?? process.cwd(), rawPath); +function resolveLocalInstallPath( + rawPath: string, + cwd: string | undefined, + host: HostEnvironment, +): string | undefined { + const localPath = path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd ?? host.cwd, rawPath); return fs.existsSync(localPath) ? localPath : undefined; } @@ -190,6 +204,7 @@ async function prepareRemoteInstallSource( info: DaemonArtifactEndpoint, onProgress: UploadProgressSink | undefined, signal: AbortSignal, + host: HostEnvironment, ): Promise<{ installSource: NonNullable['installSource']; uploadedArtifactId?: string; @@ -214,7 +229,7 @@ async function prepareRemoteInstallSource( const localPath = path.isAbsolute(rawPath) ? rawPath - : path.resolve(req.meta?.cwd ?? process.cwd(), rawPath); + : path.resolve(req.meta?.cwd ?? host.cwd, rawPath); if (!fs.existsSync(localPath)) { return { installSource: { @@ -225,6 +240,7 @@ async function prepareRemoteInstallSource( } const uploadedArtifactId = await uploadArtifact({ + env: host.env, localPath, baseUrl: info.baseUrl!, token: info.token, @@ -245,6 +261,7 @@ async function prepareRemoteInstallSource( function prepareRemoteArtifactCommand( req: Omit, positionals: string[], + host: HostEnvironment, ): { field: string; localPath: string; @@ -254,7 +271,7 @@ function prepareRemoteArtifactCommand( flagPath?: string; } | null { if (req.command === 'screenshot') { - const localPath = resolveClientArtifactOutputPath(req, 'path', '.png'); + const localPath = resolveClientArtifactOutputPath(req, 'path', '.png', host); if (positionals[0]) { return { field: 'path', @@ -276,12 +293,12 @@ function prepareRemoteArtifactCommand( // this destination and records to a temp path on its own host. return { field: 'outPath', - localPath: resolveAbsoluteClientPath(`recording-${Date.now()}`, req.meta?.cwd), + localPath: resolveAbsoluteClientPath(`recording-${Date.now()}`, req.meta?.cwd, host), }; } const fallbackExtension = recordingFallbackExtension(req); const localPath = normalizeRecordingClientArtifactPath( - resolveClientArtifactOutputPath(req, 'outPath', fallbackExtension, 1), + resolveClientArtifactOutputPath(req, 'outPath', fallbackExtension, host, 1), req, ); return { @@ -301,7 +318,7 @@ function prepareRemoteArtifactCommand( // the same command: the daemon must never resolve a path against the caller's `cwd`. return { field: 'artifactsDir', - localPath: resolveClientArtifactOutputRoot(req), + localPath: resolveClientArtifactOutputRoot(req, host), flagKey: 'artifactsDir', flagPath: buildRemoteTempArtifactDirPath('test-artifacts'), }; @@ -309,10 +326,13 @@ function prepareRemoteArtifactCommand( return null; } -function resolveClientArtifactOutputRoot(req: Omit): string { +function resolveClientArtifactOutputRoot( + req: Omit, + host: HostEnvironment, +): string { const requested = req.flags?.artifactsDir; const rawPath = hasNonEmptyString(requested) ? requested : DEFAULT_TEST_ARTIFACTS_ROOT; - return resolveAbsoluteClientPath(rawPath, req.meta?.cwd); + return resolveAbsoluteClientPath(rawPath, req.meta?.cwd, host); } function recordingFallbackExtension(req: Omit): string { @@ -335,6 +355,7 @@ function resolveClientArtifactOutputPath( req: Omit, field: 'path' | 'outPath', fallbackExtension: string, + host: HostEnvironment, positionalIndex: number = 0, ): string { const requested = req.positionals?.[positionalIndex] ?? req.flags?.out; @@ -342,12 +363,17 @@ function resolveClientArtifactOutputPath( return resolveAbsoluteClientPath( hasNonEmptyString(requested) ? requested : fallbackName, req.meta?.cwd, + host, ); } /** Shared tail of every "what local path did the caller mean" resolution in this file. */ -function resolveAbsoluteClientPath(rawPath: string, cwd: string | undefined): string { - return path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd ?? process.cwd(), rawPath); +function resolveAbsoluteClientPath( + rawPath: string, + cwd: string | undefined, + host: HostEnvironment, +): string { + return path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd ?? host.cwd, rawPath); } function hasNonEmptyString(value: unknown): value is string { @@ -358,6 +384,7 @@ export async function materializeRemoteArtifacts( info: DaemonArtifactEndpoint, req: DaemonWireRequest, response: Extract, + host: HostEnvironment, ): Promise { const artifacts = Array.isArray(response.data?.artifacts) ? response.data.artifacts : []; if (artifacts.length === 0 || !info.baseUrl) return response; @@ -368,7 +395,7 @@ export async function materializeRemoteArtifacts( nextArtifacts.push(artifact); continue; } - const downloadPath = resolveMaterializedArtifactPath(artifact, req); + const downloadPath = resolveMaterializedArtifactPath(artifact, req, host); const materializedPath = await downloadRemoteArtifact({ baseUrl: info.baseUrl, token: info.token, @@ -394,7 +421,11 @@ export async function materializeRemoteArtifacts( return { ok: true, data: nextData }; } -function resolveMaterializedArtifactPath(artifact: DaemonArtifact, req: DaemonWireRequest): string { +function resolveMaterializedArtifactPath( + artifact: DaemonArtifact, + req: DaemonWireRequest, + host: HostEnvironment, +): string { if (artifact.localPath && artifact.localPath.trim().length > 0) { return artifact.localPath; } @@ -403,7 +434,7 @@ function resolveMaterializedArtifactPath(artifact: DaemonArtifact, req: DaemonWi return requestedPath; } const fallbackName = artifact.fileName?.trim() || `${artifact.field}-${Date.now()}`; - return path.resolve(req.meta?.cwd ?? process.cwd(), fallbackName); + return path.resolve(req.meta?.cwd ?? host.cwd, fallbackName); } type DownloadRemoteArtifactParams = { diff --git a/packages/remote/src/remote-config-core.ts b/packages/remote/src/remote-config-core.ts index 063e8527eb..f5913ebb6c 100644 --- a/packages/remote/src/remote-config-core.ts +++ b/packages/remote/src/remote-config-core.ts @@ -5,12 +5,18 @@ import { getRemoteConfigEnvNames, getRemoteConfigFieldSpec, type RemoteConfigProfile, - type RemoteConfigProfileOptions, type ResolvedRemoteConfigProfile, } from './remote-config-schema.ts'; import { AppError } from '@agent-device/kernel/errors'; import { resolveUserPath } from '@agent-device/host-kit/file'; -import { parseSourceValue } from '@agent-device/kernel/source-value'; +import { parseSourceValue, type EnvMap } from '@agent-device/kernel/source-value'; + +/** The remote-config location and the host environment it resolves against. */ +export type HostRemoteConfigProfileOptions = { + configPath: string; + cwd: string; + env: EnvMap; +}; // Deliberately narrower than `resolveRemoteConfigProfile`: this reads only // what the config *file itself* declares, with no ambient environment @@ -19,9 +25,9 @@ import { parseSourceValue } from '@agent-device/kernel/source-value'; // somewhere") must use this, not the env-merged resolver, or provenance is // lost — an env var is global and cannot say which endpoint it belongs to. export function readRemoteConfigFile( - options: RemoteConfigProfileOptions, + options: HostRemoteConfigProfileOptions, ): ResolvedRemoteConfigProfile { - const env = options.env ?? process.env; + const { env } = options; const resolvedPath = resolveRemoteConfigPath(options); if (!fs.existsSync(resolvedPath)) { throw new AppError('INVALID_ARGS', `Remote config file not found: ${resolvedPath}`); @@ -78,9 +84,7 @@ export function readRemoteConfigFile( return { resolvedPath, profile }; } -function readRemoteConfigEnvDefaults( - env: Record = process.env, -): RemoteConfigProfile { +function readRemoteConfigEnvDefaults(env: EnvMap): RemoteConfigProfile { const profile: RemoteConfigProfile = {}; for (const spec of REMOTE_CONFIG_PROFILE_FIELD_SPECS) { const envMatch = getRemoteConfigEnvNames(spec.key) @@ -113,13 +117,13 @@ function mergeRemoteConfigProfile( return merged; } -export function resolveRemoteConfigPath(options: RemoteConfigProfileOptions): string { - const env = options.env ?? process.env; +export function resolveRemoteConfigPath(options: HostRemoteConfigProfileOptions): string { + const { env } = options; return resolveUserPath(options.configPath, { cwd: options.cwd, env }); } export function resolveRemoteConfigProfile( - options: RemoteConfigProfileOptions, + options: HostRemoteConfigProfileOptions, ): ResolvedRemoteConfigProfile { const loaded = readRemoteConfigFile(options); return { diff --git a/packages/remote/src/upload-client-artifact.ts b/packages/remote/src/upload-client-artifact.ts index 2cd6296cbd..d4de4a4f9c 100644 --- a/packages/remote/src/upload-client-artifact.ts +++ b/packages/remote/src/upload-client-artifact.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; @@ -24,6 +25,7 @@ export type PreparedUploadArtifact = { export async function prepareUploadArtifact( localPath: string, requestedPlatform: string | undefined, + env: Readonly, ): Promise { const stat = fs.statSync(localPath); const fileName = path.basename(localPath); @@ -33,7 +35,7 @@ export async function prepareUploadArtifact( const cleanupPaths: string[] = []; try { const payloadPath = isDirectory - ? await createGzipTarArchive(localPath, cleanupPaths) + ? await createGzipTarArchive(localPath, cleanupPaths, env) : localPath; const payloadStat = fs.statSync(payloadPath); @@ -53,7 +55,11 @@ export async function prepareUploadArtifact( } } -async function createGzipTarArchive(localPath: string, cleanupPaths: string[]): Promise { +async function createGzipTarArchive( + localPath: string, + cleanupPaths: string[], + env: Readonly, +): Promise { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `agent-device-upload-${randomUUID()}-`)); cleanupPaths.push(tempDir); const archivePath = path.join(tempDir, `${path.basename(localPath)}.tar.gz`); @@ -62,7 +68,7 @@ async function createGzipTarArchive(localPath: string, cleanupPaths: string[]): ['czf', archivePath, '-C', path.dirname(localPath), path.basename(localPath)], { env: { - ...process.env, + ...env, COPYFILE_DISABLE: '1', }, }, diff --git a/packages/remote/src/upload-client.ts b/packages/remote/src/upload-client.ts index d503c4aa07..bc6526c704 100644 --- a/packages/remote/src/upload-client.ts +++ b/packages/remote/src/upload-client.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import { randomUUID } from 'node:crypto'; import { AppError } from '@agent-device/kernel/errors'; import { @@ -16,6 +17,8 @@ type UploadArtifactOptions = { baseUrl: string; token: string; platform?: string; + /** The environment a packaging command (`tar`) starts from. */ + env: Readonly; /** The lease this upload is for; every request to the daemon names it in a header. */ leaseId?: string; onProgress?: UploadProgressSink; @@ -54,7 +57,7 @@ type UploadPreflightResult = }; export async function uploadArtifact(options: UploadArtifactOptions): Promise { - const prepared = await prepareUploadArtifact(options.localPath, options.platform); + const prepared = await prepareUploadArtifact(options.localPath, options.platform, options.env); const normalizedBase = options.baseUrl.endsWith('/') ? options.baseUrl : `${options.baseUrl}/`; const uploadAttemptId = randomUUID(); diff --git a/packages/remote/src/upload-progress.ts b/packages/remote/src/upload-progress.ts index bc5560a9d1..8f537d5263 100644 --- a/packages/remote/src/upload-progress.ts +++ b/packages/remote/src/upload-progress.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import { Transform } from 'node:stream'; const DEFAULT_UPLOAD_PROGRESS_STEP_BYTES = 8 * 1024 * 1024; @@ -58,8 +59,10 @@ export function createUploadProgressTransform(options: { }); } -export function createStderrUploadProgressReporter(): UploadProgressSink | undefined { - if (process.stderr.isTTY !== true || process.env.CI) return undefined; +export function createStderrUploadProgressReporter( + env: Readonly, +): UploadProgressSink | undefined { + if (process.stderr.isTTY !== true || env.CI) return undefined; return (event) => { process.stderr.write(`${formatUploadProgressEvent(event)}\n`); }; 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 3d4979b719..a5d46daba9 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 @@ -180,7 +180,7 @@ function preflightSaveScriptTarget(params: { if (!saveScript) return undefined; const targetPath = typeof saveScript === 'string' - ? expandSessionPath(saveScript) + ? expandSessionPath(saveScript, process.cwd()) : (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 eb0e563c62..d9179221dc 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 @@ -92,7 +92,10 @@ async function resolveReplayTestShardDevices( function buildReplayTestShardInventoryRequest( flags: CommandFlags | undefined, ): DeviceInventoryRequest { - const androidSerialAllowlist = resolveAndroidSerialAllowlist(flags?.androidDeviceAllowlist); + const androidSerialAllowlist = resolveAndroidSerialAllowlist( + flags?.androidDeviceAllowlist, + process.env, + ); return { platform: flags?.platform, target: flags?.target, @@ -100,6 +103,7 @@ function buildReplayTestShardInventoryRequest( requestedSetPath: flags?.iosSimulatorDeviceSet, platform: flags?.platform, target: flags?.target, + env: process.env, }), androidSerialAllowlist: androidSerialAllowlist ? Array.from(androidSerialAllowlist).sort() diff --git a/packages/replay-port/src/daemon-port/test-command.ts b/packages/replay-port/src/daemon-port/test-command.ts index 40e607f688..09a23cdc28 100644 --- a/packages/replay-port/src/daemon-port/test-command.ts +++ b/packages/replay-port/src/daemon-port/test-command.ts @@ -403,7 +403,10 @@ function toReplayTestSuiteRequest( cwd, requestId: req.meta?.requestId, platformFilter: flags.platform, - artifactsDir: artifactsDir === undefined ? undefined : expandSessionPath(artifactsDir, cwd), + artifactsDir: + artifactsDir === undefined + ? undefined + : expandSessionPath(artifactsDir, cwd ?? process.cwd()), failFast: flags.failFast === true, retries: numberFlag(flags.retries), timeoutMs: numberFlag(flags.timeoutMs), diff --git a/packages/test-kit/src/device-claim-fixtures.ts b/packages/test-kit/src/device-claim-fixtures.ts index 7922437f28..4f6e7d9da5 100644 --- a/packages/test-kit/src/device-claim-fixtures.ts +++ b/packages/test-kit/src/device-claim-fixtures.ts @@ -9,8 +9,9 @@ import path from 'node:path'; */ export function deviceClaimFilePath(deviceKey: string): string { const override = process.env.AGENT_DEVICE_CLAIMS_DIR?.trim(); - const root = override - ? path.resolve(override) - : path.join(os.homedir(), '.agent-device', 'device-claims'); + if (override && !path.isAbsolute(override)) { + throw new Error(`AGENT_DEVICE_CLAIMS_DIR must be an absolute path: ${override}`); + } + const root = override || path.join(os.homedir(), '.agent-device', 'device-claims'); return path.join(root, `${crypto.createHash('sha256').update(deviceKey).digest('hex')}.json`); } diff --git a/packages/test-kit/src/host-environment.ts b/packages/test-kit/src/host-environment.ts new file mode 100644 index 0000000000..10655324a9 --- /dev/null +++ b/packages/test-kit/src/host-environment.ts @@ -0,0 +1,33 @@ +import { aroundEach } from 'vitest'; +import { withCommandHost } from '@agent-device/host-kit/command'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; +import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; + +/** + * A host for tests that boot daemon code in-process. It snapshots the process environment and + * working directory unless a test names its own, so a suite never shares a mutable reference to + * `process.env` with the runtime under test. + */ +export function createTestHostEnvironment( + options: { env?: EnvMap; cwd?: string } = {}, +): HostEnvironment { + return captureHostEnvironment({ + env: options.env ?? process.env, + cwd: () => options.cwd ?? process.cwd(), + }); +} + +/** + * Runs every test of the calling suite as a command host whose environment is the live process + * environment, for suites that reach code which reads `commandHostEnvironment()` without going + * through a daemon request or the CLI entry, which open their own host. Call it once at the top of + * the suite. + */ +export function bindTestCommandHost(): void { + aroundEach(async (runTest) => { + await withCommandHost( + { host: { env: process.env, cwd: process.cwd() }, requestEnv: undefined }, + async () => await runTest(), + ); + }); +} diff --git a/packages/test-kit/src/index.ts b/packages/test-kit/src/index.ts index 70c2167301..78dabdb0d2 100644 --- a/packages/test-kit/src/index.ts +++ b/packages/test-kit/src/index.ts @@ -36,6 +36,7 @@ export { dispatchDisclosureRowsOwnedBy, } from './dispatch-disclosure.fixtures.ts'; export { restoreEnv } from './env.ts'; +export { bindTestCommandHost, createTestHostEnvironment } from './host-environment.ts'; export { eagerClosureGraphOf, eagerClosureOf, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03af95c3da..0cf558c119 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -535,6 +535,10 @@ importers: '@agent-device/provision-kit': specifier: workspace:* version: link:../provision-kit + devDependencies: + '@agent-device/test-kit': + specifier: workspace:* + version: link:../test-kit packages/platform-linux: dependencies: @@ -550,6 +554,10 @@ importers: '@agent-device/kernel': specifier: workspace:* version: link:../kernel + devDependencies: + '@agent-device/test-kit': + specifier: workspace:* + version: link:../test-kit packages/platform-vega: dependencies: diff --git a/scripts/clean-daemon.ts b/scripts/clean-daemon.ts index ce3c39ce00..7343aafedf 100644 --- a/scripts/clean-daemon.ts +++ b/scripts/clean-daemon.ts @@ -14,7 +14,10 @@ const DAEMON_KILL_TIMEOUT_MS = 2_000; const PRUNE_DEV_FLAG = '--prune-dev'; const PRUNE_DEV_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000; -const paths = resolveDaemonPaths(process.env.AGENT_DEVICE_STATE_DIR); +const paths = resolveDaemonPaths(process.env.AGENT_DEVICE_STATE_DIR, { + env: process.env, + cwd: process.cwd(), +}); const retirement = await stopAndRetireDaemon({ paths, observed: readRegisteredDaemonIdentity(paths.infoPath), @@ -48,7 +51,7 @@ async function pruneStaleDevStateDirs(): Promise { const cutoffMs = Date.now() - PRUNE_DEV_MAX_AGE_MS; for (const dirPath of listDevStateDirs(devRoot)) { if (newestMtimeMs(dirPath) > cutoffMs) continue; - const paths = resolveDaemonPaths(dirPath); + const paths = resolveDaemonPaths(dirPath, { cwd: process.cwd() }); const result = await recoverAbandonedDaemonRegistration({ paths, observed: readRegisteredDaemonIdentity(paths.infoPath), diff --git a/scripts/print-daemon-state-dir.ts b/scripts/print-daemon-state-dir.ts index a7dcc3260f..4ac24fd1dd 100644 --- a/scripts/print-daemon-state-dir.ts +++ b/scripts/print-daemon-state-dir.ts @@ -1,5 +1,8 @@ import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -const paths = resolveDaemonPaths(process.env.AGENT_DEVICE_STATE_DIR); +const paths = resolveDaemonPaths(process.env.AGENT_DEVICE_STATE_DIR, { + env: process.env, + cwd: process.cwd(), +}); process.stdout.write(`${paths.baseDir}\n`); diff --git a/src/__tests__/cli-agent-cdp.test.ts b/src/__tests__/cli-agent-cdp.test.ts index d9a5f6d952..b7c320a5c6 100644 --- a/src/__tests__/cli-agent-cdp.test.ts +++ b/src/__tests__/cli-agent-cdp.test.ts @@ -159,6 +159,8 @@ test('cdp passes injected remote target url to npm exec', async () => { }); const exitCode = await runAgentCdpCommand(['target', 'list'], { + cwd: process.cwd(), + env: process.env, flags: { leaseBackend: 'ios-instance', metroProxyBaseUrl: 'https://bridge.example.test', diff --git a/src/__tests__/cli-capture.ts b/src/__tests__/cli-capture.ts index 437bcac5eb..ad7c1c8c13 100644 --- a/src/__tests__/cli-capture.ts +++ b/src/__tests__/cli-capture.ts @@ -6,7 +6,10 @@ import type { sendToDaemon, } from '../daemon-client/daemon-client.ts'; import { installIsolatedCliTestEnv } from './cli-test-env.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; + +// Every suite that imports this runs its tests as a command host. +bindTestCommandHost(); import { beginStderrCapture, beginStdoutCapture } from './test-utils/stream-capture.ts'; class ExitSignal extends Error { diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index f65ae9f46c..4192c655c2 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import { PNG } from '@agent-device/capture-kit/png'; -import { tryRunClientBackedCommand } from '../cli/commands/router.ts'; +import { tryRunClientBackedCommand } from './test-utils/host-bound-commands.ts'; import { runCliCapture } from './cli-capture.ts'; import type { AgentDeviceClient, diff --git a/src/__tests__/cli-install-from-source.test.ts b/src/__tests__/cli-install-from-source.test.ts index bc103e7649..f592030db8 100644 --- a/src/__tests__/cli-install-from-source.test.ts +++ b/src/__tests__/cli-install-from-source.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; @@ -15,6 +16,7 @@ async function runInstallFromSource(source: string) { }, } as unknown as AgentDeviceClient; return await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'install-from-source', positionals: [source], flags: { json: false, help: false, version: false }, diff --git a/src/__tests__/cli-run-transport.test.ts b/src/__tests__/cli-run-transport.test.ts new file mode 100644 index 0000000000..cfbd1d17d2 --- /dev/null +++ b/src/__tests__/cli-run-transport.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { + hashRemoteConfigFile, + writeRemoteConnectionState, +} from '@agent-device/remote/remote-connection-state'; +import { runCli } from '../cli.ts'; +import type { DaemonRequest, DaemonResponse } from '../daemon-client/daemon-client.ts'; +import { makeTempWorkspace } from './cli-config-fixtures.ts'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +class ProcessExit extends Error {} + +test('a CLI run builds one transport, shared by the remote-materialization client and the command client', async () => { + const { root, home, project } = makeTempWorkspace(); + const stateDir = path.join(root, 'state'); + const remoteConfig = path.join(project, 'remote.json'); + fs.writeFileSync(remoteConfig, JSON.stringify({}), 'utf8'); + const now = new Date().toISOString(); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'transport-run', + remoteConfigPath: remoteConfig, + remoteConfigHash: hashRemoteConfigFile(remoteConfig), + tenant: 'tenant', + runId: 'run-transport', + leaseBackend: 'ios-instance', + leaseProvider: 'limrun', + platform: 'ios', + connectedAt: now, + updatedAt: now, + }, + }); + const commands: string[] = []; + const transportForHost = vi.fn(() => async (req: Omit) => { + commands.push(req.command); + return ( + req.command === 'lease_allocate' + ? { + ok: true, + data: { + lease: { + leaseId: 'lease-transport', + tenantId: 'tenant', + runId: 'run-transport', + backend: 'ios-instance', + }, + }, + } + : { ok: true, data: {} } + ) satisfies DaemonResponse; + }); + const host = { env: { HOME: home, PATH: process.env.PATH }, cwd: project }; + const exitCodes: Array = []; + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + exitCodes.push(code); + throw new ProcessExit(); + }) as never); + vi.spyOn(process.stdout, 'write').mockReturnValue(true); + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + try { + // A successful run returns without exiting; a failure that exits or throws fails the test. + await runCli( + ['open', '--state-dir', stateDir, '--json'], + { sendToDaemon: vi.fn(), transportForHost }, + host, + ).catch((error: unknown) => { + if (!(error instanceof ProcessExit)) throw error; + }); + + assert.deepEqual(exitCodes, [], 'the run completed without exiting'); + assert.ok(commands.includes('lease_allocate'), `materialization ran: ${commands.join(',')}`); + assert.ok(commands.length > 1, 'the command client sent through the same transport'); + assert.equal(transportForHost.mock.calls.length, 1); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/__tests__/cloud-connect-auth.test.ts b/src/__tests__/cloud-connect-auth.test.ts index cfe18aef88..c5472fd85e 100644 --- a/src/__tests__/cloud-connect-auth.test.ts +++ b/src/__tests__/cloud-connect-auth.test.ts @@ -7,7 +7,13 @@ import { beginStdoutCapture } from './test-utils/stream-capture.ts'; import { resolveCloudAccessForConnect } from '../cli/auth-session.ts'; import { readActiveConnectionState } from '@agent-device/remote/remote-connection-state'; import type { AgentDeviceClient } from '../agent-device-client.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; + +bindTestCommandHost(); afterEach(() => { vi.unstubAllGlobals(); @@ -52,7 +58,6 @@ test('cloud access with no-login reports auth requirement after reuse options ar }, env: {}, io: { - env: {}, fetch: vi.fn(), }, }), @@ -85,6 +90,7 @@ async function connectWithNoLogin(stateDir: string): Promise { const stdoutCapture = beginStdoutCapture(); try { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index 2b0a7c580f..e10b3312f9 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -20,9 +20,15 @@ import { verifyAwsDeviceFarmConnection, verifyBrowserStackConnection, } from '@agent-device/provider-webdriver/connection-verification'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { connectWithGeneratedProviderProfile } from './test-utils/connect-command.ts'; +bindTestCommandHost(); + vi.mock('../cli/auth-session.ts', async (importOriginal) => ({ ...(await importOriginal()), resolveCloudAccessForConnect: vi.fn(), @@ -161,6 +167,7 @@ test('connect limrun generates a local daemon remote profile', async () => { try { await captureConnectStdout(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: ['limrun'], flags: { json: true, @@ -214,6 +221,7 @@ test('connect limrun attaches to an existing instance without storing its token' await captureConnectStdout(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: ['limrun'], flags: { json: true, help: false, version: false, stateDir, platform: 'ios' }, client: {} as AgentDeviceClient, @@ -234,6 +242,7 @@ test('connect limrun persists deferred Metro bridge settings', async () => { try { await captureConnectStdout(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: ['limrun'], flags: { json: true, @@ -292,6 +301,7 @@ test('connect limrun requires LIMRUN_API_KEY or access to an existing instance', try { await assert.rejects( connectCommand({ + host: createTestHostEnvironment(), positionals: ['limrun'], flags: { json: true, @@ -550,22 +560,18 @@ test('connect browserstack persists a local app artifact as an absolute path', a vi.stubEnv('BROWSERSTACK_ACCESS_KEY', 'browser-key'); try { - const previousCwd = process.cwd(); - process.chdir(tempRoot); - try { - await connectWithGeneratedProviderProfile({ - stateDir, - positionals: ['browserstack'], - flags: { - platform: 'android', - device: 'Google Pixel 8', - providerOsVersion: '14.0', - providerApp: './sample.apk', - }, - }); - } finally { - process.chdir(previousCwd); - } + // The host the command runs in decides what `./sample.apk` is relative to. + await connectWithGeneratedProviderProfile({ + stateDir, + host: { env: process.env, cwd: fs.realpathSync(tempRoot) }, + positionals: ['browserstack'], + flags: { + platform: 'android', + device: 'Google Pixel 8', + providerOsVersion: '14.0', + providerApp: './sample.apk', + }, + }); const state = readRequiredActiveState(stateDir); assert.equal(readGeneratedConfig(state.remoteConfigPath).providerApp, fs.realpathSync(appPath)); @@ -793,6 +799,7 @@ test('connect does not activate provider state when verification fails', async ( try { await assert.rejects( connectCommand({ + host: createTestHostEnvironment(), positionals: ['limrun'], flags: { json: true, @@ -901,6 +908,7 @@ function fetchProfileUrl(fetchMock: ReturnType): string | undefine async function connectWithGeneratedCloudProfile(stateDir: string): Promise { await captureConnectStdout(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, diff --git a/src/__tests__/cloud-connect-testmu.test.ts b/src/__tests__/cloud-connect-testmu.test.ts index 8d08192c07..637fa11933 100644 --- a/src/__tests__/cloud-connect-testmu.test.ts +++ b/src/__tests__/cloud-connect-testmu.test.ts @@ -12,11 +12,18 @@ import { verifyTestMuConnection } from '@agent-device/testmu/connection-verifica import testmu from '@agent-device/testmu'; import { resolveProviderPlugins } from '@agent-device/plugins/registry'; import { resolveConnectProviderProfile } from '../cli/connection/connect-provider-adapters.ts'; -import { mkdtempForTestSync, pluginHome, selectPlugin } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + pluginHome, + selectPlugin, + bindTestCommandHost, +} from '@agent-device/test-kit'; import manifest from '@agent-device/testmu/package.json' with { type: 'json' }; import type { PluginConnectionCallbacks } from '@agent-device/plugins/connection'; import { connectWithGeneratedProviderProfile } from './test-utils/connect-command.ts'; +bindTestCommandHost(); + vi.mock('@agent-device/testmu/connection-verification', () => ({ verifyTestMuConnection: vi.fn(), })); diff --git a/src/__tests__/daemon-composition/platform-services-fixture.ts b/src/__tests__/daemon-composition/platform-services-fixture.ts index f88ebf7653..7a3a4fc51d 100644 --- a/src/__tests__/daemon-composition/platform-services-fixture.ts +++ b/src/__tests__/daemon-composition/platform-services-fixture.ts @@ -1,6 +1,6 @@ import { type ClaimRecoveryGatewayFactory } from '@agent-device/daemon-contracts/device-claim-recovery'; import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts'; -import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts'; +import { createClaimRecoveryGatewayFor } from '../../platform-runtime-claim-recovery.ts'; import type { DaemonPlatformServices } from '@agent-device/daemon'; /** @@ -22,5 +22,5 @@ export function composedPlatformServicesFixture( * passes its own factory. */ export function composedClaimRecoveryGatewayFixture(): ClaimRecoveryGatewayFactory { - return createClaimRecoveryGateway; + return createClaimRecoveryGatewayFor(process.env); } diff --git a/src/__tests__/daemon-launch-cwd.test.ts b/src/__tests__/daemon-launch-cwd.test.ts new file mode 100644 index 0000000000..5106f197b6 --- /dev/null +++ b/src/__tests__/daemon-launch-cwd.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { launchDaemonProcess } from '@agent-device/daemon-contracts/daemon-registration-owner'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; + +test('a launched daemon starts in the working directory of the host that launched it', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-launch-cwd-')); + const hostCwd = fs.realpathSync(mkdtempForTestSync('agent-device-launch-host-cwd-')); + const reported = path.join(paths.baseDir, 'cwd.txt'); + assert.notEqual(hostCwd, fs.realpathSync(process.cwd())); + const launch = launchDaemonProcess({ + env: process.env, + cwd: hostCwd, + paths, + args: ['-e', `require('node:fs').writeFileSync(${JSON.stringify(reported)}, process.cwd())`], + serverMode: 'socket', + }); + + await launch.exited; + + assert.equal(fs.readFileSync(reported, 'utf8'), hostCwd); +}); diff --git a/src/__tests__/daemon-registration-owner.fixtures.ts b/src/__tests__/daemon-registration-owner.fixtures.ts index 4148af46bd..9866e87ced 100644 --- a/src/__tests__/daemon-registration-owner.fixtures.ts +++ b/src/__tests__/daemon-registration-owner.fixtures.ts @@ -5,6 +5,9 @@ import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; import { readProcessStartTime } from '@agent-device/host-kit/process'; import { stopDaemonProcess } from '@agent-device/daemon-contracts/daemon-process'; import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { launchDaemonProcess as launchOnHost } from '@agent-device/daemon-contracts/daemon-registration-owner'; +import { createTestHostEnvironment } from '@agent-device/test-kit'; +import { resolveClientSettings } from '../daemon-client/daemon-client-lifecycle.ts'; // c237027737: server-lifecycle.ts readLockInfo/acquireDaemonLock/releaseDaemonLock. const legacyLockProtocol = ` @@ -100,3 +103,18 @@ setInterval(() => {}, 1000); }, }; } + +/** Launches a daemon from the live process host, the one these tests run as. */ +export function launchDaemonProcess( + input: Omit[0], 'env' | 'cwd'>, +): ReturnType { + return launchOnHost({ ...input, env: process.env, cwd: process.cwd() }); +} + +export function devicesClientSettings(stateDir: string) { + return resolveClientSettings( + { session: 'default', command: 'devices', positionals: [], flags: { stateDir } }, + undefined, + createTestHostEnvironment(), + ); +} diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index da720dd78c..8f86229aa3 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -10,7 +10,6 @@ import { recoverAbandonedDaemonRegistration, createOwnedReplayStateDir, DAEMON_STARTUP_EXIT_CODES, - launchDaemonProcess, type DaemonRetirementResult, type OwnedReplayStateDir, } from '@agent-device/daemon-contracts/daemon-registration-owner'; @@ -21,16 +20,17 @@ import { import { readRegisteredDaemonOwnership } from '@agent-device/daemon-contracts/daemon-registration'; import { readDaemonShutdownReport } from '@agent-device/daemon-contracts/daemon-shutdown-report'; import { mkdtempForTestSync } from '@agent-device/test-kit'; -import { spawnLegacyDaemonFixture } from './daemon-registration-owner.fixtures.ts'; +import { + devicesClientSettings, + launchDaemonProcess, + spawnLegacyDaemonFixture, +} from './daemon-registration-owner.fixtures.ts'; import { registeredDaemonFixtureArgs, spawnRegisteredDaemonFixture, finishRegisteredDaemonFixture, } from './test-utils/registered-daemon-fixture.ts'; -import { - ensureManagedDaemon, - resolveClientSettings, -} from '../daemon-client/daemon-client-lifecycle.ts'; +import { ensureManagedDaemon } from '../daemon-client/daemon-client-lifecycle.ts'; import { inspectProcessLock } from '@agent-device/host-kit/file'; import { AppError } from '@agent-device/kernel/errors'; import { sleep } from '@agent-device/host-kit/retry'; @@ -794,14 +794,7 @@ test('cutover refuses an already-running legacy daemon before signaling or chang assert.ok(disposition, 'a new daemon must refuse an occupied legacy file'); assert.equal(disposition.exitCode, DAEMON_STARTUP_EXIT_CODES.unproven); await assert.rejects( - ensureManagedDaemon( - resolveClientSettings({ - session: 'default', - command: 'devices', - positionals: [], - flags: { stateDir: paths.baseDir }, - }), - ), + ensureManagedDaemon(devicesClientSettings(paths.baseDir)), (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.details?.reason, 'daemon_registration_unproven'); diff --git a/src/__tests__/platform-runtime-claim-recovery.test.ts b/src/__tests__/platform-runtime-claim-recovery.test.ts index 09d47bb668..37bee72acd 100644 --- a/src/__tests__/platform-runtime-claim-recovery.test.ts +++ b/src/__tests__/platform-runtime-claim-recovery.test.ts @@ -283,7 +283,7 @@ test('recovery is composed per claim from the stale owner state dir, never the c process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; const composedFor: string[] = []; - const outcomes = await runStaleDeviceClaimRelease({}, (stateDir) => { + const outcomes = await runStaleDeviceClaimRelease({}, process.env, (stateDir) => { composedFor.push(stateDir); return { reconcile: async (claim) => { diff --git a/src/__tests__/remote-connection-harmonyos.test.ts b/src/__tests__/remote-connection-harmonyos.test.ts index 776593a379..6e2f955a71 100644 --- a/src/__tests__/remote-connection-harmonyos.test.ts +++ b/src/__tests__/remote-connection-harmonyos.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { expect, test } from 'vitest'; import fs from 'node:fs'; import { @@ -13,6 +14,8 @@ import { import { disconnectCommand } from '../cli/commands/connection.ts'; import { readRemoteConnectionState } from '@agent-device/remote/remote-connection-state'; +bindTestCommandHost(); + test('HarmonyOS platform resolves to its proxy lease backend', () => { expect( resolveRequestedLeaseBackend( @@ -42,6 +45,7 @@ test('stored Harmony runtime compatibility keeps the HarmonyOS runtime for a Har }, }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', client: createTestClient(), flags: { @@ -70,6 +74,7 @@ async function expectRejectedSelection( ): Promise { await expect( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', client: createTestClient(), flags: { @@ -173,6 +178,7 @@ test('proxy HarmonyOS inventory materializes a scoped lease and closes that same session: 'harmony-proxy', }; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags, client, @@ -186,7 +192,7 @@ test('proxy HarmonyOS inventory materializes a scoped lease and closes that same deviceKey: 'harmonyos:mobile:127.0.0.1:16001', }); expect(readRemoteConnectionState({ stateDir, session: flags.session })?.leaseId).toBe(leaseId); - await disconnectCommand({ positionals: [], flags, client }); + await disconnectCommand({ host: createTestHostEnvironment(), positionals: [], flags, client }); expect(released).toEqual([leaseId]); expect(readRemoteConnectionState({ stateDir, session: flags.session })).toBeNull(); }); diff --git a/src/__tests__/remote-connection-macos-app.test.ts b/src/__tests__/remote-connection-macos-app.test.ts index 994f615e48..ecf9582a97 100644 --- a/src/__tests__/remote-connection-macos-app.test.ts +++ b/src/__tests__/remote-connection-macos-app.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { expect, test } from 'vitest'; import fs from 'node:fs'; import { AppError } from '@agent-device/kernel/errors'; @@ -8,6 +9,8 @@ import { materializeRemoteConnectionForCommand } from '../cli/commands/connectio import { beginStdoutCapture } from './test-utils/stream-capture.ts'; import { readRemoteConnectionState } from '@agent-device/remote/remote-connection-state'; +bindTestCommandHost(); + const LEASE_ID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; function writeHostIssuedConfig(remoteConfigPath: string): void { @@ -65,6 +68,7 @@ test('a macos-app remote config opens on the lease the host allocated, unchanged }); const flags = flagsFor(stateDir, remoteConfigPath); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', positionals: ['com.example.app'], flags, @@ -107,6 +111,7 @@ test('an inactive host lease is reported, never replaced by a tenant allocation' }); await expect( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: flagsFor(stateDir, remoteConfigPath), client, @@ -123,6 +128,7 @@ async function connectHostedMacosApp( const stdoutCapture = beginStdoutCapture(); try { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { ...flagsFor(workspace.stateDir, workspace.remoteConfigPath), @@ -162,6 +168,7 @@ test('connect binds the lease and device a macos-app remote config names, and op }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', positionals: ['com.example.app'], flags: { ...flagsFor(workspace.stateDir, workspace.remoteConfigPath), platform: 'macos' }, @@ -242,6 +249,7 @@ test('disconnect drops the local connection and never releases the host lease', const stdoutCapture = beginStdoutCapture(); try { await disconnectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: flagsFor(workspace.stateDir, workspace.remoteConfigPath), client: createTestClient({ diff --git a/src/__tests__/remote-connection-platform-axis.test.ts b/src/__tests__/remote-connection-platform-axis.test.ts index d90973b63f..92793830ce 100644 --- a/src/__tests__/remote-connection-platform-axis.test.ts +++ b/src/__tests__/remote-connection-platform-axis.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; // The platform axis a remote connection is named on (#2962). // // A `DeviceInfo` carries the INTERNAL `apple` platform while everything a connection records or @@ -24,6 +25,8 @@ import { materializeRemoteConnectionForCommand } from '../cli/commands/connectio import { AppError } from '@agent-device/kernel/errors'; import { readRemoteConnectionState } from '@agent-device/remote/remote-connection-state'; +bindTestCommandHost(); + afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); @@ -51,6 +54,7 @@ test('proxy install against an iOS-bound connection is not refused as a platform const allocate = recordedLeaseAllocate({ leaseId: 'ios-lease-1', backend: 'ios-instance' }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { json: true, @@ -111,6 +115,7 @@ test('proxy install with the apple family selector matches an ios-bound connecti const allocate = recordedLeaseAllocate({ leaseId: 'ios-lease-1', backend: 'ios-instance' }); await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { json: true, @@ -170,6 +175,7 @@ test('remote command with the apple family selector matches an ios-bound connect const heartbeats: string[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: { json: true, @@ -225,6 +231,7 @@ test('proxy install against a differently-bound platform is still refused', asyn await assert.rejects( async () => await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { json: true, @@ -288,6 +295,7 @@ test('proxy install records the public platform and the next command reuses that }); const install = () => materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { json: true, @@ -373,6 +381,7 @@ test('a connection opened on the apple family collapses to the bound device leaf }); const install = (platform: 'apple' | 'ios' | 'macos') => materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { json: true, @@ -435,6 +444,7 @@ test('a default-policy connection collapses its recorded apple alias when the le const allocate = recordedLeaseAllocate({ leaseId: 'default-lease-1', backend: 'ios-instance' }); const command = (platform: 'apple' | 'ios' | 'macos') => materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: { json: true, @@ -498,6 +508,7 @@ test('a stored apple record refuses the other leaf even though nothing rewrote i const heartbeats: string[] = []; const command = (platform: 'macos') => materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: { json: true, @@ -559,6 +570,7 @@ test('connect refuses to reuse an apple-bound connection for the other leaf', as }); const connect = (platform: 'apple' | 'ios' | 'macos') => connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -622,6 +634,7 @@ test('connect keeps the bound leaf when re-declared with the family selector', a }); await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -671,6 +684,7 @@ test('an undecided record narrows to the requested leaf or refuses, and never se }); allocations.push(allocation); return materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: { json: true, @@ -755,6 +769,7 @@ test('connect --force refuses a backend and platform that name different devices await assert.rejects( async () => await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, diff --git a/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts b/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts index b2f213ee4b..f5cc8f1cf9 100644 --- a/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts +++ b/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -21,6 +22,8 @@ vi.mock('@agent-device/react-native', async (importOriginal) => ({ import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; import { readRemoteConnectionState } from '@agent-device/remote/remote-connection-state'; +bindTestCommandHost(); + const IPHONE_SIMULATOR = { platform: 'ios', target: 'mobile', @@ -96,6 +99,7 @@ test('proxy open without an exact device allocates by its selectors and keeps th const requests: LeaseAllocateRequest[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, device: 'iPhone 17', target: 'mobile', iosSimulatorDeviceSet: '/sims' }, client: createTestClient({ @@ -149,6 +153,7 @@ test('a later open on the live lease uses the device its key names instead of pi const heartbeats: string[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, device: 'iPhone 17' }, // Another simulator with the same name must not replace the leased one. @@ -169,6 +174,7 @@ test('a later open naming another device on the live lease is refused instead of await assert.rejects( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, device: 'iPhone 16' }, client: renewingClient([], [LEASED_SIMULATOR, { ...IPHONE_SIMULATOR, name: 'iPhone 16' }]), @@ -210,6 +216,7 @@ test('a later open scoped to a simulator set that excludes the leased device is await assert.rejects( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, iosSimulatorDeviceSet: '/other-sims' }, client, @@ -233,6 +240,7 @@ test('a daemon whose lease names no device gets that lease released and the devi const releases: LeaseReleaseRequest[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, device: 'iPhone 17' }, client: createTestClient({ @@ -267,6 +275,7 @@ test('a renewed lease that names no device is refused and left to its owner', as await assert.rejects( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, device: 'iPhone 17' }, client: createTestClient({ @@ -299,6 +308,7 @@ test('proxy open with an exact udid still picks locally and allocates that devic const requests: LeaseAllocateRequest[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, udid: 'SIM-LOCAL' }, client: createTestClient({ diff --git a/src/__tests__/remote-connection-proxy-lease-id.test.ts b/src/__tests__/remote-connection-proxy-lease-id.test.ts index 41c32f561a..6cfff6fc47 100644 --- a/src/__tests__/remote-connection-proxy-lease-id.test.ts +++ b/src/__tests__/remote-connection-proxy-lease-id.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -17,6 +18,8 @@ vi.mock('@agent-device/react-native', async (importOriginal) => ({ import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; import { readRemoteConnectionState } from '@agent-device/remote/remote-connection-state'; +bindTestCommandHost(); + const DAEMON_LEASE_ID = 'abc123abc123abc1'; const LEASED_DEVICE = { @@ -79,11 +82,13 @@ test('proxy allocation sends a fresh lease id on every attempt and keeps the id } as const; const first = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { ...flags }, client, }); const second = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { ...flags }, client, @@ -121,6 +126,7 @@ test('a non-proxy remote connection leaves the lease id to the daemon', async () const requests: LeaseAllocateRequest[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'screenshot', flags: { json: true, diff --git a/src/__tests__/remote-connection.fixtures.ts b/src/__tests__/remote-connection.fixtures.ts index d0784c996a..c0545c531c 100644 --- a/src/__tests__/remote-connection.fixtures.ts +++ b/src/__tests__/remote-connection.fixtures.ts @@ -7,7 +7,10 @@ import { writeRemoteConnectionState, type RemoteConnectionState, } from '@agent-device/remote/remote-connection-state'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; + +// Every suite that imports this runs its tests as a command host. +bindTestCommandHost(); export const unexpectedCommandCall = async (): Promise => { throw new Error('unexpected call'); diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 16b5b5b438..d903cda234 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -25,11 +25,11 @@ import { connectCommand, connectionCommand, disconnectCommand, -} from '../cli/commands/connection.ts'; + materializeRemoteConnectionForCommand, +} from './test-utils/host-bound-commands.ts'; import { writeGeneratedRemoteConfig } from '../cli/connection/generated-config.ts'; import { hasDeferredMetroConfig, - materializeRemoteConnectionForCommand, CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS, PROXY_REMOTE_LEASE_TTL_MS, } from '../cli/commands/connection-runtime.ts'; diff --git a/src/__tests__/takeover-command.test.ts b/src/__tests__/takeover-command.test.ts index 75f53163ae..fe728dfb88 100644 --- a/src/__tests__/takeover-command.test.ts +++ b/src/__tests__/takeover-command.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { afterEach, beforeEach, test, vi } from 'vitest'; import { createAgentDeviceClient } from '../agent-device-client.ts'; @@ -52,8 +53,14 @@ test('takeover output explains the hold and release gesture', () => { test('takeover status and release use the configured lease client', async () => { const { client, transport } = clientForTest(); - await takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals: ['status'] }); await takeoverCommand({ + host: createTestHostEnvironment(), + client, + flags: TAKEOVER_CLI_FLAGS, + positionals: ['status'], + }); + await takeoverCommand({ + host: createTestHostEnvironment(), client, flags: TAKEOVER_CLI_FLAGS, positionals: ['release', 'operator-1'], @@ -72,9 +79,17 @@ test('takeover status and release use the configured lease client', async () => test('takeover rejects malformed actions without contacting the daemon', async () => { const { client, transport } = clientForTest(); for (const positionals of [['release'], ['status', 'extra'], ['unknown']]) { - await assert.rejects(takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals }), { - code: 'INVALID_ARGS', - }); + await assert.rejects( + takeoverCommand({ + host: createTestHostEnvironment(), + client, + flags: TAKEOVER_CLI_FLAGS, + positionals, + }), + { + code: 'INVALID_ARGS', + }, + ); } assert.equal(transport.mock.calls.length, 0); }); @@ -84,7 +99,12 @@ test('foreground takeover renews its admitted lease hold and releases it on Ctrl const { client, transport } = clientForTest(); const started = createControlLatch(); mocks.writeCommandOutput.mockImplementationOnce(() => started.resolve()); - const pending = takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals: [] }); + const pending = takeoverCommand({ + host: createTestHostEnvironment(), + client, + flags: TAKEOVER_CLI_FLAGS, + positionals: [], + }); await started.promise; await vi.advanceTimersByTimeAsync(5_000); process.emit('SIGINT'); @@ -110,7 +130,12 @@ test('a failed heartbeat stops foreground takeover and attempts release', async const { client, transport } = clientForTest(); const started = createControlLatch(); mocks.writeCommandOutput.mockImplementationOnce(() => started.resolve()); - const pending = takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals: [] }); + const pending = takeoverCommand({ + host: createTestHostEnvironment(), + client, + flags: TAKEOVER_CLI_FLAGS, + positionals: [], + }); const rejected = assert.rejects(pending, /heartbeat failed/); await started.promise; transport.mockRejectedValueOnce(new Error('heartbeat failed')); diff --git a/src/__tests__/test-utils/connect-command.ts b/src/__tests__/test-utils/connect-command.ts index 5550ee9916..9ac6fb5866 100644 --- a/src/__tests__/test-utils/connect-command.ts +++ b/src/__tests__/test-utils/connect-command.ts @@ -1,3 +1,5 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { connectCommand } from '../../cli/commands/connection.ts'; import type { AgentDeviceClient } from '../../agent-device-client.ts'; import { beginStdoutCapture } from './stream-capture.ts'; @@ -5,12 +7,14 @@ import { beginStdoutCapture } from './stream-capture.ts'; /** Runs `connect` the way the CLI does, writing its generated profile under `stateDir`. */ export async function connectWithGeneratedProviderProfile(options: { stateDir: string; + host?: HostEnvironment; positionals: string[]; flags: Partial[0]['flags']>; }): Promise { const stdoutCapture = beginStdoutCapture(); try { await connectCommand({ + host: options.host ?? createTestHostEnvironment(), positionals: options.positionals, flags: { json: true, diff --git a/src/__tests__/test-utils/host-bound-commands.ts b/src/__tests__/test-utils/host-bound-commands.ts new file mode 100644 index 0000000000..0dbdd655c7 --- /dev/null +++ b/src/__tests__/test-utils/host-bound-commands.ts @@ -0,0 +1,17 @@ +import { + connectCommand as connectCommandOnHost, + connectionCommand as connectionCommandOnHost, + disconnectCommand as disconnectCommandOnHost, +} from '../../cli/commands/connection.ts'; +import { materializeRemoteConnectionForCommand as materializeRemoteConnectionForCommandOnHost } from '../../cli/commands/connection-runtime.ts'; +import { tryRunClientBackedCommand as tryRunClientBackedCommandOnHost } from '../../cli/commands/router.ts'; +import { withTestHost } from './with-test-host.ts'; + +/** The CLI commands that take a run's host, bound to a fresh test host. */ +export const connectCommand = withTestHost(connectCommandOnHost); +export const disconnectCommand = withTestHost(disconnectCommandOnHost); +export const connectionCommand = withTestHost(connectionCommandOnHost); +export const materializeRemoteConnectionForCommand = withTestHost( + materializeRemoteConnectionForCommandOnHost, +); +export const tryRunClientBackedCommand = withTestHost(tryRunClientBackedCommandOnHost); diff --git a/src/__tests__/test-utils/public-daemon-runtime.ts b/src/__tests__/test-utils/public-daemon-runtime.ts index d00fca1a05..ad8c87897c 100644 --- a/src/__tests__/test-utils/public-daemon-runtime.ts +++ b/src/__tests__/test-utils/public-daemon-runtime.ts @@ -8,7 +8,7 @@ import type { AgentDeviceRuntimeConfig } from '@agent-device/contracts/runtime-c import { AppError } from '@agent-device/kernel/errors'; import { startDaemonRuntime, type DaemonRuntimeController } from '@agent-device/daemon'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import fs from 'node:fs'; import { createDaemonRuntimeDeps } from '../../daemon-composition.ts'; import { currentDaemonCodeSignature } from './daemon-http-fixture.ts'; @@ -43,13 +43,15 @@ export async function startPublicDaemonRuntime( } : deps; }, - env: { - ...process.env, - ...options.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: options.serverMode ?? 'dual', - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + ...options.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: options.serverMode ?? 'dual', + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, diff --git a/src/__tests__/test-utils/with-test-host.ts b/src/__tests__/test-utils/with-test-host.ts new file mode 100644 index 0000000000..d0fe3d0967 --- /dev/null +++ b/src/__tests__/test-utils/with-test-host.ts @@ -0,0 +1,9 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; + +/** Binds a command that takes a `host` to a fresh test host, so a call site names only its inputs. */ +export function withTestHost

( + command: (params: P) => R, +): (params: Omit) => R { + return (params) => command({ host: createTestHostEnvironment(), ...params } as P); +} diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 4bc838f0a0..07f0635f74 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -59,7 +59,7 @@ import { type DaemonCommandName, } from './commands/command-projection.ts'; import type { CommandResult } from '@agent-device/command-registry/command-result'; -import { sendToDaemon } from './daemon-client/daemon-client.ts'; +import { createDaemonTransport } from './daemon-client/daemon-client.ts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { prepareMetroRuntime, @@ -76,15 +76,18 @@ import { } from '@agent-device/kernel/record'; import { readSnapshotViewportSize } from '@agent-device/kernel/rect'; import { readResponseWarnings } from '@agent-device/kernel/success-text'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { createLeaseClient } from './client/lease-client.ts'; import { normalizeScreenshotCaptureResult } from './client/screenshot-result.ts'; import { createRequestGuard } from './daemon-client/daemon-client-transport.ts'; export function createAgentDeviceClient( config: AgentDeviceClientConfig = {}, - deps: { transport?: AgentDeviceDaemonTransport } = {}, + deps: { transport?: AgentDeviceDaemonTransport; host?: HostEnvironment } = {}, ): AgentDeviceClient { - const transport = deps.transport ?? sendToDaemon; + const host = deps.host ?? captureHostEnvironment(process); + const transport = deps.transport ?? createDaemonTransport(host); // A typo in a JSON or JS config must not fall through to the default and manage a daemon. readDaemonLifecycle(config.daemonLifecycle); @@ -216,7 +219,10 @@ export function createAgentDeviceClient( // Pure local resolution; mirrors how the daemon client picks its state dir. stateDir: async (options = {}) => { const merged = mergeClientOptions(config, options); - return resolveDaemonPaths(merged.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR).baseDir; + return resolveDaemonPaths(merged.stateDir ?? host.env.AGENT_DEVICE_STATE_DIR, { + env: host.env, + cwd: host.cwd, + }).baseDir; }, close: async (options = {}) => { const session = resolveRequestSession(options); diff --git a/src/bin.ts b/src/bin.ts index 1763cfff55..c42815e437 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,13 +1,29 @@ +import { withCommandHost } from '@agent-device/host-kit/command'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { runEntry } from './cli/entry.ts'; declare const __AGENT_DEVICE_VERSION__: string; +const host = captureHostEnvironment(process); + runEntry( process.argv.slice(2), { help: () => import('./commands/schema/cli-help.ts'), - cli: () => import('./cli/process-entry.ts'), - mcp: () => import('./mcp/server.ts'), + cli: async () => { + const { runCliProcess } = await import('./cli/process-entry.ts'); + return { runCliProcess: (argv) => runCliProcess(argv, host) }; + }, + mcp: async () => { + const { runAgentDeviceMcpServer } = await import('./mcp/server.ts'); + return { + runAgentDeviceMcpServer: async () => + await withCommandHost( + { host, requestEnv: undefined }, + async () => await runAgentDeviceMcpServer(), + ), + }; + }, version: () => import('@agent-device/host-kit/version'), processExit: () => import('./cli/process-exit.ts'), }, diff --git a/src/cli.ts b/src/cli.ts index 9a50fbf6fe..aaecb8c0b5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,10 +8,17 @@ import { type NormalizedError, } from '@agent-device/kernel/errors'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { withCommandHost } from '@agent-device/host-kit/command'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { exitAfterFlush } from './cli/process-exit.ts'; import { readVersion } from '@agent-device/host-kit/version'; import { pathToFileURL } from 'node:url'; -import { createEnsuredDaemonTransport, sendToDaemon } from './daemon-client/daemon-client.ts'; +import { + createEnsuredDaemonTransport, + resolveRunTransport, + sendToDaemon, +} from './daemon-client/daemon-client.ts'; import fs from 'node:fs'; import type { BatchStep } from '@agent-device/contracts/client'; import type { ReplayTestReporterRuntime } from './cli/replay-test/reporting.ts'; @@ -51,6 +58,8 @@ import { isKnownCliCommandName } from '@agent-device/command-registry/catalog'; type CliDeps = { sendToDaemon: typeof sendToDaemon; + /** Builds the transport that talks as a given host; injected deps leave it out. */ + transportForHost?: (host: HostEnvironment) => typeof sendToDaemon; }; type CliDaemonTransport = typeof sendToDaemon; @@ -71,6 +80,8 @@ const printJson = ( const DEFAULT_CLI_DEPS: CliDeps = { sendToDaemon: createEnsuredDaemonTransport({ onReplaced: writeDaemonReplacedNotice }), + transportForHost: (host) => + createEnsuredDaemonTransport({ host, onReplaced: writeDaemonReplacedNotice }), }; function writeDaemonReplacedNotice(replaced: { @@ -121,14 +132,28 @@ const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([ 'takeover', ]); -export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): Promise { +export async function runCli( + argv: string[], + deps: CliDeps = DEFAULT_CLI_DEPS, + host: HostEnvironment = captureHostEnvironment(process), +): Promise { + await withCommandHost( + { host, requestEnv: undefined }, + async () => await runCliOnHost(argv, deps, host), + ); +} + +async function runCliOnHost(argv: string[], deps: CliDeps, host: HostEnvironment): Promise { + // One transport per run: its ensure-once state is shared by the materialization client and the + // command client, so a run ensures the daemon once. + const transport = resolveRunTransport(deps, host); const requestId = createRequestId(); const version = readVersion(); const debugEnabled = isDebugRequested(argv); const jsonRequested = argv.includes('--json'); // Best-effort session guess used only for pre-parse diagnostics scope. // After parse succeeds, request dispatch uses parsed flags/session resolution. - const sessionGuess = guessSessionFromArgv(argv) ?? process.env.AGENT_DEVICE_SESSION ?? 'default'; + const sessionGuess = guessSessionFromArgv(argv) ?? host.env.AGENT_DEVICE_SESSION ?? 'default'; await withDiagnosticsScope( { @@ -139,12 +164,14 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): }, async () => { const { parsed, command, positionals } = await parseCliInputOrExit(argv, { + host, version, jsonRequested, debugEnabled, }); const debugOutputEnabled = isParsedDebugRequested(command, parsed.providedFlags); const ctx = await resolveRunContextOrExit(parsed, { + host, command, positionals, requestId, @@ -173,15 +200,15 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): stateDir: ctx.daemonPaths.baseDir, flags: ctx.effectiveFlags, }); - await resolveRemoteContext(ctx, deps); + await resolveRemoteContext(ctx, transport); registerDaemonAuthDiagnosticValue(ctx.effectiveFlags); if (command === 'cdp') { await exitAfterFlush( await runAgentCdpCommand(positionals, { flags: ctx.effectiveFlags, runtime: ctx.resolvedRuntime, - cwd: process.cwd(), - env: process.env, + cwd: host.cwd, + env: host.env, }), ); return; @@ -190,12 +217,14 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): const replayTestReporterRuntime = await createReplayReporterForTest(ctx); const commandProgress = createCommandProgressState(); const client = createAgentDeviceClient(buildClientConfig(ctx), { + host: ctx.host, transport: createCliDaemonTransport({ + host, command, flags: ctx.effectiveFlags, replayTestReporterRuntime, commandProgress, - transport: deps.sendToDaemon, + transport, }), }); await dispatchCliCommand(ctx, client, replayTestReporterRuntime, commandProgress); @@ -220,11 +249,16 @@ type ParsedCliInput = { async function parseCliInputOrExit( argv: string[], - options: { version: string; jsonRequested: boolean; debugEnabled: boolean }, + options: { + host: HostEnvironment; + version: string; + jsonRequested: boolean; + debugEnabled: boolean; + }, ): Promise { let parsed: ReturnType; try { - parsed = resolveCliOptions(argv, { cwd: process.cwd(), env: process.env }); + parsed = resolveCliOptions(argv, { cwd: options.host.cwd, env: options.host.env }); } catch (error) { emitDiagnostic({ level: 'error', @@ -285,6 +319,7 @@ async function parseCliInputOrExit( } type CliRunContext = { + host: HostEnvironment; command: string; positionals: string[]; requestId: string; @@ -307,11 +342,18 @@ type CliRunContext = { async function resolveRunContextOrExit( parsed: ReturnType, - base: { command: string; positionals: string[]; requestId: string; debugOutputEnabled: boolean }, + base: { + host: HostEnvironment; + command: string; + positionals: string[]; + requestId: string; + debugOutputEnabled: boolean; + }, ): Promise { const explicitFlagKeys = new Set(parsed.providedFlags.map((entry) => entry.key)); try { const binding = resolveBindingSettings({ + env: base.host.env, policyOverrides: parsed.flags, configuredPlatform: parsed.flags.platform, configuredSession: parsed.flags.session, @@ -319,13 +361,18 @@ async function resolveRunContextOrExit( const flags = binding.lockPolicy ? { ...parsed.flags } : applyDefaultPlatformBinding(parsed.flags, { + env: base.host.env, policyOverrides: parsed.flags, configuredPlatform: parsed.flags.platform, configuredSession: parsed.flags.session, }); - const daemonPaths = resolveDaemonPaths(flags.stateDir); + const daemonPaths = resolveDaemonPaths(flags.stateDir, { + env: base.host.env, + cwd: base.host.cwd, + }); const sessionName = flags.session ?? 'default'; const connectionDefaults = resolveActiveConnectionDefaults({ + host: base.host, command: base.command, explicitFlagKeys, stateDir: daemonPaths.baseDir, @@ -373,7 +420,10 @@ async function runReactDevtoolsCli(ctx: CliRunContext): Promise { }); } -async function resolveRemoteContext(ctx: CliRunContext, deps: CliDeps): Promise { +async function resolveRemoteContext( + ctx: CliRunContext, + transport: CliDaemonTransport, +): Promise { if (ctx.command === 'batch') { if (ctx.positionals.length > 0) { throw new AppError('INVALID_ARGS', 'batch does not accept positional arguments.'); @@ -386,18 +436,20 @@ async function resolveRemoteContext(ctx: CliRunContext, deps: CliDeps): Promise< command: ctx.command, flags: ctx.effectiveFlags, stateDir: ctx.daemonPaths.baseDir, - env: process.env, + env: ctx.host.env, }); ctx.effectiveFlags = authResolution.flags; } if (ctx.effectiveFlags.remoteConfig && shouldMaterializeRemoteConnection(ctx.command)) { const materializationClient = createAgentDeviceClient(buildClientConfig(ctx), { - transport: createClientDaemonTransport(deps.sendToDaemon), + host: ctx.host, + transport: createClientDaemonTransport(transport), }); const { materializeRemoteConnectionForCommand } = await import('./cli/commands/connection-runtime.ts'); const materialized = await materializeRemoteConnectionForCommand({ + host: ctx.host, command: ctx.command, flags: ctx.effectiveFlags, client: materializationClient, @@ -467,7 +519,7 @@ function buildClientConfig(ctx: CliRunContext): AgentDeviceClientConfig { runtime: ctx.resolvedRuntime, lockPolicy: ctx.binding.lockPolicy, lockPlatform: ctx.binding.defaultPlatform, - cwd: process.cwd(), + cwd: ctx.host.cwd, debug: ctx.debugOutputEnabled, cost: currentFlags.cost, responseLevel: currentFlags.responseLevel, @@ -514,6 +566,7 @@ async function dispatchCliCommand( ctx.binding.lockPolicy && ctx.flags.platform === undefined ? { ...step.input } : applyDefaultPlatformBinding(step.input, { + env: ctx.host.env, policyOverrides: effectiveFlags, configuredPlatform: effectiveFlags.platform, configuredSession: effectiveFlags.session, @@ -522,6 +575,7 @@ async function dispatchCliCommand( })); if ( await tryRunClientBackedCommand({ + host: ctx.host, command, positionals, flags: { ...effectiveFlags, batchSteps }, @@ -540,6 +594,7 @@ async function dispatchCliCommand( ); } else if ( await tryRunClientBackedCommand({ + host: ctx.host, command, positionals, flags: effectiveFlags, @@ -700,6 +755,7 @@ function isDaemonStartupFailure(error: AppError): boolean { } function resolveActiveConnectionDefaults(options: { + host: HostEnvironment; command: string; explicitFlagKeys: Set; stateDir: string; @@ -724,8 +780,8 @@ function resolveActiveConnectionDefaults(options: { stateDir: options.stateDir, session: options.session, remoteConfig: options.remoteConfig, - cwd: process.cwd(), - env: process.env, + cwd: options.host.cwd, + env: options.host.env, allowActiveFallback: !options.explicitFlagKeys.has('session') && (!options.remoteConfig || options.command === 'disconnect' || !options.hasResolvedSession), @@ -800,6 +856,7 @@ function hasExplicitMetroRuntimeOverrides(explicitFlagKeys: Set): boole } type CliDaemonTransportSetup = { + host: HostEnvironment; command: string; flags: CliFlags; replayTestReporterRuntime?: ReplayTestReporterRuntime; @@ -817,7 +874,7 @@ function createCliDaemonTransport(options: CliDaemonTransportSetup): AgentDevice return async (req, context) => { const response = await send(req, context); const { forgetEndedProxyLease } = await import('./cli/connection/ended-lease.ts'); - forgetEndedProxyLease(options.flags, response); + forgetEndedProxyLease(options.flags, response, options.host); return response; }; } diff --git a/src/cli/auth-session.ts b/src/cli/auth-session.ts index cda05045d8..3c227cde73 100644 --- a/src/cli/auth-session.ts +++ b/src/cli/auth-session.ts @@ -65,7 +65,6 @@ type CliSessionRefreshResponse = { }; type AuthIo = { - env?: EnvMap; stdinIsTTY?: boolean; stdoutIsTTY?: boolean; stderr?: Pick; @@ -80,7 +79,7 @@ export async function resolveRemoteAuthForCli(options: { command: string; flags: CliFlags; stateDir: string; - env?: EnvMap; + env: EnvMap; }): Promise { return await resolveRemoteAuth({ command: options.command, @@ -96,10 +95,10 @@ export async function resolveRemoteAuth(options: { flags: CliFlags; stateDir: string; allowInteractiveLogin: boolean; - env?: EnvMap; + env: EnvMap; io?: AuthIo; }): Promise { - const env = options.env ?? options.io?.env ?? process.env; + const { env } = options; if (!options.flags.daemonBaseUrl) return { flags: options.flags, source: 'none' }; if (hasToken(options.flags.daemonAuthToken)) return { flags: options.flags, source: 'flag' }; if (hasToken(env.AGENT_DEVICE_DAEMON_AUTH_TOKEN)) { @@ -146,13 +145,13 @@ export async function resolveRemoteAuth(options: { export async function resolveCloudAccessForConnect(options: { stateDir: string; flags: CliFlags; - env?: EnvMap; + env: EnvMap; io?: AuthIo; }): Promise<{ accessToken: string; cloudBaseUrl: string; }> { - const env = options.env ?? options.io?.env ?? process.env; + const { env } = options; if (hasToken(options.flags.daemonAuthToken)) { return { accessToken: options.flags.daemonAuthToken, @@ -198,7 +197,7 @@ export async function resolveCloudAccessForConnect(options: { export async function loginWithDeviceAuth(options: { stateDir: string; flags: CliFlags; - env?: EnvMap; + env: EnvMap; io?: AuthIo; commandLabel?: string; }): Promise<{ @@ -206,7 +205,7 @@ export async function loginWithDeviceAuth(options: { expiresAt?: string; session: CliSessionRecord; }> { - const env = options.env ?? options.io?.env ?? process.env; + const { env } = options; const authMode = detectAuthMode(env, options.io); if (authMode === 'non-interactive') { throw buildNonInteractiveLoginError(options.commandLabel ?? 'agent-device connect', env); diff --git a/src/cli/commands/__tests__/daemon.test.ts b/src/cli/commands/__tests__/daemon.test.ts index 3c1e1d2f48..580714bf22 100644 --- a/src/cli/commands/__tests__/daemon.test.ts +++ b/src/cli/commands/__tests__/daemon.test.ts @@ -2,7 +2,11 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { afterEach, expect, test, vi } from 'vitest'; import type { DaemonStopResult } from '@agent-device/daemon-contracts/daemon-stop'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; const mocks = vi.hoisted(() => ({ cleanupDaemonOwner: vi.fn(async () => undefined), @@ -34,6 +38,8 @@ vi.mock('../shared.ts', () => ({ writeCommandOutput: mocks.writeCommandOutput }) import { daemonCommand } from '../daemon.ts'; +bindTestCommandHost(); + const GRACEFUL_RESULT: DaemonStopResult = { stopped: true, mode: 'graceful', @@ -54,6 +60,7 @@ test('accepts only daemon stop and status', async () => { await assert.rejects( async () => await daemonCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { help: false, json: false, version: false }, client: {} as never, @@ -63,6 +70,7 @@ test('accepts only daemon stop and status', async () => { await assert.rejects( async () => await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['stop', 'extra'], flags: { help: false, json: false, version: false }, client: {} as never, @@ -91,6 +99,7 @@ test('merges a graceful shutdown report and cleans runner leases with the start- try { await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['stop'], flags: { clean: true, help: false, json: false, stateDir, version: false }, client: {} as never, @@ -130,6 +139,7 @@ test('reports graceful provider cleanup as unknown when the shutdown report is u try { await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['stop'], flags: { help: false, json: false, stateDir, version: false }, client: {} as never, @@ -167,6 +177,7 @@ test('warns in text output when a graceful stop leaves an orphaned claim', async try { await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['stop'], flags: { clean: false, help: false, json: false, stateDir, version: false }, client: {} as never, @@ -204,6 +215,7 @@ test('routes an unattributable claim to the default status view, never to --stal try { await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['stop'], flags: { clean: false, help: false, json: false, stateDir, version: false }, client: {} as never, @@ -258,6 +270,7 @@ test('daemon status reads the daemon record without stopping or contacting the d try { await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['status'], flags: { help: false, json: true, stateDir, version: false }, client: {} as never, diff --git a/src/cli/commands/__tests__/generic.test.ts b/src/cli/commands/__tests__/generic.test.ts index 846b552391..d32e33716a 100644 --- a/src/cli/commands/__tests__/generic.test.ts +++ b/src/cli/commands/__tests__/generic.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import { withCapturedStdoutAsync } from '../../../__tests__/test-utils/stream-capture.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { createAgentDeviceClient } from '../../../agent-device-client.ts'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { CliFlags } from '@agent-device/contracts/command'; @@ -25,6 +25,7 @@ test('snapshot --level digest --json preserves the digest through the generic CL const out = await withCapturedStdoutAsync(() => runGenericClientBackedCommand({ + host: createTestHostEnvironment(), command: 'snapshot' as ClientBackedCliCommandName, positionals: [], flags, @@ -66,6 +67,7 @@ test('replay human output keeps the composable warnings channel (#2560)', async const out = await withCapturedStdoutAsync(() => runGenericClientBackedCommand({ + host: createTestHostEnvironment(), command: 'replay' as ClientBackedCliCommandName, positionals: [scriptPath], flags: {} as CliFlags, @@ -100,6 +102,7 @@ test('the generic CLI path prints the response warnings', async () => { const out = await withCapturedStdoutAsync(() => runGenericClientBackedCommand({ + host: createTestHostEnvironment(), command: 'type' as ClientBackedCliCommandName, positionals: ['hi'], flags: {} as CliFlags, diff --git a/src/cli/commands/__tests__/proxy.test.ts b/src/cli/commands/__tests__/proxy.test.ts index 8c206fc623..1957a7164a 100644 --- a/src/cli/commands/__tests__/proxy.test.ts +++ b/src/cli/commands/__tests__/proxy.test.ts @@ -10,6 +10,8 @@ import { listenOnLoopback, mkdtempForTestSync, skipWhenLoopbackUnavailable, + bindTestCommandHost, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; @@ -46,6 +48,8 @@ vi.mock('../shared.ts', () => ({ writeCommandOutput: mocks.writeCommandOutput }) import { proxyCommand, renderProxyStartup } from '../proxy.ts'; import type { AgentDeviceClient } from '../../../agent-device-client.ts'; +bindTestCommandHost(); + // The proxy command never reads the client; a typed stub that throws on any access proves it // without the `{} as never` blind cast. const unreferencedClient = new Proxy({} as AgentDeviceClient, { @@ -67,6 +71,7 @@ function runProxy(flags: Partial[0]['flags']> = // The command streams its startup card and then serves until interrupted, so the handler // promise intentionally never settles; the test observes the emitted output and lets it float. void proxyCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, help: false, version: false, ...flags }, client: unreferencedClient, @@ -90,6 +95,7 @@ async function waitForStartupOutput(): Promise<{ test('proxy rejects positional arguments', async () => { await assert.rejects( proxyCommand({ + host: createTestHostEnvironment(), positionals: ['extra'], flags: { json: true, help: false, version: false }, client: unreferencedClient, @@ -107,6 +113,7 @@ test('proxy refuses to start when the local daemon has no HTTP endpoint', async await assert.rejects( proxyCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, help: false, version: false }, client: unreferencedClient, diff --git a/src/cli/commands/__tests__/recording.test.ts b/src/cli/commands/__tests__/recording.test.ts index 72963b7106..ad7c028465 100644 --- a/src/cli/commands/__tests__/recording.test.ts +++ b/src/cli/commands/__tests__/recording.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { describe, expect, test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import type { ClientCommandParams } from '../router-types.ts'; @@ -5,6 +6,7 @@ import { recordingCommand } from '../recording.ts'; function params(positionals: string[]): ClientCommandParams { return { + host: createTestHostEnvironment(), positionals, flags: {} as ClientCommandParams['flags'], client: {} as ClientCommandParams['client'], diff --git a/src/cli/commands/__tests__/screenshot.test.ts b/src/cli/commands/__tests__/screenshot.test.ts index 1828612407..e108e2945c 100644 --- a/src/cli/commands/__tests__/screenshot.test.ts +++ b/src/cli/commands/__tests__/screenshot.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import { withCapturedStdoutAsync } from '../../../__tests__/test-utils/stream-capture.ts'; @@ -32,7 +33,7 @@ test('screenshot --level digest --json preserves the digest payload through the const flags = { json: true, responseLevel: 'digest' } as CliFlags; const out = await withCapturedStdoutAsync(() => - screenshotCommand({ positionals: [], flags, client }), + screenshotCommand({ host: createTestHostEnvironment(), positionals: [], flags, client }), ); const parsed = JSON.parse(out) as { success: boolean; data: Record }; @@ -50,7 +51,7 @@ test('screenshot surfaces response-level warnings under the summary', async () = const flags = { json: false } as CliFlags; const out = await withCapturedStdoutAsync(() => - screenshotCommand({ positionals: [], flags, client }), + screenshotCommand({ host: createTestHostEnvironment(), positionals: [], flags, client }), ); assert.equal(out, `/tmp/shot.png (40x20)\n${CROP_WARNING}\n`); @@ -62,7 +63,7 @@ test('screenshot --json carries response-level warnings in the data', async () = const flags = { json: true } as CliFlags; const out = await withCapturedStdoutAsync(() => - screenshotCommand({ positionals: [], flags, client }), + screenshotCommand({ host: createTestHostEnvironment(), positionals: [], flags, client }), ); const parsed = JSON.parse(out) as { data: { path: string; warnings?: string[] } }; @@ -84,7 +85,7 @@ test('screenshot --json at the default level still emits normalized screenshot m const flags = { json: true } as CliFlags; const out = await withCapturedStdoutAsync(() => - screenshotCommand({ positionals: [], flags, client }), + screenshotCommand({ host: createTestHostEnvironment(), positionals: [], flags, client }), ); const parsed = JSON.parse(out) as { data: { path: string; width?: number; pixelDensity?: number }; diff --git a/src/cli/commands/agent-cdp.ts b/src/cli/commands/agent-cdp.ts index 092d8c2cbc..fb783bcad3 100644 --- a/src/cli/commands/agent-cdp.ts +++ b/src/cli/commands/agent-cdp.ts @@ -20,8 +20,8 @@ type AgentCdpCommandOptions = { | 'tenant' >; runtime?: SessionRuntimeHints; - cwd?: string; - env?: NodeJS.ProcessEnv; + cwd: string; + env: NodeJS.ProcessEnv; }; export function buildAgentCdpNpmExecArgs(args: string[]): string[] { @@ -80,12 +80,12 @@ export function buildAgentCdpPassthroughArgs( export async function runAgentCdpCommand( args: string[], - options: AgentCdpCommandOptions = {}, + options: AgentCdpCommandOptions, ): Promise { const passthroughArgs = buildAgentCdpPassthroughArgs(args, options); const result = await runCmdStreaming('npm', buildAgentCdpNpmExecArgs(passthroughArgs), { - cwd: options.cwd ?? process.cwd(), - env: options.env ?? process.env, + cwd: options.cwd, + env: options.env, allowFailure: true, onStdoutChunk: (chunk) => { process.stdout.write(chunk); diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index ca41919870..797f127335 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -4,9 +4,12 @@ import { loginWithDeviceAuth, removeCliSession, summarizeCliSession } from '../a import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; -export const authCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const authCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { const subcommand = positionals[0] ?? 'status'; - const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const stateDir = resolveDaemonPaths(flags.stateDir, { + cwd: host.cwd, + env: host.env, + }).baseDir; if (subcommand === 'status') { const status = summarizeCliSession({ stateDir }); await writeCommandOutput(flags, status, () => renderAuthStatus(status)); @@ -17,6 +20,7 @@ export const authCommand: ClientCommandHandler = async ({ positionals, flags }) stateDir, flags, commandLabel: 'agent-device auth login', + env: host.env, }); const data = { authenticated: true, diff --git a/src/cli/commands/connection-runtime-metro-teardown.test.ts b/src/cli/commands/connection-runtime-metro-teardown.test.ts index cd2a366b7b..5c68b32452 100644 --- a/src/cli/commands/connection-runtime-metro-teardown.test.ts +++ b/src/cli/commands/connection-runtime-metro-teardown.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -17,6 +18,8 @@ vi.mock('@agent-device/react-native', async (importOriginal) => ({ import { stopMetroTunnel } from '@agent-device/react-native'; import { materializeRemoteConnectionForCommand } from './connection-runtime.ts'; +bindTestCommandHost(); + afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); @@ -57,6 +60,7 @@ test('deferred materialization stops the new Metro companion if state persistenc await assert.rejects( async () => await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { json: true, diff --git a/src/cli/commands/connection-runtime-plugin-capabilities.test.ts b/src/cli/commands/connection-runtime-plugin-capabilities.test.ts index 9eca35ee0c..74e0092545 100644 --- a/src/cli/commands/connection-runtime-plugin-capabilities.test.ts +++ b/src/cli/commands/connection-runtime-plugin-capabilities.test.ts @@ -1,6 +1,11 @@ import { expect, test, vi } from 'vitest'; import fs from 'node:fs'; -import { pluginHome, selectV2Plugin, v2RegistrationSource } from '@agent-device/test-kit'; +import { + pluginHome, + selectV2Plugin, + v2RegistrationSource, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { connectionWorkspace, createTestClient, @@ -43,6 +48,7 @@ test('a stored profile for a v2 plugin provider needs no remote daemon URL', asy // refused for lacking a daemonBaseUrl. await expect( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', client: createTestClient(), flags: { diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index d14935d03f..28a8a36e32 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import crypto from 'node:crypto'; import type { MetroBridgeScope } from '@agent-device/contracts/remote'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; @@ -60,6 +61,7 @@ export const PROXY_REMOTE_LEASE_TTL_MS = 5 * 60 * 1000; export const CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS = 10 * 60 * 1000; export async function materializeRemoteConnectionForCommand(options: { + host: HostEnvironment; command: string; flags: CliFlags; client: AgentDeviceClient; @@ -72,16 +74,19 @@ export async function materializeRemoteConnectionForCommand(options: { runtime?: SessionRuntimeHints; connection?: RemoteConnectionRequestMetadata; }> { - const { command, flags, client } = options; + const { command, flags, client, host } = options; if (!flags.remoteConfig) { return { flags, runtime: options.runtime }; } - const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const stateDir = resolveDaemonPaths(flags.stateDir, { + cwd: host.cwd, + env: host.env, + }).baseDir; const remoteConfig = resolveRemoteConfigProfile({ configPath: flags.remoteConfig, - cwd: process.cwd(), - env: process.env, + cwd: host.cwd, + env: host.env, }); const profileFlags = profileToCliFlags(remoteConfig.profile); const mergedFlags = { diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index dad3790de9..0f50ded8e0 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { CloudArtifact, CloudProviderSessionResult, @@ -53,14 +54,28 @@ import { type RuntimePreparationNotice, } from './connection-presentation.ts'; -export const connectCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { - const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; +export const connectCommand: ClientCommandHandler = async ({ + positionals, + flags, + client, + host, +}) => { + const stateDir = resolveDaemonPaths(flags.stateDir, { + cwd: host.cwd, + env: host.env, + }).baseDir; await primeProviderConnection(positionals[0]); const provider = readConnectProvider(positionals); assertConnectProviderUsage(provider, flags); - const resolved = await resolveConnectProviderProfile({ provider, flags, stateDir }); + const resolved = await resolveConnectProviderProfile({ + provider, + flags, + stateDir, + cwd: host.cwd, + env: host.env, + }); const connectFlags = resolved.flags; - const connectionMetadata = readRemoteConfigConnectionMetadata(resolved.remoteConfigPath); + const connectionMetadata = readRemoteConfigConnectionMetadata(resolved.remoteConfigPath, host); const scope = readRequiredConnectScope(connectFlags, connectionMetadata); const context = resolveConnectContext({ stateDir, @@ -76,7 +91,7 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, connection: connectionMetadata, daemon: context.daemon, }); - const verification = await verifyResolvedConnectProvider(resolved); + const verification = await verifyResolvedConnectProvider(resolved, host.env); const state = buildConnectedState({ flags: connectFlags, scope, @@ -90,8 +105,9 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, connectFlags, context.previous, state, + host, ); - const runtimePreparation = buildRuntimePreparationNotice(connectFlags, state); + const runtimePreparation = buildRuntimePreparationNotice(connectFlags, state, host); const readiness = presentConnectReadiness(state, verification); await writeCommandOutput( @@ -270,6 +286,7 @@ async function cleanupForcedPreviousConnection( flags: CliFlags, previous: RemoteConnectionState | null, next: RemoteConnectionState, + host: HostEnvironment, ): Promise { if (!previous || !flags.force) return undefined; await stopMetroCleanup(previous.metro); @@ -277,24 +294,25 @@ async function cleanupForcedPreviousConnection( return await releasePreviousLease(client, previous, { nextDaemonBaseUrl: next.daemon?.baseUrl, ambientDaemonAuthToken: flags.daemonAuthToken, - cwd: process.cwd(), - env: process.env, + cwd: host.cwd, + env: host.env, }); } function readRemoteConfigConnectionMetadata( remoteConfigPath: string, + host: HostEnvironment, ): RemoteConnectionRequestMetadata | undefined { const profile = resolveRemoteConfigProfile({ configPath: remoteConfigPath, - cwd: process.cwd(), - env: process.env, + cwd: host.cwd, + env: host.env, }).profile; return buildRemoteConnectionRequestMetadata(profile); } -export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) => { - const { session, stateDir, state } = readRequestedConnectionState(flags); +export const disconnectCommand: ClientCommandHandler = async ({ flags, client, host }) => { + const { session, stateDir, state } = readRequestedConnectionState(flags, host); if (!state) { await writeNoRemoteConnectionOutput(flags, session); return true; @@ -336,18 +354,18 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) return true; }; -export const connectionCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const connectionCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { if (positionals[0] !== 'status') { throw new AppError('INVALID_ARGS', 'connection accepts only: status'); } - const { session, state } = readRequestedConnectionState(flags); + const { session, state } = readRequestedConnectionState(flags, host); if (!state) { await writeNoRemoteConnectionOutput(flags, session); return true; } await primeProviderConnection(state.leaseProvider); const leasePreparation = buildLeasePreparationNotice(state); - const runtimePreparation = buildRuntimePreparationNoticeFromState(state); + const runtimePreparation = buildRuntimePreparationNoticeFromState(state, host); await writeCommandOutput(flags, serializeConnectionState({ state, runtimePreparation }), () => [ `Configured remote session "${state.session}".`, @@ -414,13 +432,19 @@ function readConnectProvider(positionals: string[]): ConnectProvider | undefined ); } -function readRequestedConnectionState(flags: CliFlags): { +function readRequestedConnectionState( + flags: CliFlags, + host: HostEnvironment, +): { session: string; stateDir: string; state: RemoteConnectionState | null; } { const session = flags.session ?? 'default'; - const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const stateDir = resolveDaemonPaths(flags.stateDir, { + cwd: host.cwd, + env: host.env, + }).baseDir; return { session, stateDir, @@ -505,9 +529,13 @@ function isSameDaemonState( function buildRuntimePreparationNotice( flags: CliFlags, state: RemoteConnectionState, + host: HostEnvironment, ): RuntimePreparationNotice | undefined { if (state.runtime) return undefined; - if (!hasDeferredMetroConfig(flags) && !remoteConfigHasMetroSettings(state.remoteConfigPath)) { + if ( + !hasDeferredMetroConfig(flags) && + !remoteConfigHasMetroSettings(state.remoteConfigPath, host) + ) { return undefined; } return buildDeferredRuntimeNotice(state); @@ -515,8 +543,10 @@ function buildRuntimePreparationNotice( function buildRuntimePreparationNoticeFromState( state: RemoteConnectionState, + host: HostEnvironment, ): RuntimePreparationNotice | undefined { - if (state.runtime || !remoteConfigHasMetroSettings(state.remoteConfigPath)) return undefined; + if (state.runtime || !remoteConfigHasMetroSettings(state.remoteConfigPath, host)) + return undefined; return buildDeferredRuntimeNotice(state); } @@ -534,12 +564,12 @@ function buildDeferredRuntimeNotice(state: RemoteConnectionState): RuntimePrepar }; } -function remoteConfigHasMetroSettings(remoteConfigPath: string): boolean { +function remoteConfigHasMetroSettings(remoteConfigPath: string, host: HostEnvironment): boolean { try { const remoteConfig = resolveRemoteConfigProfile({ configPath: remoteConfigPath, - cwd: process.cwd(), - env: process.env, + cwd: host.cwd, + env: host.env, }); const profile = remoteConfig.profile; return Boolean( diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 5bb2a3b9c2..8d3499452f 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -21,12 +21,15 @@ import { createDaemonOwnerCleanup } from '../../platform-runtime-daemon-owner-cl const daemonOwnerCleanup = createDaemonOwnerCleanup(); -export const daemonCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const daemonCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { const subcommand = positionals[0]; if ((subcommand !== 'stop' && subcommand !== 'status') || positionals.length !== 1) { throw new AppError('INVALID_ARGS', 'daemon accepts only: stop, status'); } - const paths = resolveDaemonPaths(flags.stateDir); + const paths = resolveDaemonPaths(flags.stateDir, { + cwd: host.cwd, + env: host.env, + }); if (subcommand === 'status') { const status = readDaemonStatus(paths); const shown = { ...status, plugins: readPluginInventory(status.plugins) }; diff --git a/src/cli/commands/device.ts b/src/cli/commands/device.ts index 15b2675e0f..2e2a40ea95 100644 --- a/src/cli/commands/device.ts +++ b/src/cli/commands/device.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { AppError } from '@agent-device/kernel/errors'; import { publicPlatformString } from '@agent-device/kernel/device'; import { @@ -13,17 +14,20 @@ import type { CliFlags } from '@agent-device/contracts/command'; import type { ClientCommandHandler } from './router-types.ts'; // Loaded dynamically by dedicatedCliCommandHandlerLoaders in router.ts. -export const deviceCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const deviceCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { if (positionals.length !== 1 || (positionals[0] !== 'status' && positionals[0] !== 'release')) { throw new AppError('INVALID_ARGS', 'device accepts only: status, release'); } - if (positionals[0] === 'release') return await runDeviceRelease(flags); - const inspectedClaims = inspectDeviceClaims({ - platform: flags.platform, - device: flags.device, - udid: flags.udid, - serial: flags.serial, - }); + if (positionals[0] === 'release') return await runDeviceRelease(flags, host); + const inspectedClaims = inspectDeviceClaims( + { + platform: flags.platform, + device: flags.device, + udid: flags.udid, + serial: flags.serial, + }, + host.env, + ); const staleClaims = inspectedClaims.filter((claim) => deviceClaimRequiresStaleInspection(claim.classification), ); @@ -55,7 +59,7 @@ export const deviceCommand: ClientCommandHandler = async ({ positionals, flags } * provably cannot release them. Everything else fails closed and is reported * with the reason, so a live or uncertain owner is never interrupted. */ -async function runDeviceRelease(flags: CliFlags): Promise { +async function runDeviceRelease(flags: CliFlags, host: HostEnvironment): Promise { if (flags.stale !== true) { throw new AppError( 'INVALID_ARGS', @@ -66,12 +70,15 @@ async function runDeviceRelease(flags: CliFlags): Promise { ); } const { runStaleDeviceClaimRelease } = await import('../../platform-runtime-claim-recovery.ts'); - const outcomes = await runStaleDeviceClaimRelease({ - platform: flags.platform, - device: flags.device, - udid: flags.udid, - serial: flags.serial, - }); + const outcomes = await runStaleDeviceClaimRelease( + { + platform: flags.platform, + device: flags.device, + udid: flags.udid, + serial: flags.serial, + }, + host.env, + ); const data = { released: outcomes.filter((outcome) => outcome.status === 'released').map(serializeOutcome), retained: outcomes.filter((outcome) => outcome.status === 'retained').map(serializeOutcome), diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index f277c1d886..4cfb6fe3c8 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { randomBytes } from 'node:crypto'; import { createDaemonProxyServer } from '@agent-device/proxy'; import { buildDaemonHttpBaseUrl } from '@agent-device/contracts/daemon-http'; @@ -19,28 +20,36 @@ type ProxyStartup = { stateDir: string; }; -export const proxyCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const proxyCommand: ClientCommandHandler = async ({ + positionals, + flags, + host: clientHost, +}) => { if (positionals.length > 0) { throw new AppError('INVALID_ARGS', 'proxy does not accept positional arguments.'); } - const startup = await startProxy(flags); + const startup = await startProxy(flags, clientHost); await writeCommandOutput(flags, startup, () => renderProxyStartup(startup)); await waitForever(); return true; }; -async function startProxy(flags: CliFlags): Promise { - const settings = resolveClientSettings({ - session: 'default', - command: 'proxy', - positionals: [], - flags: { - stateDir: flags.stateDir, - daemonBaseUrl: '', - daemonTransport: 'http', - daemonServerMode: 'http', +async function startProxy(flags: CliFlags, clientHost: HostEnvironment): Promise { + const settings = resolveClientSettings( + { + session: 'default', + command: 'proxy', + positionals: [], + flags: { + stateDir: flags.stateDir, + daemonBaseUrl: '', + daemonTransport: 'http', + daemonServerMode: 'http', + }, }, - }); + undefined, + clientHost, + ); const daemon = await ensureManagedDaemon(settings); const upstreamBaseUrl = resolveLocalDaemonBaseUrl(daemon.info.httpPort); const token = resolveProxyClientToken(flags); diff --git a/src/cli/commands/recording.ts b/src/cli/commands/recording.ts index 27311d9515..868ce38b24 100644 --- a/src/cli/commands/recording.ts +++ b/src/cli/commands/recording.ts @@ -8,7 +8,7 @@ import type { ClientCommandHandler } from './router-types.ts'; /** The `record` action that reads a finished file locally instead of asking a device for anything. */ const CONTACT_SHEET_ACTION = 'contact-sheet'; -export const recordingCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const recordingCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { if (positionals[0] !== CONTACT_SHEET_ACTION) return false; if (positionals.length > 2) { throw new AppError( @@ -17,8 +17,11 @@ export const recordingCommand: ClientCommandHandler = async ({ positionals, flag ); } - const recordingPath = resolveUserPath(readRequiredPositional(positionals[1])); - const outputPath = typeof flags.out === 'string' ? resolveUserPath(flags.out) : undefined; + const recordingPath = resolveUserPath(readRequiredPositional(positionals[1]), { + cwd: host.cwd, + }); + const outputPath = + typeof flags.out === 'string' ? resolveUserPath(flags.out, { cwd: host.cwd }) : undefined; // Lazy: createAgentDevice pulls in the client-side command runtime, which only this action needs. const [{ createAgentDevice }, { createLocalArtifactAdapter }] = await Promise.all([ @@ -27,7 +30,7 @@ export const recordingCommand: ClientCommandHandler = async ({ positionals, flag ]); const runtime = createAgentDevice({ backend: { platform: 'ios' }, - artifacts: createLocalArtifactAdapter(), + artifacts: createLocalArtifactAdapter({ cwd: host.cwd }), sessions: { get: (name) => ({ name }), set: () => {}, diff --git a/src/cli/commands/replay.ts b/src/cli/commands/replay.ts index d50c4f79c5..96d8caf968 100644 --- a/src/cli/commands/replay.ts +++ b/src/cli/commands/replay.ts @@ -30,6 +30,7 @@ function handleReplayRunCommand({ positionals, flags }: ReplayCommandParams): fa async function handleReplayExportCommand({ positionals, flags, + host, }: ReplayCommandParams): Promise { validateReplayExportOptions(positionals, flags); const inputPath = positionals[1]; @@ -37,7 +38,7 @@ async function handleReplayExportCommand({ throw new AppError('INVALID_ARGS', 'replay export requires an input path.'); } - const sourcePath = resolveUserPath(inputPath); + const sourcePath = resolveUserPath(inputPath, { cwd: host.cwd }); const script = fs.readFileSync(sourcePath, 'utf8'); const parsed = parseReplayScriptDetailed(script); // `replay export` exports to Maestro, so the conversion goes through that backend's registry @@ -50,7 +51,8 @@ async function handleReplayExportCommand({ actionLines: parsed.actionLines, metadata: readReplayScriptMetadata(script), }); - const outputPath = typeof flags.out === 'string' ? resolveUserPath(flags.out) : undefined; + const outputPath = + typeof flags.out === 'string' ? resolveUserPath(flags.out, { cwd: host.cwd }) : undefined; if (outputPath) { fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, result.yaml); diff --git a/src/cli/commands/router-types.ts b/src/cli/commands/router-types.ts index 2d0c9e38f0..609b223ef8 100644 --- a/src/cli/commands/router-types.ts +++ b/src/cli/commands/router-types.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { CliFlags } from '@agent-device/contracts/command'; import type { AgentDeviceClient } from '../../agent-device-client.ts'; import type { CliCommandName } from '@agent-device/command-registry/catalog'; @@ -5,6 +6,8 @@ import type { CommandProgressState } from '../../commands/command-progress.ts'; import type { ReplayTestReporterRuntime } from '../replay-test/reporting.ts'; export type ClientCommandParams = { + /** The host this run reads its environment and working directory from. */ + host: HostEnvironment; positionals: string[]; flags: CliFlags; client: AgentDeviceClient; diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index 6774e2a6d4..14220317d1 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -26,6 +26,7 @@ const dedicatedCliCommandHandlerLoaders = { } satisfies ClientCommandHandlerMap; export async function tryRunClientBackedCommand(params: { + host: ClientCommandParams['host']; command: string; positionals: string[]; flags: CliFlags; diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts index 0e3b291236..f12fe61f20 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -37,7 +37,7 @@ export const screenshotCommand: ClientCommandHandler = async ({ positionals, fla return true; }; -export const diffCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { +export const diffCommand: ClientCommandHandler = async ({ positionals, flags, client, host }) => { if (positionals[0] === 'snapshot') { const result = await runCliCommand({ client, command: 'diff', positionals, flags }); await writeCommandOutput(flags, result, () => formatSnapshotDiffText(result)); @@ -51,8 +51,9 @@ export const diffCommand: ClientCommandHandler = async ({ positionals, flags, cl throw new AppError('INVALID_ARGS', 'diff screenshot requires --baseline '); } - const baselinePath = resolveUserPath(baselineRaw); - const outputPath = typeof flags.out === 'string' ? resolveUserPath(flags.out) : undefined; + const baselinePath = resolveUserPath(baselineRaw, { cwd: host.cwd }); + const outputPath = + typeof flags.out === 'string' ? resolveUserPath(flags.out, { cwd: host.cwd }) : undefined; const currentRaw = positionals[1]; if (positionals.length > 2) { throw new AppError( @@ -69,7 +70,7 @@ export const diffCommand: ClientCommandHandler = async ({ positionals, flags, cl ]); const runtime = createAgentDevice({ backend: createClientScreenshotBackend(client, flags), - artifacts: createLocalArtifactAdapter(), + artifacts: createLocalArtifactAdapter({ cwd: host.cwd }), sessions: { get: (name) => ({ name }), set: () => {}, @@ -80,7 +81,9 @@ export const diffCommand: ClientCommandHandler = async ({ positionals, flags, cl const result = await runtime.capture.diffScreenshot({ session: flags.session, baseline: { kind: 'path', path: baselinePath }, - current: currentRaw ? { kind: 'path', path: resolveUserPath(currentRaw) } : { kind: 'live' }, + current: currentRaw + ? { kind: 'path', path: resolveUserPath(currentRaw, { cwd: host.cwd }) } + : { kind: 'live' }, ...(outputPath ? { out: { kind: 'path', path: outputPath } } : {}), threshold: parseCliThreshold(flags.threshold), overlayRefs: flags.overlayRefs, diff --git a/src/cli/connection/cloud-profile.ts b/src/cli/connection/cloud-profile.ts index 5af7d3fad5..fe58324e96 100644 --- a/src/cli/connection/cloud-profile.ts +++ b/src/cli/connection/cloud-profile.ts @@ -21,7 +21,7 @@ export async function resolveCloudConnectProfile(options: { flags: CliFlags; stateDir: string; cwd: string; - env?: EnvMap; + env: EnvMap; // di-seam-approved: injects the fetch global, which has no module boundary vi.mock can // intercept. Exercised at the CLI layer by cloud-connect-profile.test.ts via // vi.stubGlobal('fetch', ...), a layer where this parameter is not reachable. @@ -32,7 +32,6 @@ export async function resolveCloudConnectProfile(options: { flags: options.flags, env: options.env, io: { - env: options.env, fetch: options.fetchImpl, }, }); diff --git a/src/cli/connection/connect-provider-adapters.ts b/src/cli/connection/connect-provider-adapters.ts index 0b078c9cda..8ca1de5d20 100644 --- a/src/cli/connection/connect-provider-adapters.ts +++ b/src/cli/connection/connect-provider-adapters.ts @@ -102,11 +102,10 @@ export async function resolveConnectProviderProfile(options: { provider?: ConnectProvider; flags: CliFlags; stateDir: string; - cwd?: string; - env?: EnvMap; + cwd: string; + env: EnvMap; }): Promise { - const cwd = options.cwd ?? process.cwd(); - const env = options.env ?? process.env; + const { cwd, env } = options; if (options.flags.remoteConfig) { const resolved = resolveRemoteConfigProfile({ configPath: options.flags.remoteConfig, @@ -187,6 +186,7 @@ async function resolveProviderConnectionProfile( export async function verifyResolvedConnectProvider( resolved: ResolvedConnectProfile, + env: EnvMap, ): Promise { if (!resolved.provider) { return { @@ -198,7 +198,7 @@ export async function verifyResolvedConnectProvider( } const context = { flags: resolved.flags, - env: process.env, + env, }; const adapter = builtinConnectAdapter(resolved.provider); return adapter diff --git a/src/cli/connection/ended-lease.test.ts b/src/cli/connection/ended-lease.test.ts index c09d432a69..d73f4b4eeb 100644 --- a/src/cli/connection/ended-lease.test.ts +++ b/src/cli/connection/ended-lease.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { test } from 'vitest'; @@ -10,6 +11,8 @@ import { import { readRemoteConnectionState } from '@agent-device/remote/remote-connection-state'; import { forgetEndedProxyLease } from './ended-lease.ts'; +bindTestCommandHost(); + const LEASE_ID = 'abc123abc123abc1'; const ENDED = [{ leaseId: LEASE_ID, outcome: 'released' }] as const; @@ -43,6 +46,7 @@ function storedLeaseIdAfter( session: 'adc-proxy', }, response, + createTestHostEnvironment(), ); return readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId; } diff --git a/src/cli/connection/ended-lease.ts b/src/cli/connection/ended-lease.ts index 0076a4412d..0415d5a12c 100644 --- a/src/cli/connection/ended-lease.ts +++ b/src/cli/connection/ended-lease.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { CliFlags } from '@agent-device/contracts/command'; import { readEndedLeases } from '@agent-device/contracts/lease-scope'; @@ -13,11 +14,18 @@ import { connectionProviderCapabilities } from './provider-policy.ts'; * Forgets the stored proxy lease once a daemon reply says its request ended that lease, so the next * `open` allocates instead of heartbeating a lease that no longer exists. */ -export function forgetEndedProxyLease(flags: CliFlags, response: DaemonResponse): void { +export function forgetEndedProxyLease( + flags: CliFlags, + response: DaemonResponse, + host: HostEnvironment, +): void { if (!flags.remoteConfig) return; const endedLeaseIds = readEndedLeases(response).map((lease) => lease.leaseId); if (endedLeaseIds.length === 0) return; - const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const stateDir = resolveDaemonPaths(flags.stateDir, { + cwd: host.cwd, + env: host.env, + }).baseDir; const state = readRemoteConnectionState({ stateDir, session: flags.session ?? 'default' }); if (!state?.leaseId || !endedLeaseIds.includes(state.leaseId)) return; if (isHostAllocatedLease(state)) return; diff --git a/src/cli/connection/generated-config.ts b/src/cli/connection/generated-config.ts index 48168ecb71..e82ad7544e 100644 --- a/src/cli/connection/generated-config.ts +++ b/src/cli/connection/generated-config.ts @@ -37,7 +37,7 @@ export function writeGeneratedRemoteConfig(options: { function resolveGeneratedRemoteConfigProfile(options: { configPath: string; cwd: string; - env?: EnvMap; + env: EnvMap; provider: string; }): ResolvedRemoteConfigProfile { try { @@ -60,7 +60,7 @@ function resolveGeneratedRemoteConfigProfile(options: { export function persistAndResolveGeneratedProfile(options: { stateDir: string; cwd: string; - env?: EnvMap; + env: EnvMap; provider: string; profile: RemoteConfigProfile; flags: CliFlags; diff --git a/src/cli/connection/limrun-profile.ts b/src/cli/connection/limrun-profile.ts index e2868b5901..2586e6b383 100644 --- a/src/cli/connection/limrun-profile.ts +++ b/src/cli/connection/limrun-profile.ts @@ -19,10 +19,10 @@ export function resolveLimrunConnectProfile(options: { flags: CliFlags; stateDir: string; cwd: string; - env?: EnvMap; + env: EnvMap; }): { flags: CliFlags; remoteConfigPath: string } { - const env = options.env ?? process.env; - const profile = buildLimrunRemoteProfile({ flags: options.flags }); + const { env } = options; + const profile = buildLimrunRemoteProfile({ flags: options.flags, cwd: options.cwd, env }); const credentials = readLimrunCredentials(env); const platform = limrunPlatformForLeaseBackend(profile.leaseBackend) ?? 'android'; if (!credentials?.apiKey && !credentials?.instances?.[platform]) { @@ -44,10 +44,17 @@ export function resolveLimrunConnectProfile(options: { }); } -function buildLimrunRemoteProfile(options: { flags: CliFlags }): RemoteConfigProfile { +function buildLimrunRemoteProfile(options: { + flags: CliFlags; + cwd: string; + env: EnvMap; +}): RemoteConfigProfile { const flags = options.flags; const leaseBackend = validateLimrunConnectFlags(flags); - const daemonPaths = resolveDaemonPaths(flags.stateDir); + const daemonPaths = resolveDaemonPaths(flags.stateDir, { + cwd: options.cwd, + env: options.env, + }); return { stateDir: daemonPaths.baseDir, daemonTransport: 'auto', diff --git a/src/cli/connection/proxy-profile.ts b/src/cli/connection/proxy-profile.ts index 768bbfe7a6..fc3f90b5c6 100644 --- a/src/cli/connection/proxy-profile.ts +++ b/src/cli/connection/proxy-profile.ts @@ -11,9 +11,9 @@ export function resolveProxyConnectProfile(options: { flags: CliFlags; stateDir: string; cwd: string; - env?: EnvMap; + env: EnvMap; }): { flags: CliFlags; remoteConfigPath: string } { - const daemonBaseUrl = options.flags.daemonBaseUrl ?? options.env?.AGENT_DEVICE_DAEMON_BASE_URL; + const daemonBaseUrl = options.flags.daemonBaseUrl ?? options.env.AGENT_DEVICE_DAEMON_BASE_URL; if (!daemonBaseUrl) { throw new AppError( 'INVALID_ARGS', diff --git a/src/cli/process-entry.ts b/src/cli/process-entry.ts index db81bccbe0..43f1a608d2 100644 --- a/src/cli/process-entry.ts +++ b/src/cli/process-entry.ts @@ -1,16 +1,18 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { exitAfterFlush } from './process-exit.ts'; type CliModule = { - runCli(argv: string[]): Promise; + runCli(argv: string[], deps: undefined, host: HostEnvironment): Promise; }; type LoadCli = () => Promise; export async function runCliProcess( argv: string[], + host: HostEnvironment, loadCli: LoadCli = async () => await import('../cli.ts'), ): Promise { const { runCli } = await loadCli(); - await runCli(argv); + await runCli(argv, undefined, host); return await exitAfterFlush(0); } diff --git a/src/cli/replay-test/progress.ts b/src/cli/replay-test/progress.ts index 9ef7ebde37..54b3a18e7a 100644 --- a/src/cli/replay-test/progress.ts +++ b/src/cli/replay-test/progress.ts @@ -6,7 +6,10 @@ import type { ReplayTestResult, ReplayTestStep, } from './reporters/types.ts'; -import { formatCliStatusMarker } from '@agent-device/host-kit/status-markers'; +import { + formatCliStatusMarker, + shouldColorStatusMarkers, +} from '@agent-device/host-kit/status-markers'; import { formatDurationSeconds } from './duration-format.ts'; import { colorize, supportsColor } from '../../commands/output/color.ts'; @@ -236,12 +239,14 @@ function formatReplayTestProgressName(event: ReplayTestResult | ReplayTestStep): } function formatReplayTestProgressStatusLabel(event: ReplayTestResult): string { + const useColor = shouldColorStatusMarkers(process.env, Boolean(process.stderr.isTTY)); if (event.status === 'pass') { return formatCliStatusMarker('pass', { + useColor, passFormat: event.attempt && event.attempt > 1 ? 'yellow' : 'green', }); } - return formatCliStatusMarker(event.status === 'fail' ? 'fail' : 'skip'); + return formatCliStatusMarker(event.status === 'fail' ? 'fail' : 'skip', { useColor }); } function colorizeProgressMarker(text: string, format: Parameters[1]): string { diff --git a/src/cli/resolve-cli-options.ts b/src/cli/resolve-cli-options.ts index 96ff93bb51..69eb8d3737 100644 --- a/src/cli/resolve-cli-options.ts +++ b/src/cli/resolve-cli-options.ts @@ -7,15 +7,14 @@ import { type EnvMap } from '@agent-device/kernel/source-value'; export function resolveCliOptions( argv: string[], - options?: { - cwd?: string; - env?: EnvMap; + options: { + cwd: string; + env: EnvMap; strictFlags?: boolean; }, ) { const rawParsed = parseRawArgs(argv); - const env = options?.env ?? process.env; - const cwd = options?.cwd ?? process.cwd(); + const { env, cwd } = options; const remoteConfigDefaults = shouldApplyRemoteConfigDefaults(rawParsed.command) ? resolveRemoteConfigDefaults({ remoteConfig: rawParsed.flags.remoteConfig, diff --git a/src/cli/session-binding.ts b/src/cli/session-binding.ts index 89757bcdd5..2e2f85b38f 100644 --- a/src/cli/session-binding.ts +++ b/src/cli/session-binding.ts @@ -21,7 +21,7 @@ type LockableFlags = Pick< >; type BindingOptions = { - env?: NodeJS.ProcessEnv; + env: NodeJS.ProcessEnv; policyOverrides?: BindingPolicyOverrides; configuredPlatform?: CliFlags['platform']; configuredSession?: string; @@ -30,7 +30,7 @@ type BindingOptions = { export function applyDefaultPlatformBinding( flags: T, - options: BindingOptions = {}, + options: BindingOptions, ): T { const settings = resolveBindingSettings(options); const nextFlags = { ...flags }; @@ -43,7 +43,7 @@ export function applyDefaultPlatformBinding( } export function resolveBindingSettings(options: BindingOptions): BindingSettings { - const env = options.env ?? process.env; + const { env } = options; const defaultPlatform = options.inheritedPlatform ?? options.configuredPlatform ?? diff --git a/src/commands/cli-output.test.ts b/src/commands/cli-output.test.ts index 75dc7b3652..d0317fafe3 100644 --- a/src/commands/cli-output.test.ts +++ b/src/commands/cli-output.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { describe, expect, test } from 'vitest'; import { listCommandFamilyCliOutputFormatters, @@ -6,6 +7,8 @@ import { import { formatCliOutput, isParseableOutputCommand } from './cli-output.ts'; import type { CommandName } from './command-metadata.ts'; +bindTestCommandHost(); + /** * #2682: one dispatcher, one warning rule. Every command that renders text owes the agent the * response's `Warning:` lines, and the ONLY way a command keeps them off stdout is the diff --git a/src/commands/management/output.test.ts b/src/commands/management/output.test.ts index a7044854c4..19d7a6fd67 100644 --- a/src/commands/management/output.test.ts +++ b/src/commands/management/output.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from 'vitest'; import { doctorCliOutput, managementCliOutputFormatters, openCliOutput } from './output.ts'; -import { withNoColorAsync } from '@agent-device/test-kit'; +import { withNoColorAsync, bindTestCommandHost } from '@agent-device/test-kit'; import type { AppOpenResult } from '@agent-device/contracts/client'; +bindTestCommandHost(); + describe('openCliOutput', () => { test('prints session state directory on a second line', async () => { const output = await openCliOutput({ diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index 331a4302e0..84c6146f58 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { AgentDeviceCapabilitiesResult, AgentDeviceDevice, @@ -188,6 +189,11 @@ export async function doctorCliOutput( ): Promise { const { formatDoctorCheckDetailLines, formatDoctorCheckSummaryLine } = await import('@agent-device/host-kit/doctor-lines'); + const { shouldColorStatusMarkers } = await import('@agent-device/host-kit/status-markers'); + const useColor = shouldColorStatusMarkers( + commandHostEnvironment().env, + Boolean(process.stderr.isTTY), + ); const data = result as Record; const status = typeof data.status === 'string' ? data.status : 'unknown'; const lines = [`Doctor: ${status}`]; @@ -203,7 +209,7 @@ export async function doctorCliOutput( lines.push(summary); } else { for (const check of checks) { - lines.push(formatDoctorCheckSummaryLine(check)); + lines.push(formatDoctorCheckSummaryLine(check, { useColor })); lines.push(...formatDoctorCheckDetailLines(check)); } } diff --git a/src/core/interactors-app-events.test.ts b/src/core/interactors-app-events.test.ts index cdf6bfbfe7..51fe7f78ab 100644 --- a/src/core/interactors-app-events.test.ts +++ b/src/core/interactors-app-events.test.ts @@ -4,9 +4,17 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { ANDROID_EMULATOR, IOS_DEVICE, MACOS_DEVICE, mkdtempForTest } from '@agent-device/test-kit'; +import { + ANDROID_EMULATOR, + IOS_DEVICE, + MACOS_DEVICE, + mkdtempForTest, + bindTestCommandHost, +} from '@agent-device/test-kit'; import { getInteractor } from './interactors.ts'; +bindTestCommandHost(); + async function triggerEvent(device: DeviceInfo, eventUrl: string, appBundleId?: string) { const operations = bindAdmittedLocalInteractorOperations({ device, diff --git a/src/daemon-client/__tests__/boundary-fault-acceptance.test.ts b/src/daemon-client/__tests__/boundary-fault-acceptance.test.ts index 7b0e8b5ef5..2fff6d745b 100644 --- a/src/daemon-client/__tests__/boundary-fault-acceptance.test.ts +++ b/src/daemon-client/__tests__/boundary-fault-acceptance.test.ts @@ -4,7 +4,11 @@ import http from 'node:http'; import path from 'node:path'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { assertRejectsAppError, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + assertRejectsAppError, + mkdtempForTestSync, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { sendRequest } from '../daemon-client-transport.ts'; @@ -44,6 +48,7 @@ for (const row of Object.values(PROCESS_DEATH_ROWS)) { 'http', statePaths, 1_000, + { host: createTestHostEnvironment() }, ); } catch (error) { observed = error; diff --git a/src/daemon-client/__tests__/boundary-fault-transport.test.ts b/src/daemon-client/__tests__/boundary-fault-transport.test.ts index 3025d1fba0..0c59f48ffe 100644 --- a/src/daemon-client/__tests__/boundary-fault-transport.test.ts +++ b/src/daemon-client/__tests__/boundary-fault-transport.test.ts @@ -11,6 +11,7 @@ import { listenOnLoopback, type LoopbackServer, mkdtempForTestSync, + createTestHostEnvironment, } from '@agent-device/test-kit'; import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { sendRequest } from '../daemon-client-transport.ts'; @@ -60,6 +61,7 @@ for (const [commandClass, row] of Object.entries(COMMAND_ROWS)) { 'http', daemonPaths(), 60, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -95,6 +97,7 @@ for (const [commandClass, row] of Object.entries(COMMAND_ROWS)) { 'http', daemonPaths(), 1_000, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -136,6 +139,7 @@ test('HTTP RPC errors preserve typed identity through the client transport', asy 'http', daemonPaths(), 1_000, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -165,6 +169,7 @@ test('socket partial response fails at end-of-stream instead of waiting for the 'socket', daemonPaths(), 1_000, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -194,6 +199,7 @@ test('socket empty response fails at end-of-stream with request context', async 'socket', daemonPaths(), 1_000, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -222,6 +228,7 @@ test('socket malformed response stays typed at the daemon client seam', async () 'socket', daemonPaths(), 1_000, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); diff --git a/src/daemon-client/__tests__/daemon-client-abort-timeout.test.ts b/src/daemon-client/__tests__/daemon-client-abort-timeout.test.ts index 02d6997787..dcae9a7162 100644 --- a/src/daemon-client/__tests__/daemon-client-abort-timeout.test.ts +++ b/src/daemon-client/__tests__/daemon-client-abort-timeout.test.ts @@ -25,6 +25,7 @@ import { type SkippableTestContext, skipWhenLoopbackUnavailable, trackLoopbackSockets, + createTestHostEnvironment, } from '@agent-device/test-kit'; const { handleRequestTimeoutCalls } = vi.hoisted(() => ({ @@ -78,7 +79,9 @@ async function runTimerDiscipline( transport, STATE_PATHS, TIMEOUT_MS, - aborted ? { signal: controller.signal } : {}, + aborted + ? { host: createTestHostEnvironment(), signal: controller.signal } + : { host: createTestHostEnvironment() }, ); if (aborted) { const armedTimeoutIndex = setTimeoutSpy.mock.calls.findIndex( @@ -162,7 +165,7 @@ test('an abort during a restart health probe never retires the daemon or redispa 'auto', STATE_PATHS, 1_000, - { signal: controller.signal }, + { host: createTestHostEnvironment(), signal: controller.signal }, ); const rejection = assert.rejects(request, (error: unknown) => isRequestCanceledError(error)); await healthStarted; diff --git a/src/daemon-client/__tests__/daemon-client-lease-beat.test.ts b/src/daemon-client/__tests__/daemon-client-lease-beat.test.ts index 8db255bfca..38b117e053 100644 --- a/src/daemon-client/__tests__/daemon-client-lease-beat.test.ts +++ b/src/daemon-client/__tests__/daemon-client-lease-beat.test.ts @@ -578,6 +578,7 @@ describe('buildUploadLeaseHeartbeat', () => { }; const settings = { + host: { env: process.env, cwd: process.cwd() }, paths: resolveDaemonPaths('/tmp/agent-device-upload-lease'), transportPreference: 'socket' as const, daemonLifecycle: 'managed' as const, diff --git a/src/daemon-client/__tests__/daemon-client-policy-reuse.test.ts b/src/daemon-client/__tests__/daemon-client-policy-reuse.test.ts index c6c56bfef9..7645c0dde5 100644 --- a/src/daemon-client/__tests__/daemon-client-policy-reuse.test.ts +++ b/src/daemon-client/__tests__/daemon-client-policy-reuse.test.ts @@ -69,7 +69,10 @@ test('a caller naming a policy refuses a running daemon that enforces a differen assert.ok(error instanceof AppError); assert.equal(error.details?.reason, 'DAEMON_POLICY_MISMATCH'); assert.equal(error.details?.daemonPolicyDigest, otherDigest); - assert.equal(error.details?.expectedPolicyDigest, loadDaemonPolicy()?.digest); + assert.equal( + error.details?.expectedPolicyDigest, + loadDaemonPolicy(process.env, process.cwd())?.digest, + ); return true; }, ); @@ -153,7 +156,9 @@ async function startReusableDaemon(options: { codeSignature: currentDaemonCodeSignature(), processStartTime: readProcessStartTime(process.pid) ?? undefined, policyDigest: - options.policyDigest === 'matching' ? loadDaemonPolicy()?.digest : options.policyDigest, + options.policyDigest === 'matching' + ? loadDaemonPolicy(process.env, process.cwd())?.digest + : options.policyDigest, })}\n`, ); if (options.publish === 'on-spawn') { diff --git a/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts b/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts index a266293a47..1bd7e64a9f 100644 --- a/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts +++ b/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts @@ -48,6 +48,7 @@ import { type LoopbackServer, mkdtempForTestSync, skipWhenLoopbackUnavailable, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { @@ -362,6 +363,7 @@ for (const row of ROUTE_ROWS) { row.transport === 'remote' ? 'http' : row.transport, statePaths, TIMEOUT_MS, + { host: createTestHostEnvironment() }, ), row.hintPattern, ); @@ -406,6 +408,7 @@ test('request-timeout route: a reset never deletes a registration a replacement 'socket', statePaths, TIMEOUT_MS, + { host: createTestHostEnvironment() }, ), /The daemon could not be safely retired/, ); @@ -439,7 +442,14 @@ test('a refused timeout fallback preserves the timeout without an unhandled reje }); try { await assert.rejects( - sendRequest(info, buildRequest(RESET_POLICY_COMMAND, undefined), 'socket', paths, TIMEOUT_MS), + sendRequest( + info, + buildRequest(RESET_POLICY_COMMAND, undefined), + 'socket', + paths, + TIMEOUT_MS, + { host: createTestHostEnvironment() }, + ), (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.details?.reason, 'daemon_transport_timeout'); @@ -470,7 +480,7 @@ test('request-timeout route: an attach client leaves a daemon that answers no pr 'socket', paths, TIMEOUT_MS, - { timeoutRecovery: 'none' }, + { host: createTestHostEnvironment(), timeoutRecovery: 'none' }, ), /did not answer the liveness probe.*does not retire a daemon it did not start.*agent-device daemon stop/s, ); @@ -510,6 +520,7 @@ test('request-timeout route: the reset acts on the resolved state paths, not the 'http', resolvedPaths, TIMEOUT_MS, + { host: createTestHostEnvironment() }, ), /The daemon did not answer the liveness probe and was reset after the timeout/, ); diff --git a/src/daemon-client/__tests__/daemon-client-transport.test.ts b/src/daemon-client/__tests__/daemon-client-transport.test.ts index 54e4b5e6c5..a1319a5676 100644 --- a/src/daemon-client/__tests__/daemon-client-transport.test.ts +++ b/src/daemon-client/__tests__/daemon-client-transport.test.ts @@ -9,6 +9,7 @@ import { listenOnLoopback, mkdtempForTestSync, skipWhenLoopbackUnavailable, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { test, vi } from 'vitest'; import * as hostTransport from '@agent-device/host-kit/transport'; @@ -40,6 +41,7 @@ function sendWithStaleInstance(port: number, timeoutMs: number) { 'auto', resolveDaemonPaths('/tmp/agent-device-instance-retry-test'), timeoutMs, + { host: createTestHostEnvironment() }, ); } @@ -497,6 +499,7 @@ test.for(['timeout', 'reset'] as const)( 'http', paths, mode === 'timeout' ? 40 : 1_000, + { host: createTestHostEnvironment() }, ), ), (error: unknown) => { diff --git a/src/daemon-client/daemon-client-attach.ts b/src/daemon-client/daemon-client-attach.ts index bf0d35a7f1..9597a83f1a 100644 --- a/src/daemon-client/daemon-client-attach.ts +++ b/src/daemon-client/daemon-client-attach.ts @@ -36,6 +36,6 @@ export async function connectToDaemon(settings: DaemonClientSettings): Promise { - const settings = resolveLocalDaemonSettings(options); +export async function ensureDaemon( + options: EnsureDaemonOptions = {}, + host: HostEnvironment = captureHostEnvironment(process), +): Promise { + const settings = resolveLocalDaemonSettings(options, host); for (let attempt = 1; attempt <= ENSURE_ATTEMPTS; attempt += 1) { const step = await ensureOnce(settings, options.onStart); if (step.kind === 'judge-again') { await sleep(JUDGE_AGAIN_DELAY_MS); continue; } - await assertDaemonPolicyMatches(step.daemon.info, settings.paths.baseDir); + await assertDaemonPolicyMatches(step.daemon.info, settings.paths.baseDir, host); return { stateDir: settings.paths.baseDir, daemon: daemonIdentityOf(step.daemon.info), diff --git a/src/daemon-client/daemon-client-ensuring.ts b/src/daemon-client/daemon-client-ensuring.ts index 24eded8866..a43275ba5a 100644 --- a/src/daemon-client/daemon-client-ensuring.ts +++ b/src/daemon-client/daemon-client-ensuring.ts @@ -1,4 +1,6 @@ import type { DaemonIdentity, EnsureDaemonOptions } from '@agent-device/contracts/client'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import type { sendToDaemon } from './daemon-client.ts'; import { isDaemonNotRunningError } from './daemon-lifecycle-errors.ts'; @@ -6,6 +8,8 @@ import { isDaemonNotRunningError } from './daemon-lifecycle-errors.ts'; type DaemonTransport = typeof sendToDaemon; export type EnsuringTransportOptions = { + /** The host the ensured daemon is resolved and started from; the process's own when omitted. */ + host?: HostEnvironment; /** * Called when ensuring replaced a daemon: an `incompatible` one that held no work, or an * `unreachable` one that answered nothing. @@ -37,9 +41,10 @@ export function withEnsuredDaemon( return async (req, context) => { // Loaded on the first request: the lifecycle module is not part of what every CLI start pays for. const { resolveEnsurableDaemon } = await import('./daemon-client-lifecycle.ts'); - const target = resolveEnsurableDaemon(req); + const host = options.host ?? captureHostEnvironment(process); + const target = resolveEnsurableDaemon(req, host); if (!target) return await transport(req, context); - const stateDir = resolveDaemonPaths(target.stateDir).baseDir; + const stateDir = resolveDaemonPaths(target.stateDir, { env: host.env, cwd: host.cwd }).baseDir; // A caller that has already given up starts nothing: attaching refuses before any connection. if (context?.signal?.aborted) { return await transport(req, { ...context, daemonLifecycle: 'attach' }); @@ -49,7 +54,7 @@ export function withEnsuredDaemon( const ensure = async (): Promise<{ relied: Promise }> => { let pending = ensured.get(stateDir); if (!pending) { - const started = ensureLocalDaemon(target, stateDir, options); + const started = ensureLocalDaemon(target, stateDir, host, options); pending = started; ensured.set(stateDir, started); started.catch(() => { @@ -79,19 +84,23 @@ export function withEnsuredDaemon( async function ensureLocalDaemon( target: EnsureDaemonOptions, stateDir: string, + host: HostEnvironment, options: EnsuringTransportOptions, ): Promise { const { ensureDaemon } = await import('./daemon-client-ensure.ts'); - await ensureDaemon({ - ...target, - onStart: (reason, previous) => { - if (previous) { - options.onReplaced?.({ - stateDir, - previous, - reason: reason === 'incompatible' ? 'incompatible' : 'unreachable', - }); - } + await ensureDaemon( + { + ...target, + onStart: (reason, previous) => { + if (previous) { + options.onReplaced?.({ + stateDir, + previous, + reason: reason === 'incompatible' ? 'incompatible' : 'unreachable', + }); + } + }, }, - }); + host, + ); } diff --git a/src/daemon-client/daemon-client-host.test.ts b/src/daemon-client/daemon-client-host.test.ts new file mode 100644 index 0000000000..3e3f7578c4 --- /dev/null +++ b/src/daemon-client/daemon-client-host.test.ts @@ -0,0 +1,77 @@ +import path from 'node:path'; +import { afterEach, expect, test, vi } from 'vitest'; + +const ensured = vi.hoisted(() => ({ settings: undefined as unknown })); + +vi.mock('./daemon-client-lifecycle.ts', async (importOriginal) => ({ + ...(await importOriginal()), + ensureManagedDaemon: async (settings: unknown) => { + ensured.settings = settings; + throw new Error('stop before talking to a daemon'); + }, + observeLocalDaemon: async (settings: unknown) => { + ensured.settings = settings; + throw new Error('stop before talking to a daemon'); + }, +})); + +import { runCli } from '../cli.ts'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; +import { createDaemonTransport } from './daemon-client.ts'; +import type { DaemonClientSettings } from './daemon-client-lifecycle.ts'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const request = { session: 'default', command: 'devices', positionals: [] }; + +test('a transport resolves its daemon from the host it was built for, not the process', async () => { + vi.stubEnv('AGENT_DEVICE_STATE_DIR', '/process/state'); + const host = { + env: { AGENT_DEVICE_STATE_DIR: 'client-state', AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }, + cwd: '/client/workspace', + }; + + await expect(createDaemonTransport(host)(request)).rejects.toThrow('stop before talking'); + + const settings = ensured.settings as DaemonClientSettings; + expect(settings.host).toBe(host); + expect(settings.paths.baseDir).toBe(path.resolve('/client/workspace', 'client-state')); + expect(settings.serverMode).toBe('http'); +}); + +test('a client reports and uses the same state dir when its host and the process disagree', async () => { + vi.stubEnv('AGENT_DEVICE_STATE_DIR', '/process/state'); + const host = { env: { AGENT_DEVICE_STATE_DIR: '/client/state' }, cwd: '/client/workspace' }; + const client = createAgentDeviceClient({}, { host }); + + await expect(client.sessions.stateDir()).resolves.toBe('/client/state'); + await expect(client.devices.list()).rejects.toThrow(); + + expect((ensured.settings as DaemonClientSettings).paths.baseDir).toBe('/client/state'); +}); + +test('a CLI run builds its transport from the host it was given', async () => { + const host = { env: { AGENT_DEVICE_STATE_DIR: '/cli/state' }, cwd: '/cli/workspace' }; + vi.spyOn(process.stdout, 'write').mockReturnValue(true); + const transportForHost = vi.fn(() => async () => ({ ok: true as const, data: { devices: [] } })); + + await runCli(['devices', '--json'], { sendToDaemon: vi.fn(), transportForHost }, host); + + expect(transportForHost).toHaveBeenCalledWith(host); +}); + +test('a CLI run with the default transport resolves its daemon from the given host', async () => { + vi.stubEnv('AGENT_DEVICE_STATE_DIR', '/process/state'); + const host = { env: { AGENT_DEVICE_STATE_DIR: '/cli/state' }, cwd: '/cli/workspace' }; + vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('exit'); + }) as never); + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + vi.spyOn(process.stdout, 'write').mockReturnValue(true); + + await runCli(['devices', '--json'], undefined, host).catch(() => undefined); + + expect((ensured.settings as DaemonClientSettings).paths.baseDir).toBe('/cli/state'); +}); diff --git a/src/daemon-client/daemon-client-lease-beat.ts b/src/daemon-client/daemon-client-lease-beat.ts index 35ea71afbb..3ef65e2cd5 100644 --- a/src/daemon-client/daemon-client-lease-beat.ts +++ b/src/daemon-client/daemon-client-lease-beat.ts @@ -325,6 +325,6 @@ export function buildUploadLeaseHeartbeat( // The beat's own budget governs; the command's heartbeat policy only ever caps it, and an // unbounded policy leaves the budget standing on its own. policyTimeoutMs === undefined ? budgetMs : Math.min(policyTimeoutMs, budgetMs), - { signal }, + { host: settings.host, signal }, ); } diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index 9ff93a8891..47ccda9d53 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -13,6 +13,7 @@ import net from 'node:net'; import { AppError, normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; import { readReplayDivergenceResume } from '@agent-device/ad-replay/divergence'; import { type ExecDetachedExit } from '@agent-device/host-kit/command'; +import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { isProcessAlive } from '@agent-device/host-kit/process'; import { sleep } from '@agent-device/host-kit/retry'; @@ -58,6 +59,8 @@ import { } from './daemon-client-transport.ts'; export type DaemonClientSettings = { + /** The environment and working directory this client reads, for every setting below and the launch. */ + host: HostEnvironment; paths: DaemonPaths; daemonLifecycle: DaemonLifecycle; /** @@ -97,12 +100,13 @@ LOOPBACK_BLOCK_LIST.addSubnet('::ffff:127.0.0.0', 104, 'ipv6'); export function resolveClientSettings( req: Omit, - suppliedAuthToken?: string, + suppliedAuthToken: string | undefined, + host: HostEnvironment, daemonLifecycle: DaemonLifecycle = 'managed', ): DaemonClientSettings { - const explicitStateDir = resolveExplicitStateDir(req); - const remote = resolveRemoteClientSettings(req, suppliedAuthToken); - const transport = resolveTransportClientSettings(req.flags, remote.remoteBaseUrl); + const explicitStateDir = resolveExplicitStateDir(req, host.env); + const remote = resolveRemoteClientSettings(req, suppliedAuthToken, host.env); + const transport = resolveTransportClientSettings(req.flags, remote.remoteBaseUrl, host.env); // An attach client owns no daemon, and a private state directory would hold one nobody started. const ownedStateDir = daemonLifecycle !== 'attach' && @@ -110,7 +114,10 @@ export function resolveClientSettings( ? createOwnedReplayStateDir() : undefined; return { - paths: ownedStateDir?.paths ?? resolveDaemonPaths(explicitStateDir), + host, + paths: + ownedStateDir?.paths ?? + resolveDaemonPaths(explicitStateDir, { env: host.env, cwd: host.cwd }), daemonLifecycle, retireIncompatibleContender: true, transportPreference: transport.preference, @@ -128,9 +135,10 @@ export function resolveClientSettings( */ export function resolveEnsurableDaemon( req: Omit, + host: HostEnvironment, ): EnsureDaemonOptions | undefined { - const stateDir = resolveExplicitStateDir(req); - const rawBaseUrl = req.flags?.daemonBaseUrl ?? process.env.AGENT_DEVICE_DAEMON_BASE_URL; + const stateDir = resolveExplicitStateDir(req, host.env); + const rawBaseUrl = req.flags?.daemonBaseUrl ?? host.env.AGENT_DEVICE_DAEMON_BASE_URL; if (rawBaseUrl || shouldUseOwnedReplayStateDir(req, stateDir, rawBaseUrl)) return undefined; return { ...(stateDir !== undefined ? { stateDir } : {}), @@ -147,10 +155,17 @@ export function resolveEnsurableDaemon( * The settings of a local daemon with no request behind them, for the caller that ensures one. It * reads no remote base URL: a remote connection has no local daemon to ensure. */ -export function resolveLocalDaemonSettings(options: EnsureDaemonOptions): DaemonClientSettings { - const transport = resolveTransportClientSettings(options, undefined); +export function resolveLocalDaemonSettings( + options: EnsureDaemonOptions, + host: HostEnvironment, +): DaemonClientSettings { + const transport = resolveTransportClientSettings(options, undefined, host.env); return { - paths: resolveDaemonPaths(options.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR), + host, + paths: resolveDaemonPaths(options.stateDir ?? host.env.AGENT_DEVICE_STATE_DIR, { + env: host.env, + cwd: host.cwd, + }), daemonLifecycle: 'managed', retireIncompatibleContender: false, transportPreference: transport.preference, @@ -158,21 +173,25 @@ export function resolveLocalDaemonSettings(options: EnsureDaemonOptions): Daemon }; } -function resolveExplicitStateDir(req: Omit): string | undefined { - return req.flags?.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR; +function resolveExplicitStateDir( + req: Omit, + env: Readonly, +): string | undefined { + return req.flags?.stateDir ?? env.AGENT_DEVICE_STATE_DIR; } function resolveRemoteClientSettings( req: Omit, suppliedAuthToken: string | undefined, + env: Readonly, ): { rawBaseUrl: string | undefined; remoteBaseUrl?: string; authToken?: string; } { - const rawBaseUrl = req.flags?.daemonBaseUrl ?? process.env.AGENT_DEVICE_DAEMON_BASE_URL; + const rawBaseUrl = req.flags?.daemonBaseUrl ?? env.AGENT_DEVICE_DAEMON_BASE_URL; const remoteBaseUrl = resolveRemoteDaemonBaseUrl(rawBaseUrl); - const authToken = suppliedAuthToken ?? process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; + const authToken = suppliedAuthToken ?? env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; validateRemoteDaemonTrust(remoteBaseUrl, authToken); return { rawBaseUrl, remoteBaseUrl, authToken }; } @@ -180,8 +199,9 @@ function resolveRemoteClientSettings( function resolveTransportClientSettings( flags: Partial> | undefined, remoteBaseUrl: string | undefined, + env: Readonly, ): { preference: DaemonTransportPreference; serverMode: DaemonServerMode } { - const rawTransport = flags?.daemonTransport ?? process.env.AGENT_DEVICE_DAEMON_TRANSPORT; + const rawTransport = flags?.daemonTransport ?? env.AGENT_DEVICE_DAEMON_TRANSPORT; const preference = resolveDaemonTransportPreference(rawTransport); if (remoteBaseUrl && preference === 'socket') { throw new AppError( @@ -192,7 +212,7 @@ function resolveTransportClientSettings( } const rawServerMode = flags?.daemonServerMode ?? - process.env.AGENT_DEVICE_DAEMON_SERVER_MODE ?? + env.AGENT_DEVICE_DAEMON_SERVER_MODE ?? (rawTransport === 'dual' ? 'dual' : undefined); return { preference, @@ -215,7 +235,7 @@ export async function ensureManagedDaemon(settings: DaemonClientSettings): Promi const ensured = await ensureLocalDaemon(settings); // Checked on both branches: a startup can resolve to a daemon another caller raced in. - await assertDaemonPolicyMatches(ensured.info, settings.paths.baseDir); + await assertDaemonPolicyMatches(ensured.info, settings.paths.baseDir, settings.host); return ensured; } @@ -416,10 +436,12 @@ async function canReachReusableDaemon( export async function assertDaemonPolicyMatches( existing: DaemonInfo, stateDir: string, + host: HostEnvironment, ): Promise { - if (!process.env.AGENT_DEVICE_DAEMON_POLICY?.trim()) return; + const { env } = host; + if (!env.AGENT_DEVICE_DAEMON_POLICY?.trim()) return; const { loadDaemonPolicy } = await import('@agent-device/daemon-contracts/daemon-policy-file'); - const expected = loadDaemonPolicy(process.env)?.digest; + const expected = loadDaemonPolicy(env, host.cwd)?.digest; if (expected === existing.policyDigest) return; throw new AppError( 'COMMAND_FAILED', @@ -927,6 +949,8 @@ function startDaemon(settings: DaemonClientSettings): DaemonStartupLaunch { return launchDaemonProcess({ paths: settings.paths, serverMode: settings.serverMode, + env: settings.host.env, + cwd: settings.host.cwd, ownedStateDir: settings.ownedStateDir, args: launchSpec.useSrc ? ['--experimental-strip-types', launchSpec.srcPath] diff --git a/src/daemon-client/daemon-client-metadata.ts b/src/daemon-client/daemon-client-metadata.ts index 882d7da57c..8713947d7a 100644 --- a/src/daemon-client/daemon-client-metadata.ts +++ b/src/daemon-client/daemon-client-metadata.ts @@ -6,7 +6,6 @@ import { readDaemonCapabilities, readDaemonRecord, readRecordedDaemonTransport, - resolveDaemonPaths, type DaemonCapability, type DaemonPaths, type DaemonServerMode, @@ -99,9 +98,7 @@ export function isRemoteDaemon(info: DaemonInfo): boolean { export function resolveDaemonStartupHint( state: { hasInfo: boolean; hasLock: boolean }, - paths: Pick = resolveDaemonPaths( - process.env.AGENT_DEVICE_STATE_DIR, - ), + paths: Pick, ): string { const artifacts = [state.hasInfo ? paths.infoPath : null, state.hasLock ? paths.lockPath : null] .filter(Boolean) diff --git a/src/daemon-client/daemon-client-quiesce.ts b/src/daemon-client/daemon-client-quiesce.ts index 7f35e3eb33..6a65992624 100644 --- a/src/daemon-client/daemon-client-quiesce.ts +++ b/src/daemon-client/daemon-client-quiesce.ts @@ -49,7 +49,7 @@ export async function requestDaemonQuiesce( 'auto', settings.paths, QUIESCE_TIMEOUT_MS, - { timeoutRecovery: 'none' }, + { host: settings.host, timeoutRecovery: 'none' }, ); } catch (error) { if (isDaemonTransportUnavailableError(error) || isConnectionFailure(error)) return 'gone'; diff --git a/src/daemon-client/daemon-client-rpc.test.ts b/src/daemon-client/daemon-client-rpc.test.ts index 7989e9daf8..2a8ec92267 100644 --- a/src/daemon-client/daemon-client-rpc.test.ts +++ b/src/daemon-client/daemon-client-rpc.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; @@ -161,6 +162,7 @@ test('HTTP RPC errors sanitize an untrusted cause before rehydration', () => { }, }), { + host: createTestHostEnvironment(), info: { token: 'daemon-token', pid: 1 }, req: { command: 'apps_install_source', diff --git a/src/daemon-client/daemon-client-rpc.ts b/src/daemon-client/daemon-client-rpc.ts index faa004b4cd..afe33b6201 100644 --- a/src/daemon-client/daemon-client-rpc.ts +++ b/src/daemon-client/daemon-client-rpc.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { @@ -24,11 +25,12 @@ export function handleDaemonHttpResponseBody( info: DaemonInfo; req: DaemonWireRequest; stateDir: string; + host: HostEnvironment; resolve: (response: DaemonResponse | PromiseLike) => void; reject: (error: unknown) => void; }, ): void { - const { info, req, stateDir, resolve, reject } = options; + const { info, req, stateDir, host, resolve, reject } = options; try { const parsed = parseDaemonHttpResponseBody(body); if (parsed.error) { @@ -43,7 +45,7 @@ export function handleDaemonHttpResponseBody( ); return; } - void resolveDaemonHttpResult(info, req, parsed.result, resolve, reject); + void resolveDaemonHttpResult(info, req, parsed.result, host, resolve, reject); } catch (err) { reject( new AppError( @@ -154,12 +156,15 @@ async function resolveDaemonHttpResult( info: DaemonInfo, req: DaemonWireRequest, result: DaemonResponse, + host: HostEnvironment, resolve: (response: DaemonResponse | PromiseLike) => void, reject: (error: unknown) => void, ): Promise { try { resolve( - info.baseUrl && result.ok ? await materializeRemoteArtifacts(info, req, result) : result, + info.baseUrl && result.ok + ? await materializeRemoteArtifacts(info, req, result, host) + : result, ); } catch (error) { reject(error); diff --git a/src/daemon-client/daemon-client-transport.ts b/src/daemon-client/daemon-client-transport.ts index a9a879287f..490647a700 100644 --- a/src/daemon-client/daemon-client-transport.ts +++ b/src/daemon-client/daemon-client-transport.ts @@ -25,10 +25,13 @@ import { import { buildHttpRpcPayload, handleDaemonHttpResponseBody } from './daemon-client-rpc.ts'; import { handleRequestTimeout } from './daemon-client-timeout.ts'; import { isRemoteDaemon, type DaemonInfo } from './daemon-client-metadata.ts'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { readVersion } from '@agent-device/host-kit/version'; type ResolvedDaemonTransport = 'socket' | 'http'; type SendRequestOptions = { + /** The host whose working directory and environment this request's client-side work reads. */ + host: HostEnvironment; onProgress?: RequestProgressSink; /** * The caller's per-request cancellation (#3178). Aborted at or before a send attempt, nothing @@ -378,7 +381,7 @@ export async function sendRequest( preference: DaemonTransportPreference, statePaths: DaemonPaths, timeoutMs: number | undefined, - options: SendRequestOptions = {}, + options: SendRequestOptions, ): Promise { try { return await sendRequestWithFallback(info, req, preference, statePaths, timeoutMs, options); @@ -402,7 +405,7 @@ async function sendRequestWithFallback( preference: DaemonTransportPreference, statePaths: DaemonPaths, timeoutMs: number | undefined, - options: SendRequestOptions = {}, + options: SendRequestOptions, ): Promise { const transport = chooseTransport(info, preference); const deadline = typeof timeoutMs === 'number' ? performance.now() + timeoutMs : undefined; @@ -777,6 +780,7 @@ async function sendHttpRequest( info, req, stateDir: statePaths.baseDir, + host: options.host, resolve: resolveOnce, reject: rejectOnce, }); diff --git a/src/daemon-client/daemon-client.ts b/src/daemon-client/daemon-client.ts index c65f528c0b..22d4a5b7a7 100644 --- a/src/daemon-client/daemon-client.ts +++ b/src/daemon-client/daemon-client.ts @@ -3,6 +3,8 @@ import type { RequestProgressSink } from '@agent-device/contracts/progress'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; import type { AgentDeviceDaemonTransportContext } from '@agent-device/contracts/client'; import { AppError } from '@agent-device/kernel/errors'; +import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { createRequestId, emitDiagnostic, @@ -27,9 +29,40 @@ type DaemonTransportOptions = AgentDeviceDaemonTransportContext & { onProgress?: RequestProgressSink; }; +/** The released default: a process-level client, which reads the process's own host. */ export async function sendToDaemon( req: Omit, options: DaemonTransportOptions = {}, +): Promise { + return await sendToDaemonOnHost(req, options, captureHostEnvironment(process)); +} + +/** + * A transport whose daemon resolution, spawn, policy check and request preparation read `host`. + * Remote artifact upload still reads the process (`packages/remote`, tracked by #3751). + */ +export function createDaemonTransport(host: HostEnvironment) { + return async ( + req: Omit, + options: DaemonTransportOptions = {}, + ): Promise => await sendToDaemonOnHost(req, options, host); +} + +/** The transport one run uses: host-bound when its deps can build one, else the injected one. */ +export function resolveRunTransport( + deps: { + sendToDaemon: typeof sendToDaemon; + transportForHost?: (host: HostEnvironment) => typeof sendToDaemon; + }, + host: HostEnvironment, +): typeof sendToDaemon { + return deps.transportForHost?.(host) ?? deps.sendToDaemon; +} + +async function sendToDaemonOnHost( + req: Omit, + options: DaemonTransportOptions, + host: HostEnvironment, ): Promise { const { resolveClientSettings, attachSessionAddressHints } = await import('./daemon-client-lifecycle.ts'); @@ -47,6 +80,7 @@ export async function sendToDaemon( const settings = resolveClientSettings( requestWithoutAuthFlag, options.authToken ?? flagAuthToken, + host, options.daemonLifecycle, ); const requestTimeoutMs = resolveCommandRequestTimeoutMs( @@ -77,7 +111,7 @@ export async function sendToDaemon( options.signal, ), ); - writeInstallInProgressNotice(requestWithoutAuthFlag.command); + writeInstallInProgressNotice(requestWithoutAuthFlag.command, host.env); const request = buildTransportRequest( requestWithoutAuthFlag, @@ -85,7 +119,8 @@ export async function sendToDaemon( info, requestId, debug, - await readLocalProviderCredentialFingerprint(requestWithoutAuthFlag, info), + await readLocalProviderCredentialFingerprint(requestWithoutAuthFlag, info, host.env), + host.env, ); emitDiagnostic({ level: 'info', @@ -111,6 +146,7 @@ export async function sendToDaemon( settings.paths, requestTimeoutMs, { + host, onProgress: options.onProgress, signal: options.signal, timeoutRecovery: @@ -127,6 +163,7 @@ export async function sendToDaemon( /** * The transport the CLI and MCP use: `sendToDaemon` behind an explicit `ensureDaemon`. The wrapper * loads on the first request, so a CLI start that never reaches the daemon does not pay for it. + * With a `host`, ensuring and every request read it; without one, each reads the process. */ export function createEnsuredDaemonTransport( options: EnsuringTransportOptions = {}, @@ -136,7 +173,7 @@ export function createEnsuredDaemonTransport( let ensured: Promise | undefined; return async (req, context) => { ensured ??= import('./daemon-client-ensuring.ts').then(({ withEnsuredDaemon }) => - withEnsuredDaemon(sendToDaemon, options), + withEnsuredDaemon(options.host ? createDaemonTransport(options.host) : sendToDaemon, options), ); return await ( await ensured @@ -151,6 +188,7 @@ function buildTransportRequest( requestId: string, debug: boolean, providerCredentialFingerprint: string | undefined, + env: Readonly, ): DaemonRequest { return { ...request, @@ -159,7 +197,7 @@ function buildTransportRequest( token: info.token, meta: { ...buildTransportRequestMeta(request, preparedRemoteRequest, requestId, debug), - ...buildLocalHostEnvMeta(info), + ...buildLocalHostEnvMeta(info, env), providerCredentialFingerprint, }, }; @@ -170,6 +208,7 @@ function buildTransportRequest( async function readLocalProviderCredentialFingerprint( request: Omit, info: DaemonInfo, + env: Readonly, ): Promise { if (isRemoteDaemon(info) || request.command !== 'lease_allocate') return undefined; const { leaseProvider: provider, leaseBackend } = leaseScopeFromRequest(request); @@ -180,7 +219,7 @@ async function readLocalProviderCredentialFingerprint( ]); return providerCredentialFingerprint( provider, - process.env, + env, leaseBackend, readPluginInventory(info.plugins), ); @@ -189,8 +228,9 @@ async function readLocalProviderCredentialFingerprint( // A developer dir is a path on the client's host, so only a local daemon can use it. function buildLocalHostEnvMeta( info: DaemonInfo, + env: Readonly, ): Pick, 'developerDir'> { - const developerDir = process.env.DEVELOPER_DIR; + const developerDir = env.DEVELOPER_DIR; if (isRemoteDaemon(info)) return { developerDir: undefined }; return developerDir !== undefined ? { developerDir } : {}; } @@ -298,8 +338,8 @@ async function performDaemonRequestWithCleanup( return finalResponse; } -function writeInstallInProgressNotice(command: string | undefined): void { - if (!isInstallLikeCommand(command) || process.stderr.isTTY !== true || process.env.CI) return; +function writeInstallInProgressNotice(command: string | undefined, env: Readonly): void { + if (!isInstallLikeCommand(command) || process.stderr.isTTY !== true || env.CI) return; process.stderr.write( command === PUBLIC_COMMANDS.reinstall ? 'Reinstalling...\n' : 'Installing...\n', ); @@ -334,6 +374,7 @@ async function protectArtifactUploadWithLeaseBeats( request, info, callerSignal ?? new AbortController().signal, + settings.host, ); } const { buildUploadLeaseHeartbeat, runProtectedLeaseWork } = @@ -347,6 +388,7 @@ async function protectArtifactUploadWithLeaseBeats( request, info, callerSignal ? AbortSignal.any([leaseSignal, callerSignal]) : leaseSignal, + settings.host, ), callerSignal, }); diff --git a/src/daemon-composition-credentials.test.ts b/src/daemon-composition-credentials.test.ts index 79ec9fc25c..cb85ce356c 100644 --- a/src/daemon-composition-credentials.test.ts +++ b/src/daemon-composition-credentials.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import { expect, test, vi } from 'vitest'; import { startDaemonRuntime } from '@agent-device/daemon'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { createDaemonRuntimeDeps } from './daemon-composition.ts'; import { providerCredentialFingerprint } from './provider-credential-fingerprint.ts'; import { builtinPluginInventory } from './__tests__/test-utils/builtin-plugin-inventory.ts'; @@ -54,13 +54,15 @@ test('daemon startup compares lease credentials with its own startup environment const inventory = await builtinPluginInventory(); const daemonEnv = { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key-1' }; const runtime = await startDaemonRuntime({ - env: { - ...process.env, - ...daemonEnv, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + ...daemonEnv, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, diff --git a/src/daemon-composition.test.ts b/src/daemon-composition.test.ts index f211c8ba6d..e7faee68aa 100644 --- a/src/daemon-composition.test.ts +++ b/src/daemon-composition.test.ts @@ -1,7 +1,11 @@ import { afterEach, expect, test, vi } from 'vitest'; import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import type { Interactor } from '@agent-device/contracts/interactor-types'; -import { IOS_SIMULATOR, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + IOS_SIMULATOR, + mkdtempForTestSync, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { createDaemonRuntimeDeps } from './daemon-composition.ts'; import { setActiveProviderDeviceRuntimes } from './provider-device-runtime.ts'; @@ -50,7 +54,7 @@ test('the composed interactor resolution resolves the interactor of the provider const baseDir = mkdtempForTestSync('agent-device-daemon-composition-interactor-'); const providerInteractor = { snapshot: async () => ({ nodes: [] }) } as unknown as Interactor; const deps = await createDaemonRuntimeDeps({ - env: { ...process.env, AGENT_DEVICE_STATE_DIR: baseDir }, + host: createTestHostEnvironment({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: baseDir } }), baseDir, sessionsDir: `${baseDir}/sessions`, assertShutdownAllowed: undefined, @@ -80,7 +84,9 @@ test('a composition step that fails after the plugins were set up closes them on await expect( createDaemonRuntimeDeps({ - env: { ...process.env, AGENT_DEVICE_STATE_DIR: baseDir }, + host: createTestHostEnvironment({ + env: { ...process.env, AGENT_DEVICE_STATE_DIR: baseDir }, + }), baseDir, sessionsDir: `${baseDir}/sessions`, assertShutdownAllowed: undefined, @@ -98,7 +104,9 @@ test('a composition step that fails after the plugins were set up closes them on test('a composition that succeeds leaves the plugins open for the daemon to close', async () => { const baseDir = mkdtempForTestSync('agent-device-daemon-composition-owned-'); const deps = await createDaemonRuntimeDeps({ - env: { ...process.env, AGENT_DEVICE_STATE_DIR: baseDir }, + host: createTestHostEnvironment({ + env: { ...process.env, AGENT_DEVICE_STATE_DIR: baseDir }, + }), baseDir, sessionsDir: `${baseDir}/sessions`, assertShutdownAllowed: undefined, diff --git a/src/daemon-composition.ts b/src/daemon-composition.ts index 4a5942ddfa..13cc987ce0 100644 --- a/src/daemon-composition.ts +++ b/src/daemon-composition.ts @@ -72,7 +72,7 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( input, ): Promise => { const { - env, + host: { env }, baseDir, sessionsDir, assertShutdownAllowed, @@ -85,13 +85,14 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( sessionsDir, resolveSessionDir, }); - const hostDiagnostics = createHostDiagnostics(); + const hostDiagnostics = createHostDiagnostics(env); const providerComposition = await createDaemonProviderRuntimeComposition(env); // The registry has set up every plugin. Until this function returns, nothing else owns its // cleanup, so any later failure closes it here; success transfers ownership to the daemon. try { const providerDeviceRuntimes = [...providerComposition.runtimes]; const deviceRuntimeGateway = createPlatformRuntimeGateway({ + env, assertShutdownAllowed, providerRuntimes: providerDeviceRuntimes, providerModules: providerComposition.platformModules, @@ -114,6 +115,7 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( }, }); const deviceInventoryGateways = await createPlatformDeviceInventoryGateways( + env, providerRuntimeProviders.deviceInventorySource, ); // Root composition of the platform-services port and the owner-scoped claim-recovery gateway. @@ -121,7 +123,7 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( // composition modules (and the readiness/open-target adapters only the services module pulls) // off `src/daemon.ts`'s eager closure; what each adapter itself loads stays as lazy as it was. const { createDaemonPlatformServices } = await import('./platform-runtime-daemon-services.ts'); - const { createClaimRecoveryGateway } = await import('./platform-runtime-claim-recovery.ts'); + const { createClaimRecoveryGatewayFor } = await import('./platform-runtime-claim-recovery.ts'); return Object.freeze({ ownedProcessRecords, @@ -135,7 +137,7 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( recoverableProviderIds: providerRuntimeProviders.recoverableProviderIds, deviceRuntimeGateway, platformResourceCleanup, - claimRecoveryGateway: createClaimRecoveryGateway, + claimRecoveryGateway: createClaimRecoveryGatewayFor(env), interactorResolution: Object.freeze({ resolve: getInteractor }), providerDeviceAdmission: Object.freeze({ isActive: (device: DeviceInfo) => isActiveProviderDevice(device), diff --git a/src/daemon-entrypoint.test.ts b/src/daemon-entrypoint.test.ts index 7f543ea4de..e28372b631 100644 --- a/src/daemon-entrypoint.test.ts +++ b/src/daemon-entrypoint.test.ts @@ -24,6 +24,7 @@ import { listenOnLoopback, mkdtempForTestSync, waitForHttpOk, + createTestHostEnvironment, } from '@agent-device/test-kit'; type DaemonInfoFile = { @@ -82,11 +83,13 @@ test('daemon runtime starts HTTP transport in-process and shuts down cleanly', a try { const runtime = await startDaemonRuntime({ composeDeps: createDaemonRuntimeDeps, - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), exit: (code) => { exitCode = code; }, @@ -127,11 +130,13 @@ test('daemon runtime publishes dual transport metadata', async () => { try { const runtime = await startDaemonRuntime({ composeDeps: createDaemonRuntimeDeps, - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: 'dual', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'dual', + }, + }), exit: (code) => { exitCode = code; }, @@ -178,14 +183,16 @@ test('daemon rejects unowned cloud artifacts over RPC', async () => { try { runtime = await startDaemonRuntime({ composeDeps: createDaemonRuntimeDeps, - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - BROWSERSTACK_USERNAME: 'user', - BROWSERSTACK_ACCESS_KEY: 'key', - BROWSERSTACK_SESSION_DETAILS_ENDPOINT: `http://127.0.0.1:${providerPort}/sessions`, - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + BROWSERSTACK_USERNAME: 'user', + BROWSERSTACK_ACCESS_KEY: 'key', + BROWSERSTACK_SESSION_DETAILS_ENDPOINT: `http://127.0.0.1:${providerPort}/sessions`, + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, @@ -283,10 +290,6 @@ test('daemon runtime records startup device-claim reconciliation in daemon.log', const claimsDir = mkdtempForTestSync('agent-device-daemon-prune-claims-'); const paths = resolveDaemonPaths(stateDir); const deviceKey = 'local:android:none:prune-log'; - // resolveDeviceClaimRoot reads process.env directly, not the env passed to - // startDaemonRuntime, so the store has to be redirected on the process. - const previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR; - process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; let exitCode: number | undefined; try { @@ -309,12 +312,14 @@ test('daemon runtime records startup device-claim reconciliation in daemon.log', const runtime = await startDaemonRuntime({ composeDeps: createDaemonRuntimeDeps, - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - AGENT_DEVICE_CLAIMS_DIR: claimsDir, - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + AGENT_DEVICE_CLAIMS_DIR: claimsDir, + }, + }), exit: (code) => { exitCode = code; }, @@ -333,8 +338,6 @@ test('daemon runtime records startup device-claim reconciliation in daemon.log', await runtime?.shutdown(); assert.equal(exitCode, 0); } finally { - if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; - else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; fs.rmSync(stateDir, { recursive: true, force: true }); fs.rmSync(claimsDir, { recursive: true, force: true }); } @@ -350,8 +353,6 @@ test('startup sweep settles a foreign dead owner without touching a live same-na const claimsDir = mkdtempForTestSync('agent-device-daemon-sweep-claims-'); const deviceId = 'sweep-scope-udid'; const deviceKey = `local:apple:ios:${deviceId}`; - const previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR; - process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; const sessionStoreFor = (stateDir: string) => { const paths = resolveDaemonPaths(stateDir); @@ -435,12 +436,14 @@ test('startup sweep settles a foreign dead owner without touching a live same-na runtime = await startDaemonRuntime({ composeDeps: createDaemonRuntimeDeps, - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: daemonStateDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - AGENT_DEVICE_CLAIMS_DIR: claimsDir, - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: daemonStateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + AGENT_DEVICE_CLAIMS_DIR: claimsDir, + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, @@ -475,8 +478,6 @@ test('startup sweep settles a foreign dead owner without touching a live same-na try { orphanRecorder.kill('SIGKILL'); } catch {} - if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; - else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; fs.rmSync(daemonStateDir, { recursive: true, force: true }); fs.rmSync(foreignStateDir, { recursive: true, force: true }); fs.rmSync(claimsDir, { recursive: true, force: true }); diff --git a/src/daemon-runtime-limrun-public-http.test.ts b/src/daemon-runtime-limrun-public-http.test.ts index 75e41091c2..9d44391629 100644 --- a/src/daemon-runtime-limrun-public-http.test.ts +++ b/src/daemon-runtime-limrun-public-http.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { test, vi } from 'vitest'; import { startDaemonRuntime } from '@agent-device/daemon'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; import { createLimrunRuntime } from '@agent-device/provider-limrun'; import { DAEMON_HTTP_NETWORK_ACCESS_HEADER, @@ -95,15 +95,17 @@ test('public HTTP rejects Limrun uploaded-app listing and allocation before prov try { runtime = await startDaemonRuntime({ composeDeps: createDaemonRuntimeDeps, - env: { - ...process.env, - AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', - AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - LIM_ANDROID_INSTANCE_URL: 'https://region.limrun.example/v1/android_test/api', - LIM_ANDROID_INSTANCE_TOKEN: 'lim-test-token', - LIM_ANDROID_INSTANCE_ADB_URL: 'https://region.limrun.example/v1/android_test/adb', - }, + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + LIM_ANDROID_INSTANCE_URL: 'https://region.limrun.example/v1/android_test/api', + LIM_ANDROID_INSTANCE_TOKEN: 'lim-test-token', + LIM_ANDROID_INSTANCE_ADB_URL: 'https://region.limrun.example/v1/android_test/adb', + }, + }), exit: () => {}, registerProcessHandlers: false, stderr: { write: () => {} }, diff --git a/src/daemon.ts b/src/daemon.ts index d6d1acafd8..264b1de596 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -1,10 +1,12 @@ import { asAppError } from '@agent-device/kernel/errors'; import { startDaemonRuntime } from '@agent-device/daemon'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; // The root's composition enters as a factory behind a function-scoped `await import`: the server // calls it before it acquires the registration, so startup order is unchanged, and the eager // closure of this entry stays exactly the operational server's (ADR 0019). void startDaemonRuntime({ + host: captureHostEnvironment(process), composeDeps: async (input) => (await import('./daemon-composition.ts')).createDaemonRuntimeDeps(input), }).catch((error) => { diff --git a/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts b/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts index cf5de9917d..71e6763420 100644 --- a/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts +++ b/src/mcp/__tests__/command-tools-daemon-lifecycle.test.ts @@ -14,6 +14,7 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ runCmdDetachedMonitored: vi.fn(), })); import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; +import * as hostProcess from '@agent-device/host-kit/process'; import * as ensureModule from '../../daemon-client/daemon-client-ensure.ts'; import { createCommandToolExecutor } from '../command-tools.ts'; @@ -35,6 +36,7 @@ test('each MCP tool call ensures the daemon, then attaches to it', async (t) => launchesFreshDaemonAt(spawn, paths, daemon.port); vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); const ensure = vi.spyOn(ensureModule, 'ensureDaemon'); + const capture = vi.spyOn(hostProcess, 'captureHostEnvironment'); const executor = createCommandToolExecutor(); try { @@ -44,6 +46,10 @@ test('each MCP tool call ensures the daemon, then attaches to it', async (t) => assert.equal(first.isError, false, JSON.stringify(first.structuredContent)); assert.equal(second.isError, false, JSON.stringify(second.structuredContent)); assert.equal(ensure.mock.calls.length, 2, 'one ensure per tool call'); + assert.equal(capture.mock.calls.length, 2, 'one host capture per tool call'); + // The host the ensuring transport used is the one the call captured, not a second capture. + assert.equal(ensure.mock.calls[0]?.[1], capture.mock.results[0]?.value); + assert.equal(ensure.mock.calls[1]?.[1], capture.mock.results[1]?.value); assert.equal(spawn.mock.calls.length, 1, 'the second call reuses the daemon the first started'); assert.deepEqual(daemon.rpcCommands, ['devices', 'devices']); } finally { diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts index cce69527d6..1850b324cb 100644 --- a/src/mcp/command-tools.ts +++ b/src/mcp/command-tools.ts @@ -249,9 +249,13 @@ async function createClient( if (deps.createClient) return await deps.createClient(config); const { createAgentDeviceClient } = await import('../agent-device-client.ts'); const { createEnsuredDaemonTransport } = await import('../daemon-client/daemon-client.ts'); + const { captureHostEnvironment } = await import('@agent-device/host-kit/process'); + // One host per tool call: the ensuring transport and the client resolve the same daemon from it. + const host = captureHostEnvironment(process); // One client per tool call, so each call ensures the daemon it is about to use. - const ensuring = createEnsuredDaemonTransport(); + const ensuring = createEnsuredDaemonTransport({ host }); return createAgentDeviceClient(config, { + host, // The client builds the same request the daemon transport takes; its type only differs in the // session being optional, which `sendToDaemon` defaults. transport: async (req, context) => diff --git a/src/platform-runtime-android-adb-binding.test.ts b/src/platform-runtime-android-adb-binding.test.ts index eb7190993f..6f1fd2c787 100644 --- a/src/platform-runtime-android-adb-binding.test.ts +++ b/src/platform-runtime-android-adb-binding.test.ts @@ -51,7 +51,7 @@ const device: DeviceInfo = { }; test('the composed gateway lists Android apps from inside the platform package', async () => { - const gateway = createPlatformRuntimeGateway(sessionArtifacts); + const gateway = createPlatformRuntimeGateway({ env: process.env, ...sessionArtifacts }); const binding = await gateway.bind({ device, intent: { kind: 'ordinary' }, scope }); await expect(binding.operations.listApps?.({ device, filter: 'all' })).resolves.toEqual([ @@ -76,7 +76,7 @@ test('the composed gateway lists Android apps from inside the platform package', }); test('the composed gateway reads Android app state from inside the platform package', async () => { - const gateway = createPlatformRuntimeGateway(sessionArtifacts); + const gateway = createPlatformRuntimeGateway({ env: process.env, ...sessionArtifacts }); const binding = await gateway.bind({ device, intent: { kind: 'ordinary' }, scope }); await expect(binding.operations.appState?.()).resolves.toEqual({ diff --git a/src/platform-runtime-android-deployment-executor.test.ts b/src/platform-runtime-android-deployment-executor.test.ts index ce97987b7f..221a2d5923 100644 --- a/src/platform-runtime-android-deployment-executor.test.ts +++ b/src/platform-runtime-android-deployment-executor.test.ts @@ -23,7 +23,7 @@ const device: DeviceInfo = { test('keeps root composition limited to artifact preparation and app identity resolution', async () => { resolveAndroidApp.mockResolvedValueOnce({ type: 'package', value: 'com.example.app' }); - const executor = createAndroidAppDeploymentExecutor(); + const executor = createAndroidAppDeploymentExecutor({}); await expect(executor.resolveAppPackage(device, 'Example')).resolves.toBe('com.example.app'); expect(resolveAndroidApp).toHaveBeenCalledWith(device, 'Example', undefined); @@ -42,6 +42,6 @@ test('rejects an intent where uninstall requires an exact Android package', asyn resolveAndroidApp.mockResolvedValueOnce({ type: 'intent', value: 'example.intent' }); await expect( - createAndroidAppDeploymentExecutor().resolveAppPackage(device, 'example.intent'), + createAndroidAppDeploymentExecutor({}).resolveAppPackage(device, 'example.intent'), ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); }); diff --git a/src/platform-runtime-android-deployment-executor.ts b/src/platform-runtime-android-deployment-executor.ts index 45420cd01a..87720ebe90 100644 --- a/src/platform-runtime-android-deployment-executor.ts +++ b/src/platform-runtime-android-deployment-executor.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { AndroidAppDeploymentExecutor, MaterializeAppSourceInput, @@ -5,9 +6,11 @@ import type { import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; /** Android deployment adapter; native command construction remains package-owned. */ -export function createAndroidAppDeploymentExecutor(): AndroidAppDeploymentExecutor { +export function createAndroidAppDeploymentExecutor( + env: Readonly, +): AndroidAppDeploymentExecutor { return Object.freeze({ - bundletoolJar: process.env.AGENT_DEVICE_BUNDLETOOL_JAR?.trim() || undefined, + bundletoolJar: env.AGENT_DEVICE_BUNDLETOOL_JAR?.trim() || undefined, withInvalidatedAppResolutionCache: async (device, operation) => { const { withAndroidAppResolutionCacheInvalidated } = await loadAndroidMechanics(); return await withAndroidAppResolutionCacheInvalidated(device, operation); diff --git a/src/platform-runtime-claim-recovery.ts b/src/platform-runtime-claim-recovery.ts index e63c5a6a33..6be124a168 100644 --- a/src/platform-runtime-claim-recovery.ts +++ b/src/platform-runtime-claim-recovery.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import { createOwnerScopedDeviceClaimReconciler, createRecoveryPlatformScope, @@ -19,12 +20,15 @@ import { createPlatformRuntimeGateway } from './platform-runtime.ts'; * claims exist only for local devices — and instance-scopes every stateful piece the gateway owns, * so disposal shuts down only this transaction's handles. */ -export const createClaimRecoveryGateway: ClaimRecoveryGatewayFactory = (input) => - createPlatformRuntimeGateway({ - sessionsDir: input.sessionsDir, - ownedProcesses: input.ownedProcesses, - resolveSessionArtifacts: input.resolveSessionArtifacts, - }); +export const createClaimRecoveryGatewayFor = + (env: EnvMap): ClaimRecoveryGatewayFactory => + (input) => + createPlatformRuntimeGateway({ + env, + sessionsDir: input.sessionsDir, + ownedProcesses: input.ownedProcesses, + resolveSessionArtifacts: input.resolveSessionArtifacts, + }); /** * Daemonless `device release --stale`: settles each provably dead owner @@ -35,13 +39,15 @@ export const createClaimRecoveryGateway: ClaimRecoveryGatewayFactory = (input) = */ export async function runStaleDeviceClaimRelease( selectors: DeviceClaimSelectors, + env: EnvMap, composeRecovery?: OwnerScopedClaimRecoveryComposer, ): Promise { return await releaseProvenStaleDeviceClaims({ selectors, + env, reconcile: createOwnerScopedDeviceClaimReconciler({ scope: createRecoveryPlatformScope(), - composeGateway: createClaimRecoveryGateway, + composeGateway: createClaimRecoveryGatewayFor(env), composeRecovery, }), }); diff --git a/src/platform-runtime-gateway-gesture-admission.test.ts b/src/platform-runtime-gateway-gesture-admission.test.ts index c0969b66f6..2ae0d5212c 100644 --- a/src/platform-runtime-gateway-gesture-admission.test.ts +++ b/src/platform-runtime-gateway-gesture-admission.test.ts @@ -1,8 +1,12 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { expect, test } from 'vitest'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import { createPlatformRuntimeGateway } from './platform-runtime.ts'; +bindTestCommandHost(); + const gateway = createPlatformRuntimeGateway({ + env: process.env, resolveSessionArtifacts: () => ({ outputPath: '/sessions/gesture-admission/app.log', pidPath: '/sessions/gesture-admission/app-log.pid', diff --git a/src/platform-runtime-gateway-recording.test.ts b/src/platform-runtime-gateway-recording.test.ts index 3e9387c8e4..b36c4a0bf9 100644 --- a/src/platform-runtime-gateway-recording.test.ts +++ b/src/platform-runtime-gateway-recording.test.ts @@ -20,6 +20,7 @@ test.each(unsupportedRecordingDevices)( async (device) => { const sessionsDir = mkdtempForTestSync('platform-runtime-recording-'); const gateway = createPlatformRuntimeGateway({ + env: process.env, sessionsDir, resolveSessionArtifacts: (sessionId) => ({ outputPath: path.join(sessionsDir, sessionId, 'app.log'), diff --git a/src/platform-runtime-host-diagnostics.test.ts b/src/platform-runtime-host-diagnostics.test.ts index 74f5e5b89b..5bd882e3c8 100644 --- a/src/platform-runtime-host-diagnostics.test.ts +++ b/src/platform-runtime-host-diagnostics.test.ts @@ -35,13 +35,15 @@ test('warmup hands an Apple device to the Apple runner warmup and returns its ch }; mockAppleRunnerWarmupCheck.mockResolvedValue(check); - const result = await createHostDiagnostics().warmupCheck(IOS_SIMULATOR, context); + const result = await createHostDiagnostics(process.env).warmupCheck(IOS_SIMULATOR, context); expect(result).toEqual(check); expect(mockAppleRunnerWarmupCheck).toHaveBeenCalledWith(IOS_SIMULATOR, context); }); test('warmup runs no probe for a non-Apple device', async () => { - expect(await createHostDiagnostics().warmupCheck(ANDROID_EMULATOR, context)).toBeUndefined(); + expect( + await createHostDiagnostics(process.env).warmupCheck(ANDROID_EMULATOR, context), + ).toBeUndefined(); expect(mockAppleRunnerWarmupCheck).not.toHaveBeenCalled(); }); diff --git a/src/platform-runtime-host-diagnostics.ts b/src/platform-runtime-host-diagnostics.ts index 35893c5874..799676cecb 100644 --- a/src/platform-runtime-host-diagnostics.ts +++ b/src/platform-runtime-host-diagnostics.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { DeviceInfo, PlatformSelector } from '@agent-device/kernel/device'; import type { DoctorCheck } from '@agent-device/contracts/observability'; import type { @@ -11,7 +12,7 @@ import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; * execution). Families load lazily: a probe module is imported only when its family's method * actually runs, matching the platform registry's implementation-laziness. */ -export function createHostDiagnostics(): HostDiagnostics { +export function createHostDiagnostics(env: Readonly): HostDiagnostics { return Object.freeze({ toolchainCheck: async ( platform: PlatformSelector | undefined, @@ -19,7 +20,7 @@ export function createHostDiagnostics(): HostDiagnostics { ): Promise => { if (platform === 'android') { const { androidToolchainCheck } = await loadAndroidMechanics(); - return await androidToolchainCheck(process.env, process.platform); + return await androidToolchainCheck(env, process.platform); } if (platform === 'vega') { const { vegaToolchainCheck } = await import('@agent-device/platform-vega'); diff --git a/src/platform-runtime-host.test.ts b/src/platform-runtime-host.test.ts index 5b92b9af6e..693e78f07c 100644 --- a/src/platform-runtime-host.test.ts +++ b/src/platform-runtime-host.test.ts @@ -25,7 +25,7 @@ vi.mock('@agent-device/platform-apple/tool-provider', () => { import { createDeviceInventoryHost } from './platform-runtime-host.ts'; test('inventory host loads scoped Apple mechanics only on first Apple tool use', async () => { - const host = createDeviceInventoryHost(); + const host = createDeviceInventoryHost(process.env); const signal = new AbortController().signal; expect(toolchains.appleEvaluations).toBe(0); diff --git a/src/platform-runtime-host.ts b/src/platform-runtime-host.ts index 4fd67d6dee..2df43a2d2b 100644 --- a/src/platform-runtime-host.ts +++ b/src/platform-runtime-host.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { DeviceInventoryHost, HostOperatingSystem, @@ -11,18 +12,19 @@ import os from 'node:os'; import path from 'node:path'; import { createAppleToolHost } from './platform-runtime-apple-tool-host.ts'; import { createHostToolchainPreparer } from './platform-runtime-toolchain-host.ts'; -import { runCmd, whichCmd } from '@agent-device/host-kit/command'; +import { runCmd, whichCmd, commandHostEnvironment } from '@agent-device/host-kit/command'; import { guardedHostCommandArgv } from './platform-runtime-host-device-shell.ts'; -export function createDeviceInventoryHost(): DeviceInventoryHost { +export function createDeviceInventoryHost(env: Readonly): DeviceInventoryHost { return Object.freeze({ commands: Object.freeze({ - which: async (executable: string) => ((await whichCmd(executable)) ? executable : undefined), + which: async (executable: string) => + (await whichCmd(executable, commandHostEnvironment().env)) ? executable : undefined, run: async (request: HostCommandRequest, signal?: AbortSignal) => { const result = await runCmd(request.executable, guardedHostCommandArgv(request), { allowFailure: request.allowFailure, cwd: request.cwd, - env: request.env ? { ...process.env, ...request.env } : undefined, + env: request.env ? { ...commandHostEnvironment().env, ...request.env } : undefined, signal, timeoutMs: request.timeoutMs, }); @@ -34,7 +36,7 @@ export function createDeviceInventoryHost(): DeviceInventoryHost { }, }), appleTools: createAppleToolHost(), - toolchains: createHostToolchainPreparer(), + toolchains: createHostToolchainPreparer(env), files: Object.freeze({ isExecutable: async (candidate: string) => await isExecutable(candidate), createTemporaryTextFile: async (options: { prefix: string; suffix: string }) => diff --git a/src/platform-runtime-operation-host.test.ts b/src/platform-runtime-operation-host.test.ts index 71b67aa164..79169e37a6 100644 --- a/src/platform-runtime-operation-host.test.ts +++ b/src/platform-runtime-operation-host.test.ts @@ -62,6 +62,7 @@ const shutdownLoaders = { test('operation host composes the shared lazy Apple-tool and toolchain capabilities', () => { const host = createPlatformRuntimeHost({ + env: process.env, sessionsDir: '/tmp/sessions', resolveSessionArtifacts: () => ({ outputPath: '/tmp/sessions/one/app.log', diff --git a/src/platform-runtime-operation-host.ts b/src/platform-runtime-operation-host.ts index 3fbb279478..59138275c0 100644 --- a/src/platform-runtime-operation-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { AppLogSessionArtifacts } from '@agent-device/contracts/app-log-runtime'; import type { DeviceShutdownRuntimeLoaders } from '@agent-device/contracts/device-shutdown-runtime'; import type { @@ -15,7 +16,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { setTimeout as sleep } from 'node:timers/promises'; import { createAppleToolHost } from './platform-runtime-apple-tool-host.ts'; import { createHostToolchainPreparer } from './platform-runtime-toolchain-host.ts'; -import { runCmd, whichCmd } from '@agent-device/host-kit/command'; +import { runCmd, whichCmd, commandHostEnvironment } from '@agent-device/host-kit/command'; import { guardedHostCommandArgv } from './platform-runtime-host-device-shell.ts'; import { openAppLogOutput, readAppLogOutputTail } from './platform-runtime-app-log-output.ts'; import { createManagedAppLogProcesses } from './platform-runtime-app-log-process.ts'; @@ -53,6 +54,7 @@ export async function loadMacOsSurfaceSnapshot( } export function createPlatformRuntimeHost(options: { + env: Readonly; sessionsDir: string; resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; shutdownLoaders: DeviceShutdownRuntimeLoaders; @@ -64,12 +66,13 @@ export function createPlatformRuntimeHost(options: { const localProcessTransport = Object.freeze({ mode: 'local' as const, start: processes.start }); const appleTools = createAppleToolHost(); const commands = Object.freeze({ - which: async (executable: string) => ((await whichCmd(executable)) ? executable : undefined), + which: async (executable: string) => + (await whichCmd(executable, commandHostEnvironment().env)) ? executable : undefined, run: async (request: HostCommandRequest, signal?: AbortSignal) => { const result = await runCmd(request.executable, guardedHostCommandArgv(request), { allowFailure: request.allowFailure, cwd: request.cwd, - env: request.env ? { ...process.env, ...request.env } : undefined, + env: request.env ? { ...commandHostEnvironment().env, ...request.env } : undefined, signal, timeoutMs: request.timeoutMs, }); @@ -91,7 +94,7 @@ export function createPlatformRuntimeHost(options: { return Object.freeze({ commands, appleTools, - toolchains: createHostToolchainPreparer(), + toolchains: createHostToolchainPreparer(options.env), artifacts: Object.freeze({ resolveSession: options.resolveSessionArtifacts }), outputs: Object.freeze({ openAppend: async (pathname: string) => await openAppLogOutput(options.sessionsDir, pathname), @@ -113,7 +116,7 @@ export function createPlatformRuntimeHost(options: { }), ...network, appleDeployment: createAppleAppDeploymentExecutor(), - androidDeployment: createAndroidAppDeploymentExecutor(), + androidDeployment: createAndroidAppDeploymentExecutor(options.env), androidTools: createAndroidToolHost(), temporaryFiles: Object.freeze({ create: async (temporaryFileOptions: { prefix: string; suffix: string }) => diff --git a/src/platform-runtime-perf-host.test.ts b/src/platform-runtime-perf-host.test.ts index 0415ba765b..7f75339e7e 100644 --- a/src/platform-runtime-perf-host.test.ts +++ b/src/platform-runtime-perf-host.test.ts @@ -47,6 +47,7 @@ test('the composed Android perf operation reports an unavailable ADB memory samp adb.calls.length = 0; const sessionsDir = mkdtempForTestSync('platform-runtime-perf-host-'); const gateway = createPlatformRuntimeGateway({ + env: process.env, sessionsDir, resolveSessionArtifacts: (sessionId) => ({ outputPath: `${sessionsDir}/${sessionId}/app.log`, diff --git a/src/platform-runtime-resource-cleanup.test.ts b/src/platform-runtime-resource-cleanup.test.ts index a991d58d35..90dfaed962 100644 --- a/src/platform-runtime-resource-cleanup.test.ts +++ b/src/platform-runtime-resource-cleanup.test.ts @@ -12,9 +12,12 @@ import { installFakeManagedAgentBrowser, mkdtempForTestSync, WEB_DESKTOP_DEVICE, + bindTestCommandHost, } from '@agent-device/test-kit'; import { platformResourceCleanup } from './platform-runtime-resource-cleanup.ts'; +bindTestCommandHost(); + const mockRunCmd = vi.mocked(runCmd); afterEach(() => { diff --git a/src/platform-runtime-toolchain-host.test.ts b/src/platform-runtime-toolchain-host.test.ts index bf2d9f77c1..a6b2ec4e1c 100644 --- a/src/platform-runtime-toolchain-host.test.ts +++ b/src/platform-runtime-toolchain-host.test.ts @@ -28,7 +28,7 @@ vi.mock('@agent-device/platform-harmonyos', () => { import { createHostToolchainPreparer } from './platform-runtime-toolchain-host.ts'; test('toolchain host loads and prepares only the selected family', async () => { - const host = createHostToolchainPreparer(); + const host = createHostToolchainPreparer(process.env); expect(toolchains).toEqual({ androidEvaluations: 0, diff --git a/src/platform-runtime-toolchain-host.ts b/src/platform-runtime-toolchain-host.ts index 85c4d70520..011b44a646 100644 --- a/src/platform-runtime-toolchain-host.ts +++ b/src/platform-runtime-toolchain-host.ts @@ -1,12 +1,13 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { HostToolchainPreparer } from '@agent-device/contracts/platform-runtime-host'; import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; -export function createHostToolchainPreparer(): HostToolchainPreparer { +export function createHostToolchainPreparer(env: Readonly): HostToolchainPreparer { return Object.freeze({ prepare: async (family) => { if (family === 'android') { const { ensureAndroidSdkPathConfigured } = await loadAndroidMechanics(); - await ensureAndroidSdkPathConfigured(process.env); + await ensureAndroidSdkPathConfigured(env); return; } if (family === 'harmonyos') { diff --git a/src/platform-runtime.test.ts b/src/platform-runtime.test.ts index 373be8b3a8..bbeb6075d3 100644 --- a/src/platform-runtime.test.ts +++ b/src/platform-runtime.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { PLATFORMS } from '@agent-device/kernel/device'; import { describe, expect, test, vi } from 'vitest'; @@ -32,9 +33,13 @@ vi.mock('./platform-runtime-host.ts', () => { import { createPlatformDeviceInventoryGateways, - platformModuleRegistry, + createPlatformModuleRegistryForHost, } from './platform-runtime.ts'; +bindTestCommandHost(); + +const platformModuleRegistry = createPlatformModuleRegistryForHost({}); + describe('platform module metadata composition', () => { test('registers each canonical family exactly once in canonical order', () => { expect(platformModuleRegistry.families).toEqual(PLATFORMS); @@ -61,7 +66,7 @@ describe('platform module metadata composition', () => { diagnostics: { emit: () => {} }, progress: { report: () => {} }, } as const; - const providerGateways = await createPlatformDeviceInventoryGateways({ + const providerGateways = await createPlatformDeviceInventoryGateways(process.env, { discover: async () => ({ kind: 'inventory', devices: [ @@ -81,7 +86,7 @@ describe('platform module metadata composition', () => { ); expect(hostAdapter.evaluations).toBe(0); - const localGateways = await createPlatformDeviceInventoryGateways(); + const localGateways = await createPlatformDeviceInventoryGateways(process.env); expect(await localGateways.localOnly.discover({ platform: 'web' }, scope)).toHaveLength(1); expect(hostAdapter.evaluations).toBe(0); diff --git a/src/platform-runtime.ts b/src/platform-runtime.ts index d58deb05a0..7eb2d03ed0 100644 --- a/src/platform-runtime.ts +++ b/src/platform-runtime.ts @@ -70,10 +70,6 @@ export async function getAndroidAppStateWithAdb( return await readAndroidAppStateWithExecutor(adb, signal); } -const androidInventoryModule = createAndroidInventoryModule({ - sdkRoots: configuredValues(process.env.ANDROID_SDK_ROOT, process.env.ANDROID_HOME), -}); - /** One root-composed Android observer shared by every daemon request. */ export const androidObservation = createPackageAndroidObservationAdapter( createAndroidObservationHost(), @@ -86,29 +82,34 @@ const shutdownLoaders = Object.freeze({ await loadAndroidShutdownRuntime(dependencies), }); -const harmonyosInventoryModule = createHarmonyInventoryModule({ - hdcSdkPath: process.env.HDC_SDK_PATH, - devecoSdkHome: process.env.DEVECO_SDK_HOME, - commandLineToolsHome: process.env.HARMONYOS_COMMAND_LINE_TOOLS, -}); - -export const platformModuleRegistry = createPlatformModuleRegistry([ - appleInventoryModule, - androidInventoryModule, - harmonyosInventoryModule, - vegaInventoryModule, - linuxInventoryModule, - webInventoryModule, -]); +export function createPlatformModuleRegistryForHost( + env: Readonly>, +) { + return createPlatformModuleRegistry([ + appleInventoryModule, + createAndroidInventoryModule({ + sdkRoots: configuredValues(env.ANDROID_SDK_ROOT, env.ANDROID_HOME), + }), + createHarmonyInventoryModule({ + hdcSdkPath: env.HDC_SDK_PATH, + devecoSdkHome: env.DEVECO_SDK_HOME, + commandLineToolsHome: env.HARMONYOS_COMMAND_LINE_TOOLS, + }), + vegaInventoryModule, + linuxInventoryModule, + webInventoryModule, + ]); +} export async function createPlatformDeviceInventoryGateways( + env: Readonly>, provider?: ProviderDeviceInventorySource, ): Promise { return await attachAppleInstalledAppProbe({ - registry: platformModuleRegistry, + registry: createPlatformModuleRegistryForHost(env), loadHost: async () => { const { createDeviceInventoryHost } = await import('./platform-runtime-host.ts'); - return createDeviceInventoryHost(); + return createDeviceInventoryHost(env); }, provider, }); @@ -121,8 +122,8 @@ const androidRuntimeModule = createAndroidRuntimeModule({ }, }); -/** The root composition registry shared by the gateway and bounded host-contract fixtures. */ -export const platformRuntimeModules: ReadonlyMap = new Map< +/** The root composition registry the platform runtime gateway reads. */ +const platformRuntimeModules: ReadonlyMap = new Map< Platform, PlatformRuntimeModule >([ @@ -138,6 +139,7 @@ type Platform = PlatformRuntimeModule['family']; export function createPlatformRuntimeGateway( options: Readonly<{ + env: Readonly>; providerRuntimes?: readonly ProviderDeviceRuntime[]; providerModules?: readonly PlatformRuntimeProviderRegistration[]; resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; @@ -152,6 +154,7 @@ export function createPlatformRuntimeGateway( const { createPlatformRuntimeHost, createSnapshotRuntimeHost, loadMacOsSurfaceSnapshot } = await import('./platform-runtime-operation-host.ts'); return createPlatformRuntimeHost({ + env: options.env, sessionsDir: options.sessionsDir, resolveSessionArtifacts: options.resolveSessionArtifacts, shutdownLoaders, diff --git a/src/sdk/remote-config.test.ts b/src/sdk/remote-config.test.ts new file mode 100644 index 0000000000..0d10d35e3c --- /dev/null +++ b/src/sdk/remote-config.test.ts @@ -0,0 +1,24 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { resolveRemoteConfigProfile } from './remote-config.ts'; + +test('the released remote-config entry reads the process environment when none is given', () => { + const root = mkdtempForTestSync('agent-device-sdk-remote-config-'); + const previous = process.env.AGENT_DEVICE_METRO_PREPARE_PORT; + try { + const configPath = path.join(root, 'demo.remote.json'); + fs.writeFileSync(configPath, JSON.stringify({ platform: 'ios' }), 'utf8'); + process.env.AGENT_DEVICE_METRO_PREPARE_PORT = '9191'; + + const resolved = resolveRemoteConfigProfile({ configPath, cwd: root }); + + assert.equal(resolved.profile.metroPreparePort, 9191); + } finally { + if (previous === undefined) delete process.env.AGENT_DEVICE_METRO_PREPARE_PORT; + else process.env.AGENT_DEVICE_METRO_PREPARE_PORT = previous; + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/sdk/remote-config.ts b/src/sdk/remote-config.ts index 315e44a2a2..29097df6c6 100644 --- a/src/sdk/remote-config.ts +++ b/src/sdk/remote-config.ts @@ -1,2 +1,14 @@ +import { resolveRemoteConfigProfile as resolveRemoteConfigProfileOnHost } from '@agent-device/remote/remote-config'; +import type { + RemoteConfigProfileOptions, + ResolvedRemoteConfigProfile, +} from '@agent-device/remote/remote-config-schema'; + export type { RemoteConfigProfile } from '@agent-device/remote/remote-config-schema'; -export { resolveRemoteConfigProfile } from '@agent-device/remote/remote-config'; + +/** The released entry: without `env`, the profile resolves against the process's environment. */ +export function resolveRemoteConfigProfile( + options: RemoteConfigProfileOptions, +): ResolvedRemoteConfigProfile { + return resolveRemoteConfigProfileOnHost({ ...options, env: options.env ?? process.env }); +} diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index fd26d9a11a..cfbad57b7e 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -34,6 +34,7 @@ import { } from '../../../src/platform-runtime.ts'; import type { AppleSimulatorScreenRecordingProcess } from '../../../src/platform-runtime-screen-recording-apple-transport.ts'; import { runCmdBackground } from '@agent-device/host-kit/command'; +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { withClientReplayScriptSources } from '../../../src/__tests__/test-utils/replay-script-source.fixtures.ts'; import type { DeviceInventoryProvider, @@ -96,6 +97,8 @@ type ProviderScenarioDependencies = Partial>; }; export async function createProviderScenarioHarness( @@ -108,10 +111,13 @@ export async function createProviderScenarioHarness( PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, + // A scenario that seeds claims for its daemon names the directory through the process. + AGENT_DEVICE_CLAIMS_DIR: process.env.AGENT_DEVICE_CLAIMS_DIR ?? path.join(testRoot, 'claims'), + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '3600000', + ...deps.env, + // Set last: the harness reads its own state dir and transport back from these. AGENT_DEVICE_STATE_DIR: stateDir, - AGENT_DEVICE_CLAIMS_DIR: path.join(testRoot, 'claims'), AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', - AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '3600000', }; const { deviceInventoryProvider, @@ -120,6 +126,7 @@ export async function createProviderScenarioHarness( platformRuntime, providerRuntimes, leaseTtlMs, + env: _env, requestPlatformProviders: configuredRequestPlatformProviders, androidAdbProvider, appleRunnerProvider, @@ -141,6 +148,7 @@ export async function createProviderScenarioHarness( platformRuntime?.providerModules ?? createProviderPlatformRuntimeRegistrations(configuredRuntimes); const defaultDeviceRuntimeGateway = createPlatformRuntimeGateway({ + env: process.env, assertShutdownAllowed: input.assertShutdownAllowed, providerRuntimes: configuredRuntimes, providerModules, @@ -202,7 +210,7 @@ export async function createProviderScenarioHarness( let runtime: Awaited>; try { runtime = await startDaemonRuntime({ - env, + host: createTestHostEnvironment({ env }), registerProcessHandlers: false, stdout: { write: () => undefined }, stderr: { write: () => undefined }, @@ -234,6 +242,7 @@ export async function createProviderScenarioHarness( }); return await new Promise((resolve, reject) => { handleDaemonHttpResponseBody(body, { + host: createTestHostEnvironment(), info: { transport: 'http', httpPort, diff --git a/test/integration/provider-scenarios/ios-record-trace.test.ts b/test/integration/provider-scenarios/ios-record-trace.test.ts index 2c60281944..ae1d06ebca 100644 --- a/test/integration/provider-scenarios/ios-record-trace.test.ts +++ b/test/integration/provider-scenarios/ios-record-trace.test.ts @@ -105,6 +105,8 @@ test('iOS simulator recording reports host contention and recovers through the f }, }; const daemon = await createProviderScenarioHarness({ + // No real tool is on the daemon's PATH, and its Swift helper compiles into the scenario's cache. + env: { PATH: tmpDir, AGENT_DEVICE_SWIFT_CACHE_DIR: path.join(tmpDir, 'swift-cache') }, appleRunnerProvider: () => appleRunnerProvider, appleToolProvider: () => appleTool.provider, appleSimulatorScreenRecordingTransport: () => recordingTransport, diff --git a/test/integration/provider-scenarios/linux-world.ts b/test/integration/provider-scenarios/linux-world.ts index 9c9d40c91c..f6ccd15ee3 100644 --- a/test/integration/provider-scenarios/linux-world.ts +++ b/test/integration/provider-scenarios/linux-world.ts @@ -161,7 +161,7 @@ const inventoryScope: PlatformRequestScope = Object.freeze({ }); async function discoverLocalLinuxInventory(): Promise { - const source = await linuxInventoryModule.loadInventory(createDeviceInventoryHost()); + const source = await linuxInventoryModule.loadInventory(createDeviceInventoryHost(process.env)); return [...(await source.discover({ platform: 'linux' }, inventoryScope))]; } diff --git a/test/integration/provider-scenarios/remote-daemon-client.test.ts b/test/integration/provider-scenarios/remote-daemon-client.test.ts index 77b7ccd1b6..c6153e8f74 100644 --- a/test/integration/provider-scenarios/remote-daemon-client.test.ts +++ b/test/integration/provider-scenarios/remote-daemon-client.test.ts @@ -12,6 +12,7 @@ import { closeLoopbackServer, listenOnLoopback, skipWhenLoopbackUnavailable, + createTestHostEnvironment, } from '@agent-device/test-kit'; const NO_CANCELLATION = new AbortController().signal; @@ -681,6 +682,7 @@ test('remote web recording defaults client and daemon artifact paths to WebM', a }, { baseUrl: 'http://127.0.0.1:1', token: 'remote-token' }, NO_CANCELLATION, + createTestHostEnvironment(), ); assert.equal(prepared.positionals[0], 'start'); @@ -702,6 +704,7 @@ test('remote web recording appends WebM extension to extensionless client paths' }, { baseUrl: 'http://127.0.0.1:1', token: 'remote-token' }, NO_CANCELLATION, + createTestHostEnvironment(), ); assert.equal(prepared.positionals[0], 'start'); @@ -720,6 +723,7 @@ test('remote recording without platform or requested path lets daemon name the c }, { baseUrl: 'http://127.0.0.1:1', token: 'remote-token' }, NO_CANCELLATION, + createTestHostEnvironment(), ); assert.deepEqual(prepared.positionals, ['start']); diff --git a/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts b/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts index 6cdb73743d..afcd3dc2ac 100644 --- a/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts +++ b/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts @@ -9,7 +9,7 @@ import { downloadRemoteArtifact, materializeRemoteArtifacts, } from '@agent-device/remote/daemon-artifacts'; -import { skipWhenLoopbackUnavailable } from '@agent-device/test-kit'; +import { skipWhenLoopbackUnavailable, createTestHostEnvironment } from '@agent-device/test-kit'; import { withProviderScenarioResource, withProviderScenarioTempDir } from './harness.ts'; import { createProviderScenarioProxy } from './harness-proxy.ts'; import { FAKE_PROVIDER, createFakeProviderWorld } from './provider-device-runtime.fixtures.ts'; @@ -151,6 +151,7 @@ test('Provider-backed integration proxy materializes tenant-owned test artifacts { baseUrl: proxy.baseUrl, token: TENANT_TOKEN }, request, response, + createTestHostEnvironment(), ); assert.equal(materialized.ok, true); if (!materialized.ok) return; diff --git a/test/integration/provider-scenarios/remote-test-artifacts-materialization.test.ts b/test/integration/provider-scenarios/remote-test-artifacts-materialization.test.ts index a54d6eeed0..fef49e562f 100644 --- a/test/integration/provider-scenarios/remote-test-artifacts-materialization.test.ts +++ b/test/integration/provider-scenarios/remote-test-artifacts-materialization.test.ts @@ -14,6 +14,7 @@ import { listenOnLoopback, mkdtempForTestSync, skipWhenLoopbackUnavailable, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { createProviderScenarioHarness, @@ -106,6 +107,7 @@ test('the composed daemon and client path materializes the directory produced by { baseUrl: proxy.baseUrl, token: PROXY_TOKEN }, request, response, + createTestHostEnvironment(), ); assert.equal(materialized.ok, true); if (!materialized.ok) return; diff --git a/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts b/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts index eadd0edd4e..5a15583203 100644 --- a/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts +++ b/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts @@ -7,7 +7,7 @@ import { createBundledCloudWebDriverRuntimes } from './bundled-cloud-webdriver-r import { createLimrunRuntime, type LimrunRuntimeDependencies } from '@agent-device/provider-limrun'; import { limrunTestDependencies } from '../../../src/platform-runtime-gateway.fixtures.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; import { assertRpcError, assertRpcOk } from './assertions.ts'; import { createProviderScenarioHarness } from './harness.ts'; @@ -25,6 +25,8 @@ import { import { inspectProviderDeploymentAdmission } from './stale-provider-runtime-admission.fixtures.ts'; import { CLOUD_WEBDRIVER_PROVIDERS } from '@agent-device/provider-webdriver/providers'; +bindTestCommandHost(); + const limrunState = vi.hoisted(() => ({ androidCreate: vi.fn(async () => ({ metadata: { id: 'limrun-instance-a' }, diff --git a/test/integration/smoke-tvos-platform-coverage.test.ts b/test/integration/smoke-tvos-platform-coverage.test.ts index f3dfc59592..c268c20cad 100644 --- a/test/integration/smoke-tvos-platform-coverage.test.ts +++ b/test/integration/smoke-tvos-platform-coverage.test.ts @@ -83,6 +83,7 @@ test('tvOS gesture contract preserves the typed multi-touch denial', async () => // R42: the denial is now the Apple owner's own fact for the tvOS leaf, and the refusal a // gesture reports is that fact's hint under the shared wording. const facts = await createPlatformRuntimeGateway({ + env: process.env, resolveSessionArtifacts: () => ({ outputPath: '/sessions/tvos/app.log', pidPath: '/sessions/tvos/app-log.pid', diff --git a/test/integration/support/exit-after-flush.ts b/test/integration/support/exit-after-flush.ts index 27d835fb16..9c298fbeb5 100644 --- a/test/integration/support/exit-after-flush.ts +++ b/test/integration/support/exit-after-flush.ts @@ -14,7 +14,7 @@ if (cliPayloadMarker) { } else if (process.argv.includes('--success')) { const { runCliProcess } = await import('../../../src/cli/process-entry.ts'); const { printJson } = await import('../../../src/commands/output/json.ts'); - await runCliProcess([], async () => ({ + await runCliProcess([], { env: process.env, cwd: process.cwd() }, async () => ({ runCli: async () => { printJson({ success: true, data: { payload: `${'x'.repeat(256_000)}${PAYLOAD_MARKER}` } }); }, diff --git a/test/wire-compat/closure-policy.ts b/test/wire-compat/closure-policy.ts index c818236106..16e892b2b9 100644 --- a/test/wire-compat/closure-policy.ts +++ b/test/wire-compat/closure-policy.ts @@ -35,6 +35,8 @@ const WIRE_EXTERNAL_MODULES: Readonly> = { * cannot change what a skewed peer parses. */ export const WIRE_CLOSURE_WAIVERS: Readonly> = { + 'packages/kernel/src/source-value.ts#HostEnvironment': + "The client process's own environment and working directory, read to resolve local paths. It never crosses the wire; only the paths it helps resolve do, and those are digested through the declarations that use them.", 'packages/kernel/src/errors.ts#AppError': 'The local error CLASS. Only its projection onto the wire is contractual, and that projection is DaemonError, which is listed.', 'packages/kernel/src/errors.ts#AppErrorCode': diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 57c6410b8d..152888f953 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -59,12 +59,12 @@ "src/daemon-client/daemon-client-rpc.ts#appErrorFromDaemonError": "sha256:ac4761006c71d93ccba9f8e37cbd4cf48283dbb22737c163dc9c1a878e154eea", "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload": "sha256:bbcf00e71d3975caba4add0e85537396b5831796f1af52cc8c62a4688882ebd7", "src/daemon-client/daemon-client-rpc.ts#buildLeaseRpcParams": "sha256:365490a0fe1833d7ce8b0d3f5425462608a85119b12c1a2c4f16bb83b5d28e06", - "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody": "sha256:8d5849a4e573d6bb8fbd4ee0259755b76116a65f4abe41705cf84bfab5834ce4", + "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody": "sha256:7f2913988208ce42c2f03b4c3124df25e3f887a4f987fbe448d7a6298f815c23", "src/daemon-client/daemon-client-rpc.ts#isLeaseRpcCommand": "sha256:955dc1b73462d40593e5728dbcbaeec1307944b08262a22627e84cc5dc507e8f", "src/daemon-client/daemon-client-rpc.ts#leaseRpcMethodForCommand": "sha256:3613555c6002c23ab5bec4b98bc1b4d6947014b52fa100176177f955cf762881", "src/daemon-client/daemon-client-rpc.ts#parseDaemonHttpResponseBody": "sha256:0cbe39dbd09e321ec00854a1626a7d89b6344e59c6faabe2fe02504268b2d886", "src/daemon-client/daemon-client-rpc.ts#rejectDaemonHttpRpcError": "sha256:a5cf6fd4a9abea34ec051f03d6e45e5aa248f480371fdf22f93de06622a57b47", - "src/daemon-client/daemon-client-rpc.ts#resolveDaemonHttpResult": "sha256:9432fffc59c11ff7ccf8c9ce7076559e5c043264045737f24cc4b8e67d76c3d7", + "src/daemon-client/daemon-client-rpc.ts#resolveDaemonHttpResult": "sha256:64dff5e16136cbc69f4692c64ea0ea55465155909c4da112ea1356da4fecf5e4", "src/daemon-client/daemon-client-rpc.ts#toDaemonHttpRpcError": "sha256:888246763c48670e7da893054d025744654f8715c3b4906312617a2b5028316b", "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealth": "sha256:3bac36fa97090b273afe1128103e1bf476d05441fd9de41317f1b78775b88304", "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealthLink": "sha256:7702598468b4c82b4ffa93064cd6bdfec938c1c527f7e6007c0584f3884a6eea", @@ -151,8 +151,8 @@ "packages/remote/src/daemon-artifacts.ts#buildDaemonArtifactUrl": "sha256:06dcde057809b507f70f7bb5cdef9bde6515be84b67e45865cf19e70b8636208", "packages/remote/src/daemon-artifacts.ts#downloadRemoteArtifact": "sha256:f3ab09ce387577073ecc5bcd2528963e45f91b2c1e4cc57847c3e2732ccbb300", "packages/remote/src/daemon-artifacts.ts#isRemoteDaemon": "sha256:0f6a714c5f60ce4e8548d811b516d8b6241eca9f7e5414733732da395b8a00e7", - "packages/remote/src/daemon-artifacts.ts#materializeRemoteArtifacts": "sha256:c3ea70cc9a218acfaa30f6c4ba55b80224c96aba10e4e9c7a17828fc851de77e", - "packages/remote/src/daemon-artifacts.ts#resolveMaterializedArtifactPath": "sha256:cad37beddeb03ba70bc92118a5e45f552edfc00feec789758e390d1ea4c7d879", + "packages/remote/src/daemon-artifacts.ts#materializeRemoteArtifacts": "sha256:fffe8ce578e8bcdea175d6a200bea364ffce0956c2cba65bea48d72c0aaea375", + "packages/remote/src/daemon-artifacts.ts#resolveMaterializedArtifactPath": "sha256:0cb422301edef0545615b0f3acb3d80280213921cab2eb529715260ff0fa3647", "packages/remote/src/remote-request-diagnostics.ts#RemoteDaemonErrorPayload": "sha256:8fbcbcdf4bae7b66a08e465a72593794f36364759f18f1a814cd39dfdb76f309", "packages/remote/src/remote-request-diagnostics.ts#RemoteDiagnosticsEndpoint": "sha256:c1d5fe2fe90c425502cf8b7c87d6606374d6af65b4e9faf942a416f683930bdc", "packages/remote/src/remote-request-diagnostics.ts#buildRemoteRequestDiagnosticsUrl": "sha256:30a1a9bf0df24d3564a616699916a3bb4360c2b4d21c4ad0562fb0c1e738d0a9", @@ -216,8 +216,8 @@ }, { "declaration": "packages/remote/src/daemon-artifacts.ts#materializeRemoteArtifacts", - "digest": "sha256:c3ea70cc9a218acfaa30f6c4ba55b80224c96aba10e4e9c7a17828fc851de77e", - "rationale": "#2246 passes isDirectory only for the new test-artifacts type and uses the exact path the client materializer published. Existing screenshot and recording artifacts still report their requested file path; only the new directory response gains its suite-invocation segment. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved." + "digest": "sha256:fffe8ce578e8bcdea175d6a200bea364ffce0956c2cba65bea48d72c0aaea375", + "rationale": "#2246 passes isDirectory only for the new test-artifacts type and uses the exact path the client materializer published. Existing screenshot and recording artifacts still report their requested file path; only the new directory response gains its suite-invocation segment. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved. The function now takes the client host as a parameter and resolves a relative local path against its working directory instead of the process's; the client binds the same directory, so nothing a peer parses moves." }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload", @@ -241,8 +241,8 @@ }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody", - "digest": "sha256:8d5849a4e573d6bb8fbd4ee0259755b76116a65f4abe41705cf84bfab5834ce4", - "rationale": "#1801 client-side only: the RPC error branch now runs through `rejectDaemonHttpRpcError`, which reads a superset of the fields it read before and, for a remote daemon, replaces the daemon-host `logPath` with a caller-local copy. Every field a released daemon sends is still accepted; nothing new is required of the daemon. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved." + "digest": "sha256:7f2913988208ce42c2f03b4c3124df25e3f887a4f987fbe448d7a6298f815c23", + "rationale": "#1801 client-side only: the RPC error branch now runs through `rejectDaemonHttpRpcError`, which reads a superset of the fields it read before and, for a remote daemon, replaces the daemon-host `logPath` with a caller-local copy. Every field a released daemon sends is still accepted; nothing new is required of the daemon. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved. The function now takes the client host as a parameter and resolves a relative local path against its working directory instead of the process's; the client binds the same directory, so nothing a peer parses moves." }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#toDaemonHttpRpcError", @@ -471,13 +471,13 @@ }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#resolveDaemonHttpResult", - "digest": "sha256:9432fffc59c11ff7ccf8c9ce7076559e5c043264045737f24cc4b8e67d76c3d7", - "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did." + "digest": "sha256:64dff5e16136cbc69f4692c64ea0ea55465155909c4da112ea1356da4fecf5e4", + "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did. The function now takes the client host as a parameter and resolves a relative local path against its working directory instead of the process's; the client binds the same directory, so nothing a peer parses moves." }, { "declaration": "packages/remote/src/daemon-artifacts.ts#resolveMaterializedArtifactPath", - "digest": "sha256:cad37beddeb03ba70bc92118a5e45f552edfc00feec789758e390d1ea4c7d879", - "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did." + "digest": "sha256:0cb422301edef0545615b0f3acb3d80280213921cab2eb529715260ff0fa3647", + "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did. The function now takes the client host as a parameter and resolves a relative local path against its working directory instead of the process's; the client binds the same directory, so nothing a peer parses moves." } ] }