From 77dde28538d24435ca246efb8ee671a5a974e0f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:03:59 +0200 Subject: [PATCH 01/33] feat(host): capture HostEnvironment once at entry points Add HostEnvironment to the kernel and captureHostEnvironment to host-kit. The CLI, the daemon executable and the client factory capture it once; startDaemonRuntime requires it and the request scope exposes it. Host-kit, kernel and daemon-contracts no longer read the process environment or working directory. Co-Authored-By: Claude Sonnet 5.5 --- .../src/recording/output-path.test.ts | 9 +- .../capture-kit/src/recording/output-path.ts | 2 +- .../src/daemon-policy-file.ts | 3 +- .../src/daemon-registration-owner.ts | 5 +- .../daemon-contracts/src/daemon-resolution.ts | 16 +- .../src/device-claim-inspection.ts | 8 +- .../src/device-claim-paths.test.ts | 4 +- .../src/device-claim-paths.ts | 9 +- .../src/device-claim-settlement.ts | 15 +- .../src/device-claim-store.ts | 8 +- .../daemon-contracts/src/device-claims.ts | 55 +++-- .../support/daemon-runtime-deps-fixture.ts | 2 +- .../support/human-control-router-fixture.ts | 2 + .../src/core/__tests__/daemon-policy.test.ts | 8 +- .../__tests__/request-ended-leases.test.ts | 3 +- .../request-runtime-binding-router.test.ts | 2 + .../src/core/session-replay-transaction.ts | 2 +- .../session-script-publication-capability.ts | 13 +- .../daemon/src/core/session-script-writer.ts | 2 +- .../execution/__tests__/back-runtime.test.ts | 2 + .../execution/__tests__/focus-runtime.test.ts | 2 + .../__tests__/orientation-runtime.test.ts | 2 + .../__tests__/system-button-runtime.test.ts | 2 + .../__tests__/tv-remote-runtime.test.ts | 2 + .../__tests__/viewport-runtime.test.ts | 2 + .../src/execution/screenshot-runtime.ts | 2 +- .../filesystem-boundary-faults.test.ts | 1 + .../__tests__/device-claim-admission.test.ts | 22 +- .../__tests__/device-claim-prune.test.ts | 10 +- .../__tests__/device-claim-settlement.test.ts | 44 +++- .../device/__tests__/device-claims.test.ts | 123 +++++++--- .../device/device-claim-admission.ts | 5 +- .../daemon/src/resources/macos-app-lease.ts | 4 +- ...quest-execution-scope-lease-expiry.test.ts | 4 +- ...-execution-scope-lease-session-end.test.ts | 1 + ...st-execution-scope-macos-app-lease.test.ts | 3 + ...t-execution-scope-open-device-wait.test.ts | 7 +- .../__tests__/request-execution-scope.test.ts | 38 ++- .../request-router-apps-admission.test.ts | 3 +- .../request-router-artifacts-web.test.ts | 7 +- .../__tests__/request-router-cost.test.ts | 3 +- ...request-router-custom-action-flags.test.ts | 3 +- .../request-router-developer-dir.test.ts | 3 +- .../request-router-error-redaction.test.ts | 3 +- .../__tests__/request-router-events.test.ts | 13 +- .../request-router-idle-expired.test.ts | 3 +- .../request-router-open-claim.test.ts | 13 +- .../__tests__/request-router-open-harness.ts | 2 +- .../__tests__/request-router-open.test.ts | 9 +- .../request-router-record-flags.test.ts | 3 +- .../request-router-repair-expired.test.ts | 3 +- .../request-router-replay-env.test.ts | 3 +- .../request-router-response-level.test.ts | 3 +- .../request-router-session-address.test.ts | 3 +- .../request-router-typed-error.test.ts | 3 +- .../request-save-script-transports.test.ts | 2 + .../__tests__/session-idle-expiry-harness.ts | 1 + .../http/daemon-runtime-artifacts.test.ts | 18 +- .../http/daemon-runtime-composition.test.ts | 51 ++-- .../http/daemon-runtime-idle-reap.test.ts | 44 ++-- .../http/daemon-runtime-lease-expiry.test.ts | 32 +-- .../daemon-runtime-lifecycle-shutdown.test.ts | 93 +++++--- .../daemon-runtime-metadata-ownership.test.ts | 20 +- .../daemon-runtime-owner-lifecycle.test.ts | 16 +- .../daemon-runtime-recording-reaper.test.ts | 16 +- .../daemon-runtime-rpc-flow-failure.test.ts | 16 +- .../daemon/src/server/http/daemon-runtime.ts | 29 ++- .../server/http/daemon-session-idle-expiry.ts | 2 +- .../http/daemon-shutdown-claims.test.ts | 12 +- .../src/server/http/daemon-shutdown-claims.ts | 2 +- .../src/server/request-execution-scope.ts | 10 +- packages/daemon/src/server/request-router.ts | 12 +- .../request-recording-health.test.ts | 2 + .../__tests__/session-idle-activity.test.ts | 7 +- ...shot-custom-actions-platform-guard.test.ts | 3 +- .../handlers/__tests__/human-control.test.ts | 6 + ...ession-boot-shutdown-device-claims.test.ts | 12 +- .../__tests__/session-device-claims.test.ts | 26 +- .../src/sessions/handlers/record-runtime.ts | 7 +- .../handlers/session-app-deployment.ts | 4 +- .../session-appstate-device-runtime.ts | 2 +- .../src/sessions/handlers/session-boot.ts | 1 + .../handlers/session-doctor-output.ts | 8 +- .../src/sessions/handlers/session-doctor.ts | 5 +- .../src/sessions/handlers/trace-runtime.ts | 7 +- .../daemon/src/sessions/lease-lifecycle.ts | 2 +- .../session-close-error-precedence.test.ts | 9 +- .../session-lifecycle/internal/inventory.ts | 6 +- .../internal/session-close.ts | 2 +- .../internal/session-open-execution.ts | 7 +- .../internal/session-perf-runtime.ts | 10 +- .../device-selection/src/dispatch-resolve.ts | 6 +- packages/host-kit/package.json | 4 + packages/host-kit/src/command.ts | 2 +- packages/host-kit/src/doctor-lines.ts | 7 +- packages/host-kit/src/host-environment.ts | 15 ++ packages/host-kit/src/internal/exec.test.ts | 222 ++++++++---------- packages/host-kit/src/internal/exec.ts | 54 +++-- .../host-kit/src/internal/host-process.ts | 12 - .../host-kit/src/internal/path-resolution.ts | 7 +- packages/host-kit/src/process.ts | 3 - packages/host-kit/src/session-paths.test.ts | 2 +- packages/host-kit/src/session-paths.ts | 2 +- packages/host-kit/src/status-markers.ts | 15 +- packages/kernel/src/device-isolation.ts | 3 +- packages/kernel/src/device.ts | 5 +- packages/kernel/src/source-value.ts | 9 + .../platform-android/src/app-deployment.ts | 2 +- .../platform-apple/src/core/debug-symbols.ts | 3 +- .../platform-apple/src/core/tool-provider.ts | 2 +- .../src/os/macos/app-backend.ts | 3 +- .../platform-apple/src/os/macos/helper.ts | 12 +- .../src/__tests__/hdc.test.ts | 11 +- packages/platform-harmonyos/src/hdc.ts | 3 +- packages/platform-linux/src/linux-env.ts | 3 +- packages/platform-linux/src/tool-provider.ts | 13 +- packages/platform-vega/src/tool-provider.ts | 2 +- .../platform-web/src/agent-browser-install.ts | 5 +- .../src/agent-browser-lifecycle.ts | 3 +- .../platform-web/src/agent-browser-tool.ts | 9 +- .../session-replay-runtime-session.ts | 2 +- .../daemon-port/session-test-shard-devices.ts | 6 +- .../src/daemon-port/test-command.ts | 5 +- packages/test-kit/src/host-environment.ts | 12 + packages/test-kit/src/index.ts | 1 + .../http-server-limrun-uploaded-apps.test.ts | 2 + .../request-dispatch-disclosure.test.ts | 2 + .../request-router-android-modal.test.ts | 5 +- .../request-router-android-perf.test.ts | 2 + ...est-router-android-snapshot-helper.test.ts | 2 + .../request-router-fill-text-stdin.test.ts | 7 +- .../request-router-lock-policy.test.ts | 11 +- .../request-router-macos-app-lease.test.ts | 8 +- ...request-router-open-claim-recovery.test.ts | 7 +- ...equest-router-provider-credentials.test.ts | 3 +- ...request-router-record-runtime-lock.test.ts | 3 + .../request-router-recording-health.test.ts | 4 +- .../request-router-replay-scope.test.ts | 10 +- .../request-router-replay-test-ime.test.ts | 7 +- .../request-router-screenshot.test.ts | 9 +- .../daemon-registration-owner.test.ts | 48 +++- .../platform-runtime-claim-recovery.test.ts | 2 +- .../test-utils/public-daemon-runtime.ts | 18 +- src/agent-device-client.ts | 10 +- src/bin.ts | 8 +- src/cli.ts | 59 ++++- src/cli/commands/device.ts | 30 ++- src/cli/commands/recording.ts | 7 +- src/cli/commands/replay.ts | 5 +- src/cli/commands/screenshot.ts | 9 +- src/cli/process-entry.ts | 6 +- src/cli/replay-test/progress.ts | 9 +- src/commands/management/output.ts | 4 +- .../daemon-client-policy-reuse.test.ts | 6 +- src/daemon-client/daemon-client-lifecycle.ts | 1 + src/daemon-composition-credentials.test.ts | 18 +- src/daemon-composition.test.ts | 8 +- src/daemon-composition.ts | 2 +- src/daemon-entrypoint.test.ts | 71 +++--- src/daemon.ts | 2 + src/platform-runtime-claim-recovery.ts | 3 + src/platform-runtime-host.ts | 3 +- src/platform-runtime-operation-host.ts | 3 +- .../real-scope-serialization.ts | 2 + .../integration/provider-scenarios/harness.ts | 1 + test/integration/support/exit-after-flush.ts | 2 +- 166 files changed, 1275 insertions(+), 635 deletions(-) create mode 100644 packages/host-kit/src/host-environment.ts create mode 100644 packages/test-kit/src/host-environment.ts 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.ts b/packages/daemon-contracts/src/daemon-policy-file.ts index bef16cd815..dc4d0d50df 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,7 +73,7 @@ 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): DaemonPolicy | undefined { const configured = env[DAEMON_POLICY_ENV]?.trim(); if (!configured) return undefined; const sourcePath = path.resolve(configured); diff --git a/packages/daemon-contracts/src/daemon-registration-owner.ts b/packages/daemon-contracts/src/daemon-registration-owner.ts index c2276c9f2d..b08caa1357 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, @@ -246,6 +247,8 @@ 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; ownedStateDir?: OwnedReplayStateDir; }>, ): DaemonStartupLaunch { @@ -259,7 +262,7 @@ export function launchDaemonProcess( try { const monitored = runCmdDetachedMonitored(process.execPath, input.args, { 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.ts b/packages/daemon-contracts/src/daemon-resolution.ts index 2081fac0cb..994250dc3a 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 { DaemonServerMode, DaemonTransportPreference } from '@agent-device/kernel/contracts'; @@ -27,6 +28,8 @@ export type DaemonPaths = { type ResolveDaemonPathsOptions = { env?: EnvMap; + /** Resolves a relative state dir; a relative one without it is refused. */ + cwd?: string; projectRoot?: string; }; @@ -50,7 +53,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..90df4de3d7 100644 --- a/packages/daemon-contracts/src/device-claim-paths.test.ts +++ b/packages/daemon-contracts/src/device-claim-paths.test.ts @@ -8,14 +8,14 @@ const DEVICE_KEYS = ['local:android:none:emulator-5554', 'local:ios:ios:ABCD-123 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')); 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); } }); diff --git a/packages/daemon-contracts/src/device-claim-paths.ts b/packages/daemon-contracts/src/device-claim-paths.ts index 47e8235a26..61dc18448d 100644 --- a/packages/daemon-contracts/src/device-claim-paths.ts +++ b/packages/daemon-contracts/src/device-claim-paths.ts @@ -1,6 +1,7 @@ import crypto from 'node:crypto'; import os from 'node:os'; import path from 'node:path'; +import type { EnvMap } from '@agent-device/kernel/source-value'; import { type AppleOS, type DeviceIdentity, @@ -24,14 +25,14 @@ function formatLocalDeviceKey( return `local:${platform}:${appleOs ?? 'none'}:${id}`; } -export function resolveDeviceClaimRoot(): string { - const override = process.env.AGENT_DEVICE_CLAIMS_DIR?.trim(); +export function resolveDeviceClaimRoot(env: EnvMap): string { + const override = env.AGENT_DEVICE_CLAIMS_DIR?.trim(); return override ? path.resolve(override) : path.join(os.homedir(), '.agent-device', 'device-claims'); } -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 93a4ff3c0f..064a4883f7 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, @@ -59,8 +60,9 @@ export async function resolveExistingClaim(params: { stateDir: 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 && @@ -70,7 +72,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); } @@ -81,10 +83,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); } @@ -100,6 +102,7 @@ async function settleForeignClaim( deviceKey: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; observeDeviceBoot?: DeviceBootObservationService; + env: EnvMap; }, ): Promise { const claim = existing.claim; @@ -120,6 +123,7 @@ async function settleForeignClaim( const reconciliation = await settleVerifiedOrphanedClaim( claim, params.reconcileOrphanedDeviceClaim, + params.env, ); if (reconciliation.status === 'retained') { emitClaimConflict(params.deviceKey, existing, reconciliation.reason); @@ -152,11 +156,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 2fbd71f0db..f6b47ba15c 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, @@ -89,6 +90,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 @@ -100,6 +102,7 @@ export async function acquireDeviceClaim(params: { const deviceKey = canonicalLocalDeviceKey(identity); return await withDeviceClaimLock( deviceKey, + params.env, async () => await claimHeldDevice({ ...params, deviceKey, identity }), ); } @@ -118,12 +121,13 @@ export async function acquireTransientDeviceClaim(params: { command: string; 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) && @@ -138,6 +142,7 @@ export async function acquireTransientDeviceClaim(params: { session: transientDeviceClaimSession(params.command), workspace: params.workspace, stateDir: params.stateDir, + env: params.env, reconcileOrphanedDeviceClaim: params.reconcileOrphanedDeviceClaim, }); }); @@ -151,6 +156,7 @@ async function claimHeldDevice(params: { session: string; workspace: string; stateDir: string; + env: EnvMap; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; observeDeviceBoot?: DeviceBootObservationService; }): Promise { @@ -179,7 +185,7 @@ async function claimHeldDevice(params: { createdAtMs: now, updatedAtMs: now, }; - writeDeviceClaim(claim); + writeDeviceClaim(claim, params.env); return { status: 'acquired', ownership: ownershipFromClaim(claim), @@ -197,9 +203,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 && @@ -247,10 +253,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; } @@ -258,6 +265,7 @@ export async function releaseProvenStaleDeviceClaims(params: { async function releaseInspectedStaleClaim( entry: InspectedDeviceClaim, reconcile: DeviceClaimReconciler, + env: EnvMap, ): Promise { const claim = entry.claim; const base = { @@ -279,13 +287,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 || @@ -293,7 +302,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 }; } @@ -346,10 +355,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); @@ -384,13 +395,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'), @@ -416,12 +429,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 }), @@ -434,11 +449,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; @@ -482,8 +498,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 }); @@ -508,11 +525,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 || @@ -522,7 +539,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 3767db910c..c4eaed88ed 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-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/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/device/__tests__/device-claim-admission.test.ts b/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts index 57830bb24d..a8930b8e0c 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 } from 'vitest'; import { daemonPlatformServicesFixture, @@ -30,6 +34,7 @@ const localAndroid = localRuntimeOwner('android'); function makeAdmission(policy: DeviceClaimPolicy, stateDir: string, command = 'made-up-command') { return createDeviceClaimAdmission({ + env: process.env, policy, command, workspace: '/worktrees/current', @@ -39,7 +44,7 @@ function makeAdmission(policy: DeviceClaimPolicy, stateDir: string, command = 'm } 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: @@ -64,7 +69,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); }, @@ -75,6 +80,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', @@ -102,6 +108,7 @@ test('a foreign live claim refuses the command before it can reach device operat test('a claim already held by this daemon 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', @@ -120,6 +127,7 @@ test('a claim already held by this daemon covers the command instead of collidin 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', @@ -128,7 +136,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); @@ -136,7 +144,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 () => { @@ -205,6 +213,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', @@ -223,6 +232,7 @@ test('the none policy still reaches no device state at all', async () => { /** Claims visible while one command holds a device binding from the real request scope. */ async function claimsWhileBound(command: string, stateDir: string) { const scope = await createRequestExecutionScope({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), req: { token: 't', session: 'default', command, positionals: [], flags: {} }, @@ -252,5 +262,5 @@ test('the request scope claims only for commands whose descriptor declares trans // Same binding call, same device: the difference is the descriptor policy. expect(await claimsWhileBound('shutdown', stateDir)).toEqual(['transient:shutdown']); expect(await claimsWhileBound('snapshot', stateDir)).toEqual([]); - expect(inspectDeviceClaims({})).toEqual([]); + expect(inspectDeviceClaims({}, process.env)).toEqual([]); }); 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 e43e2bebc6..ecf8f69e3f 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 @@ -68,6 +68,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', @@ -83,6 +84,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', @@ -91,12 +93,13 @@ 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'); }); 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', @@ -104,18 +107,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', @@ -134,6 +142,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', @@ -143,12 +152,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', @@ -161,6 +174,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', @@ -174,12 +188,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', @@ -190,6 +208,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', @@ -201,12 +220,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', @@ -225,6 +248,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', @@ -242,7 +266,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; } @@ -256,6 +280,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', @@ -285,6 +310,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', @@ -316,6 +342,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', @@ -338,6 +365,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', @@ -358,6 +386,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', @@ -377,6 +406,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', @@ -387,6 +417,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', @@ -401,6 +432,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 74340b5561..50bd89f9e3 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 3dd98c5728..dd8a520457 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'; @@ -37,6 +38,7 @@ export function createDeviceClaimAdmission(params: { command: string; workspace: string; stateDir: string; + env: EnvMap; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; }): DeviceClaimAdmission { // The caller admits once per device binding, so this only has to remember what @@ -55,6 +57,7 @@ export function createDeviceClaimAdmission(params: { command: params.command, workspace: params.workspace, stateDir: params.stateDir, + env: params.env, reconcileOrphanedDeviceClaim: params.reconcileOrphanedDeviceClaim, }); if (result.status === 'conflict') throw deviceClaimConflictError(device, result.conflict); @@ -73,7 +76,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', diff --git a/packages/daemon/src/resources/macos-app-lease.ts b/packages/daemon/src/resources/macos-app-lease.ts index e9f92ab44e..09ed8e8fea 100644 --- a/packages/daemon/src/resources/macos-app-lease.ts +++ b/packages/daemon/src/resources/macos-app-lease.ts @@ -5,7 +5,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'; @@ -257,7 +257,7 @@ function assertOpensLeasedApp(key: MacOsAppLeaseKey, positionals: readonly strin * every action inside the session app. */ function assertNativeBackend(): void { - if (readMacOsAppBackend(readHostEnvironmentVariable) === 'native') return; + if (readMacOsAppBackend((name) => process.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-lease-expiry.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope-lease-expiry.test.ts index 47ab96dcdc..a3b579e732 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,7 +4,7 @@ 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 } 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'; @@ -49,6 +49,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 +105,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..c21a2aaa56 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 @@ -32,6 +32,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, 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 007e13dfb8..514fe42dda 100644 --- a/packages/daemon/src/server/__tests__/request-execution-scope.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-scope.test.ts @@ -21,7 +21,7 @@ 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 } from '@agent-device/test-kit'; import { makeTestScreenRecordingResource } from '../../__tests__/support/screen-recording-live-handle.ts'; import { handleSessionCloseCommands } from '../../sessions/session-lifecycle/index.ts'; @@ -44,6 +44,7 @@ test('createRequestExecutionScope applies tenant scoping and locked lease admiss }); const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ session: 'default', command: 'snapshot', @@ -75,6 +76,7 @@ test('createRequestExecutionScope is the single defaulting authority for the app const leaseRegistry = new LeaseRegistry(); const defaulted = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'apps' }), sessionStore, leaseRegistry, @@ -82,6 +84,7 @@ test('createRequestExecutionScope is the single defaulting authority for the app expect(defaulted.req.flags?.appsFilter).toBe('user-installed'); const overridden = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'apps', flags: { appsFilter: 'all' } }), sessionStore, leaseRegistry, @@ -89,6 +92,7 @@ test('createRequestExecutionScope is the single defaulting authority for the app expect(overridden.req.flags?.appsFilter).toBe('all'); const untouched = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -105,6 +109,7 @@ test('createRequestExecutionScope resolves session-scoped request and runner log { command: 'snapshot', requestId: 'request-logs-1', logPath: LOG_PATH }, async () => await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ meta: { cwd, requestId: 'request-logs-1' } }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -126,6 +131,7 @@ test('a relative session name is rejected before any session artifact path is wr await withDiagnosticsScope({ command: 'snapshot', requestId, logPath: LOG_PATH }, async () => { await expect( createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ session: '..', meta: { requestId } }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -150,6 +156,7 @@ test('request diagnostics flush into the effective session request log', async ( { command: 'snapshot', requestId: 'request-diag-1', logPath: LOG_PATH }, async () => { const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ meta: { cwd, requestId: 'request-diag-1' } }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -167,6 +174,7 @@ test('request diagnostics flush into the effective session request log', async ( test('runLocked rejects tenant requests without an active lease', async () => { const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ session: 'default', command: 'snapshot', @@ -213,6 +221,7 @@ test('leased session admission uses stored lease metadata and heartbeats', async now = 2_000; const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -255,11 +264,13 @@ test('leased session heartbeat is serialized with the request execution lock', a ); const first = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'click' }), sessionStore, leaseRegistry, }); const second = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'click' }), sessionStore, leaseRegistry, @@ -297,11 +308,13 @@ test('a later external command cannot interleave with replay observation finaliz sessionStore.publish('default', makeIosSession('default')); const leaseRegistry = new LeaseRegistry(); const replay = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'replay' }), sessionStore, leaseRegistry, }); const laterSnapshot = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -338,6 +351,7 @@ test('a fresh replay keeps its session lock after a nested open binds the device const sessionStore = makeSessionStore('agent-device-request-scope-'); const leaseRegistry = new LeaseRegistry(); const replay = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'replay' }), sessionStore, leaseRegistry, @@ -359,6 +373,7 @@ test('a fresh replay keeps its session lock after a nested open binds the device await sessionOpenedPromise; const laterSnapshot = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -393,6 +408,7 @@ test('leased session rejects mismatched lease id before dispatch', async () => { ); const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot', meta: { leaseId: '1'.repeat(32) } }), sessionStore, leaseRegistry, @@ -427,6 +443,7 @@ test.each([ ); const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot', meta }), sessionStore, leaseRegistry, @@ -442,6 +459,7 @@ test('local unleased session admission still succeeds', async () => { sessionStore.publish('default', makeIosSession('default')); const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -454,6 +472,7 @@ test('local unleased session ignores stale lease id without tenant scope', async const sessionStore = makeSessionStore('agent-device-request-scope-'); sessionStore.publish('default', makeIosSession('default')); const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot', meta: { leaseId: '1'.repeat(32) }, @@ -488,6 +507,7 @@ test('provider lease admission succeeds without a device key', async () => { ); const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -524,6 +544,7 @@ test('an admitted request that outlives the lease TTL keeps its lease and sessio ); const slow = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -533,6 +554,7 @@ test('an admitted request that outlives the lease TTL keeps its lease and sessio expect(await slow.runLocked(async () => (now = 1_011))).toBe(1_011); const next = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'screenshot' }), sessionStore, leaseRegistry, @@ -564,11 +586,13 @@ test('expired leased session cleanup waits for the request execution lock', asyn }), ); const first = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'click', meta: { requestId } }), sessionStore, leaseRegistry, }); const second = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'click' }), sessionStore, leaseRegistry, @@ -614,6 +638,7 @@ test('tenant lease rejection flushes diagnostics into the effective session requ await withDiagnosticsScope({ command: 'snapshot', requestId, logPath: LOG_PATH }, async () => { const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ session: 'default', command: 'snapshot', @@ -644,6 +669,7 @@ test('prepareLockedRequestScope preserves existing-session selector validation', const sessionStore = makeSessionStore('agent-device-request-scope-'); sessionStore.publish('default', makeAndroidSession('default')); const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot', flags: { @@ -675,6 +701,7 @@ test('prepareLockedRequestScope blocks commands for invalidated recordings befor }); sessionStore.publish('default', session); const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -703,6 +730,7 @@ test('prepareLockedRequestScope passes the session runner log path into handler const sessionStore = makeSessionStore('agent-device-request-scope-'); sessionStore.publish('default', makeIosSession('default')); const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -731,6 +759,7 @@ test('prepareLockedRequestScope streams ordinary diagnostics into the active tra }), ); const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -752,6 +781,7 @@ test('prepareLockedRequestScope streams ordinary diagnostics into the active tra test('runLocked rejects a canceled request before executing work', async () => { const requestId = 'request-scope-canceled-before-lock'; const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ meta: { requestId } }), sessionStore: makeSessionStore('agent-device-request-scope-'), leaseRegistry: new LeaseRegistry(), @@ -771,11 +801,13 @@ test('runLocked rejects a request canceled while waiting for its execution lock' sessionStore.publish('default', makeIosSession('default')); const leaseRegistry = new LeaseRegistry(); const first = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'click' }), sessionStore, leaseRegistry, }); const second = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command: 'click', meta: { requestId } }), sessionStore, leaseRegistry, @@ -810,6 +842,7 @@ test('router: deferred tenant connect with no daemon session closes as SESSION_N const release = vi.fn(async () => ({})); const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ session: 'default', command: 'close', @@ -848,6 +881,7 @@ test('router: deferred tenant connect still refuses an app-target close before d const leaseRegistry = new LeaseRegistry(); const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ session: 'default', command: 'close', @@ -873,6 +907,7 @@ test('router: an existing lease-less session under tenant isolation still refuse const leaseRegistry = new LeaseRegistry(); const scope = await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ session: 'default', command: 'close', @@ -930,6 +965,7 @@ async function createScopeAcrossTwoImplicitWorkspaceSessions(command: string) { makeAndroidSession('default', { sessionScope: { kind: 'cwd', id: scope.id } }), ); return await createTestRequestScope({ + host: createTestHostEnvironment(), req: makeRequest({ command, meta: { cwd: root, requestId: `ambiguity-${command}` } }), sessionStore, leaseRegistry: new LeaseRegistry(), 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..f836088b07 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,7 +5,7 @@ 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( @@ -14,6 +14,7 @@ test( async () => { const seen: string[] = []; const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('agent-device-router-developer-dir-'), 'daemon.log'), token: 'test-token', 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-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-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-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-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-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-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 c6aa03f5c4..fc1e0c6027 100644 --- a/packages/daemon/src/server/http/daemon-runtime-composition.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-composition.test.ts @@ -8,7 +8,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, @@ -41,12 +46,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: () => {} }, @@ -75,7 +82,7 @@ test('a daemon attempt losing the lock shuts down every constructed provider', a const otherShutdown = vi.fn(async () => {}); 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: () => {} }, @@ -118,12 +125,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: () => {} }, @@ -186,12 +195,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 0e897bb4af..a873610674 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 @@ -1,7 +1,11 @@ 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 })); @@ -44,12 +48,14 @@ function startRuntime(stateDir: string, exit: (code: number) => void) { }), skippedProviderRuntimes: providerComposition.skipped, })), - 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-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 3de749dc9a..9db1626ab4 100644 --- a/packages/daemon/src/server/http/daemon-runtime.ts +++ b/packages/daemon/src/server/http/daemon-runtime.ts @@ -57,7 +57,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, @@ -171,7 +173,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; @@ -192,7 +195,7 @@ export type ComposeDaemonRuntimeDeps = ( ) => Promise; export type DaemonRuntimeOptions = { - env?: NodeJS.ProcessEnv; + host: HostEnvironment; stdout?: WritableOutput; stderr?: WritableOutput; exit?: (code: number) => void; @@ -392,11 +395,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); @@ -419,7 +431,7 @@ export async function startDaemonRuntime( const sessionStore = new SessionStore(sessionsDir); const deps = await options.composeDeps({ - env, + host, baseDir, sessionsDir, assertShutdownAllowed, @@ -475,6 +487,7 @@ export async function startDaemonRuntime( ); leaseExpirySweep.unref(); const dispatchRequest = createRequestHandler({ + host, logPath, token, sessionStore, @@ -744,7 +757,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); @@ -815,6 +828,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. @@ -953,10 +967,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.ts b/packages/daemon/src/server/http/daemon-session-idle-expiry.ts index cb67444652..bc966f5105 100644 --- a/packages/daemon/src/server/http/daemon-session-idle-expiry.ts +++ b/packages/daemon/src/server/http/daemon-session-idle-expiry.ts @@ -546,7 +546,7 @@ async function clearExpiredClaim( ): Promise { if (!ownership) return 'absent'; try { - return await clearDeviceClaim(ownership); + return await clearDeviceClaim(ownership, process.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..0be294040c 100644 --- a/packages/daemon/src/server/http/daemon-shutdown-claims.test.ts +++ b/packages/daemon/src/server/http/daemon-shutdown-claims.test.ts @@ -30,6 +30,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, @@ -60,7 +61,7 @@ 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 () => { @@ -72,7 +73,9 @@ test('a claim left behind by a failed teardown is reported orphaned', async () = 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 +87,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', @@ -100,7 +104,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 () => { diff --git a/packages/daemon/src/server/http/daemon-shutdown-claims.ts b/packages/daemon/src/server/http/daemon-shutdown-claims.ts index ad3c74cc46..057ca504ae 100644 --- a/packages/daemon/src/server/http/daemon-shutdown-claims.ts +++ b/packages/daemon/src/server/http/daemon-shutdown-claims.ts @@ -71,7 +71,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, process.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 21eef92c7a..49775cc01a 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, @@ -117,6 +118,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; @@ -165,6 +168,7 @@ type LockedRequestScopeResult = | { type: 'response'; response: DaemonResponse }; export async function createRequestExecutionScope(params: { + host: HostEnvironment; req: DaemonRequest; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; @@ -256,9 +260,10 @@ export async function createRequestExecutionScope(params: { }); const dispatchLedger = requestDispatchLedger(scopedReq); const { claimAdmission, runtimeBindings } = createRequestDeviceAccess({ + env: params.host.env, command, dispatchLedger, - workspace: scopedReq.meta?.cwd ?? process.cwd(), + workspace: scopedReq.meta?.cwd ?? params.host.cwd, stateDir: sessionStore.resolveDaemonStateDir(), deviceRuntimeGateway: params.deviceRuntimeGateway, platformRequestScope: params.platformRequestScope, @@ -267,6 +272,7 @@ export async function createRequestExecutionScope(params: { }); const scope: RequestExecutionScope = { + host: params.host, req: scopedReq, command, sessionName, @@ -390,6 +396,7 @@ export async function createRequestExecutionScope(params: { * stays out of the claim store. */ function createRequestDeviceAccess(params: { + env: EnvMap; command: string; dispatchLedger: RequestDispatchLedger; workspace: string; @@ -411,6 +418,7 @@ function createRequestDeviceAccess(params: { command: params.command, workspace: params.workspace, stateDir: params.stateDir, + env: params.env, reconcileOrphanedDeviceClaim: createOwnerScopedDeviceClaimReconciler({ scope: platformRequestScope, composeGateway: params.claimRecoveryGateway, diff --git a/packages/daemon/src/server/request-router.ts b/packages/daemon/src/server/request-router.ts index 5ac97fae82..b7a2f6878b 100644 --- a/packages/daemon/src/server/request-router.ts +++ b/packages/daemon/src/server/request-router.ts @@ -5,6 +5,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'; @@ -14,7 +15,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, @@ -105,6 +106,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; @@ -149,6 +152,7 @@ export type RequestRouterDeps = { export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { const { + host, logPath, token, requestPlatformProviders = EMPTY_REQUEST_PLATFORM_PROVIDERS, @@ -195,8 +199,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) { @@ -247,6 +251,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { async () => await withResolveTargetDeviceCacheScope(async () => { scope = await createRequestExecutionScope({ + host, req, sessionStore, leaseRegistry, @@ -415,6 +420,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__/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__/session-boot-shutdown-device-claims.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-boot-shutdown-device-claims.test.ts index aaba503c53..a1ff7329dc 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,7 @@ 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 } from '@agent-device/test-kit'; import { isolatedDeviceClaimStores, retainOrphanedDeviceClaims, @@ -47,6 +47,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 +72,7 @@ async function runStateCommand( meta: { cwd: '/worktrees/daemon-b' }, }; const scope = await createRequestExecutionScope({ + host: createTestHostEnvironment(), req, sessionStore: store, leaseRegistry: new LeaseRegistry(), @@ -124,7 +126,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 +154,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 +165,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-device-claims.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts index 1c7604a3a0..b4b92c3a02 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 @@ -157,7 +157,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 () => { @@ -186,7 +186,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'); }); @@ -222,7 +222,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); }); @@ -247,7 +247,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 () => { @@ -294,7 +294,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( @@ -329,7 +329,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); @@ -363,7 +363,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); }); @@ -373,6 +373,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', @@ -421,6 +422,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(), @@ -446,12 +448,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(), @@ -514,7 +517,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( @@ -535,6 +538,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', @@ -576,7 +580,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/record-runtime.ts b/packages/daemon/src/sessions/handlers/record-runtime.ts index 855dfacd01..02f95d98bb 100644 --- a/packages/daemon/src/sessions/handlers/record-runtime.ts +++ b/packages/daemon/src/sessions/handlers/record-runtime.ts @@ -227,12 +227,17 @@ function resolveRecordingStartPaths(req: DaemonRequest, platform: Platform): Rec ...resolveRecordingOutputPaths({ requestedPath: buildRemoteTempArtifactPath('recording', extension), platform, + cwd: process.cwd(), }), clientOutputPath: appendRecordingExtensionWhenMissing(clientOutputPath, extension), }; } return { - ...resolveRecordingOutputPaths({ requestedPath, platform, cwd: req.meta?.cwd }), + ...resolveRecordingOutputPaths({ + requestedPath, + platform, + cwd: req.meta?.cwd ?? process.cwd(), + }), ...(clientOutputPath === undefined ? {} : { clientOutputPath }), }; } diff --git a/packages/daemon/src/sessions/handlers/session-app-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-deployment.ts index 7290f6c5dd..0554ad36cd 100644 --- a/packages/daemon/src/sessions/handlers/session-app-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-deployment.ts @@ -74,7 +74,7 @@ export async function handleAppDeploymentCommand(params: { try { const appPath = uploadedArtifactId ? prepareUploadedArtifact(uploadedArtifactId, req.meta?.tenantId) - : expandSessionPath(target.appPathInput); + : expandSessionPath(target.appPathInput, process.cwd()); if (!fs.existsSync(appPath)) { return errorResponse('INVALID_ARGS', `App binary not found: ${appPath}`); } @@ -300,7 +300,7 @@ 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 ?? process.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 0e28cb005a..9fafc8326e 100644 --- a/packages/daemon/src/sessions/handlers/session-appstate-device-runtime.ts +++ b/packages/daemon/src/sessions/handlers/session-appstate-device-runtime.ts @@ -12,7 +12,7 @@ import type { RuntimeCommandHandlerParams } from '../../core/session-runtime-adm import { appleSessionRequiredRefusal } from './session-appstate-apple-session.ts'; function sortedAndroidSerialAllowlist(value: string | undefined): string[] | undefined { - const allowlist = resolveAndroidSerialAllowlist(value); + const allowlist = resolveAndroidSerialAllowlist(value, process.env); return allowlist ? [...allowlist].sort() : undefined; } diff --git a/packages/daemon/src/sessions/handlers/session-boot.ts b/packages/daemon/src/sessions/handlers/session-boot.ts index c71200b8a0..35a19d77fb 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, + process.env, ); const androidSerialAllowlist = resolvedAndroidSerialAllowlist ? [...resolvedAndroidSerialAllowlist].sort() 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.ts b/packages/daemon/src/sessions/handlers/session-doctor.ts index dbb5cbb0f0..79c53d150f 100644 --- a/packages/daemon/src/sessions/handlers/session-doctor.ts +++ b/packages/daemon/src/sessions/handlers/session-doctor.ts @@ -274,7 +274,10 @@ async function resolveDoctorAppInventory(params: { device, appsRuntimeUse, ); - const androidSerialAllowlist = resolveAndroidSerialAllowlist(req.flags?.androidDeviceAllowlist); + const androidSerialAllowlist = resolveAndroidSerialAllowlist( + req.flags?.androidDeviceAllowlist, + process.env, + ); const readyDevice = await ensureAppsRuntimeReady(runtime, { serial: req.flags?.serial, androidSerialAllowlist: androidSerialAllowlist ? [...androidSerialAllowlist].sort() : undefined, diff --git a/packages/daemon/src/sessions/handlers/trace-runtime.ts b/packages/daemon/src/sessions/handlers/trace-runtime.ts index bbb4cb3319..4aa02501f8 100644 --- a/packages/daemon/src/sessions/handlers/trace-runtime.ts +++ b/packages/daemon/src/sessions/handlers/trace-runtime.ts @@ -32,7 +32,10 @@ function startTrace( ): 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), + process.cwd(), + ); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.appendFileSync(outPath, ''); session.trace = { outPath, startedAt: Date.now() }; @@ -74,7 +77,7 @@ function stopTrace( function relocateTraceOutput(currentPath: string, requestedPath: string | undefined): string { if (!requestedPath) return currentPath; - const resolved = expandSessionPath(requestedPath); + const resolved = expandSessionPath(requestedPath, process.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..edb963de0b 100644 --- a/packages/daemon/src/sessions/lease-lifecycle.ts +++ b/packages/daemon/src/sessions/lease-lifecycle.ts @@ -91,7 +91,7 @@ export async function endLeasedSession( data: { reason, leaseId, session: session.name, error: errorMessage(error) }, }); }); - await clearDeviceClaim(session.deviceClaim).catch((error) => { + await clearDeviceClaim(session.deviceClaim, process.env).catch((error) => { emitDiagnostic({ level: 'warn', phase: 'leased_session_end_device_claim_clear_failed', 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..98b78c5369 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 @@ -48,6 +48,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(), @@ -105,7 +106,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 +151,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(), @@ -222,7 +224,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 +262,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(), @@ -296,7 +299,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; diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts b/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts index d83e36c570..8dd2b2f9ff 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts @@ -169,7 +169,7 @@ async function devicesInventoryResponse(req: DaemonRequest): Promise { const owners = new Map(); - for (const entry of inspectDeviceClaims({})) { + for (const entry of inspectDeviceClaims({}, process.env)) { const claim = entry.claim; if (!claim || deviceClaimOwnerCannotRelease(entry.classification)) continue; owners.set(claim.deviceKey, { @@ -220,11 +220,12 @@ function inventoryIosSimulatorSetPath( requestedSetPath: configuredPath, platform, target, + env: process.env, }); } function inventoryAndroidSerialAllowlist(configuredAllowlist: string | undefined) { - const allowlist = resolveAndroidSerialAllowlist(configuredAllowlist); + const allowlist = resolveAndroidSerialAllowlist(configuredAllowlist, process.env); return allowlist ? Array.from(allowlist).sort() : undefined; } @@ -362,6 +363,7 @@ async function handleAppsInventory(params: { const resolvedAndroidSerialAllowlist = resolveAndroidSerialAllowlist( req.flags?.androidDeviceAllowlist, + process.env, ); const runtimeResolution = await resolveAppsRuntime({ device, 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 3286112e38..783454ae65 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts @@ -351,7 +351,7 @@ async function runCloseTeardownAndRelease(params: { }); } } else { - await clearDeviceClaim(session.deviceClaim); + await clearDeviceClaim(session.deviceClaim, process.env); } sessionStore.retire(ref); if (deviceClaimBlockingError) throw deviceClaimBlockingError; 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..5e2866745e 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 @@ -148,7 +148,7 @@ export async function renewOpenSessionClaim( ownership: DeviceClaimSessionOwnership | undefined, ): Promise { if (!ownership) return undefined; - const renewal = await renewDeviceClaim(ownership); + const renewal = await renewDeviceClaim(ownership, process.env); if (renewal.status === 'renewed') return undefined; if (renewal.conflict) return buildDeviceClaimConflictError(device, renewal.conflict); return errorResponse( @@ -430,6 +430,7 @@ async function acquireDeviceClaimForOwner(params: { session: sessionName, workspace: req.meta?.cwd ?? process.cwd(), stateDir: sessionStore.resolveDaemonStateDir(), + env: process.env, reconcileOrphanedDeviceClaim, observeDeviceBoot: platformServices.deviceBootObservation, }); @@ -564,11 +565,11 @@ async function rollbackNewSessionClaim(params: { const { ownership, effects, sessionName, sessionStore } = params; if (!ownership) return; if (!effects.mayHaveStarted) { - await clearDeviceClaim(ownership); + await clearDeviceClaim(ownership, process.env); return; } if (sessionStore.get(sessionName)?.deviceClaim?.ownerToken === ownership.ownerToken) return; - const outcome = await abandonDeviceClaim(ownership); + const outcome = await abandonDeviceClaim(ownership, process.env); emitDiagnostic({ level: 'warn', phase: 'device_claim_open_effects_unconfirmed', 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..6d773a6088 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 @@ -143,7 +143,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 ?? process.cwd()) : undefined, artifactsDir: path.join( params.sessionStore.ensureSessionDir(params.sessionName), @@ -176,7 +176,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 ?? process.cwd()), outPath, template: plan.request.template ?? (last?.kind === 'xctrace' ? last.template : undefined), profile: last, @@ -262,7 +262,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 ?? process.cwd()), + ); } const completion = await finishLivePerfCapture({ intent: 'capture', @@ -393,7 +395,7 @@ 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 ?? process.cwd()); return path.join( params.sessionStore.ensureSessionDir(params.sessionName), `${timestampToken()}-${fallbackFileName}`, diff --git a/packages/device-selection/src/dispatch-resolve.ts b/packages/device-selection/src/dispatch-resolve.ts index bd0382c93e..b2f17b290f 100644 --- a/packages/device-selection/src/dispatch-resolve.ts +++ b/packages/device-selection/src/dispatch-resolve.ts @@ -210,8 +210,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/package.json b/packages/host-kit/package.json index 8e1f564f7b..e16e742334 100644 --- a/packages/host-kit/package.json +++ b/packages/host-kit/package.json @@ -52,6 +52,10 @@ "types": "./src/file.ts", "default": "./src/file.ts" }, + "./host-environment": { + "types": "./src/host-environment.ts", + "default": "./src/host-environment.ts" + }, "./host-file": { "types": "./src/host-file.ts", "default": "./src/host-file.ts" diff --git a/packages/host-kit/src/command.ts b/packages/host-kit/src/command.ts index 031846e8d8..6cff3576ba 100644 --- a/packages/host-kit/src/command.ts +++ b/packages/host-kit/src/command.ts @@ -22,6 +22,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/host-environment.ts b/packages/host-kit/src/host-environment.ts new file mode 100644 index 0000000000..6776993ad2 --- /dev/null +++ b/packages/host-kit/src/host-environment.ts @@ -0,0 +1,15 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; + +/** The process-shaped object an entry point captures: Node's `process` satisfies it. */ +export type HostEnvironmentSource = Readonly<{ + env: HostEnvironment['env']; + cwd(): string; +}>; + +/** + * 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() }; +} diff --git a/packages/host-kit/src/internal/exec.test.ts b/packages/host-kit/src/internal/exec.test.ts index 20bb597104..da03e15f37 100644 --- a/packages/host-kit/src/internal/exec.test.ts +++ b/packages/host-kit/src/internal/exec.test.ts @@ -14,10 +14,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 +162,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 +193,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 +208,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 +277,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 +327,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 +450,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 +490,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 +520,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 +545,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 +562,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( diff --git a/packages/host-kit/src/internal/exec.ts b/packages/host-kit/src/internal/exec.ts index 0df5318cde..d716cf0df6 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,38 @@ 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); + return await commandHostScope.run(scope, fn); } /** 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 +300,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 +308,9 @@ export async function whichCmd(cmd: string): Promise { return isExecutablePath(candidate); } - const pathValue = process.env.PATH; + const pathValue = 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 +541,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 +825,9 @@ function normalizeExecutableLookup(cmd: string): string | null { return BARE_COMMAND_RE.test(candidate) ? candidate : null; } -function resolvePathExtensions(): string[] { +function resolvePathExtensions(env: EnvMap): string[] { if (process.platform !== 'win32') return ['']; - const rawPathExt = process.env.PATHEXT; + const rawPathExt = env.PATHEXT; if (!rawPathExt) return WINDOWS_PATH_EXTENSIONS; const extensions = rawPathExt .split(';') diff --git a/packages/host-kit/src/internal/host-process.ts b/packages/host-kit/src/internal/host-process.ts index fdde3acb22..ef9805ea18 100644 --- a/packages/host-kit/src/internal/host-process.ts +++ b/packages/host-kit/src/internal/host-process.ts @@ -42,22 +42,10 @@ type HostProcessRunCommand = ( options: ExecOptions, ) => Promise; -export function hostEnvironment(): NodeJS.ProcessEnv { - return process.env; -} - -export function readHostEnvironmentVariable(name: string): string | undefined { - return hostEnvironment()[name]; -} - 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..726922a21d 100644 --- a/packages/host-kit/src/process.ts +++ b/packages/host-kit/src/process.ts @@ -4,8 +4,6 @@ export { type ConfirmedProcessStopDetails, describeUnconfirmedProcessStop, expandProcessTree, - hostCurrentWorkingDirectory, - hostEnvironment, hostNodeExecutablePath, hostNodeVersion, hostPlatform, @@ -23,7 +21,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..5392328db2 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: EnvMap; + cwd: string; +}>; diff --git a/packages/platform-android/src/app-deployment.ts b/packages/platform-android/src/app-deployment.ts index 710c5b78c5..cebcf7d786 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', process.env)) { const invocation = { cmd: 'bundletool', prefixArgs: [] } as const; cachedBundletoolInvocation = { key: cacheKey, invocation }; return invocation; diff --git a/packages/platform-apple/src/core/debug-symbols.ts b/packages/platform-apple/src/core/debug-symbols.ts index b70a0251ad..d8e74d10bc 100644 --- a/packages/platform-apple/src/core/debug-symbols.ts +++ b/packages/platform-apple/src/core/debug-symbols.ts @@ -5,7 +5,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 +25,7 @@ export async function symbolicateCrashArtifact( hint: 'Use debug symbols --artifact --dsym or --search-path --out .', }); } - const cwd = options.cwd ?? hostCurrentWorkingDirectory(); + const cwd = options.cwd ?? process.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..ed53e4bfc1 100644 --- a/packages/platform-apple/src/core/tool-provider.ts +++ b/packages/platform-apple/src/core/tool-provider.ts @@ -76,7 +76,7 @@ const localAppleToolProvider: AppleToolProvider = { runCmd, async (plistPath) => await readPlistJsonWithCommand(runCmd, plistPath), ), - whichCommand: whichCmd, + whichCommand: async (cmd) => await whichCmd(cmd, process.env), }; const appleToolProviderScope = createScopedProvider( diff --git a/packages/platform-apple/src/os/macos/app-backend.ts b/packages/platform-apple/src/os/macos/app-backend.ts index 5f8d6974a4..8a41e983de 100644 --- a/packages/platform-apple/src/os/macos/app-backend.ts +++ b/packages/platform-apple/src/os/macos/app-backend.ts @@ -1,7 +1,6 @@ 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) => process.env[name]); } diff --git a/packages/platform-apple/src/os/macos/helper.ts b/packages/platform-apple/src/os/macos/helper.ts index 97cc51f146..6f73fccdac 100644 --- a/packages/platform-apple/src/os/macos/helper.ts +++ b/packages/platform-apple/src/os/macos/helper.ts @@ -15,11 +15,7 @@ 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, @@ -120,7 +116,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 (process.env[MACOS_GHOST_CURSOR_ENV]?.trim() === '0') return; args.push('--ghost-cursor'); } @@ -235,7 +231,7 @@ async function readInstalledMacOsHelperFingerprint(): Promise { async function ensureMacOsHelperBinary(): Promise { const configuredPath = await resolveExecutableOverridePath( - readHostEnvironmentVariable(MACOS_HELPER_ENV_PATH), + process.env[MACOS_HELPER_ENV_PATH], MACOS_HELPER_ENV_PATH, ); if (configuredPath) { @@ -274,7 +270,7 @@ async function ensureMacOsHelperBinary(): Promise { } async function resolveMacOsHelperCommandPath(): Promise { - const configuredPath = readHostEnvironmentVariable(MACOS_HELPER_ENV_PATH)?.trim(); + const configuredPath = process.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-harmonyos/src/__tests__/hdc.test.ts b/packages/platform-harmonyos/src/__tests__/hdc.test.ts index 06a396aacd..7fb6afeb80 100644 --- a/packages/platform-harmonyos/src/__tests__/hdc.test.ts +++ b/packages/platform-harmonyos/src/__tests__/hdc.test.ts @@ -7,13 +7,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, @@ -143,10 +138,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..7260c3e27a 100644 --- a/packages/platform-harmonyos/src/hdc.ts +++ b/packages/platform-harmonyos/src/hdc.ts @@ -11,7 +11,6 @@ import { type ExecOptions, type ExecResult, } from '@agent-device/host-kit/command'; -import { hostEnvironment } from '@agent-device/host-kit/process'; export type HarmonyHdcOptions = Pick< ExecOptions, @@ -50,7 +49,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 = process.env, ): Promise { const toolchainRoots = [ env.HDC_SDK_PATH, diff --git a/packages/platform-linux/src/linux-env.ts b/packages/platform-linux/src/linux-env.ts index 24afee8704..9357327294 100644 --- a/packages/platform-linux/src/linux-env.ts +++ b/packages/platform-linux/src/linux-env.ts @@ -6,14 +6,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 = process.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..296cb2b885 100644 --- a/packages/platform-linux/src/tool-provider.ts +++ b/packages/platform-linux/src/tool-provider.ts @@ -8,7 +8,6 @@ import { type ExecResult, } 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 +78,14 @@ export type LinuxToolProvider = { input?: LinuxInputProvider; }; +const whichOnPath = async (cmd: string): Promise => await whichCmd(cmd, process.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 +308,6 @@ async function resolveLocalLinuxTool( } function isLinuxWayland(): boolean { - const environment = hostEnvironment(); + const environment = process.env; return Boolean(environment['WAYLAND_DISPLAY']) || environment['XDG_SESSION_TYPE'] === 'wayland'; } diff --git a/packages/platform-vega/src/tool-provider.ts b/packages/platform-vega/src/tool-provider.ts index 4601b266e5..f918b3ba77 100644 --- a/packages/platform-vega/src/tool-provider.ts +++ b/packages/platform-vega/src/tool-provider.ts @@ -93,7 +93,7 @@ let cachedVegaCliExecutable: string | undefined; async function resolveVegaCliExecutable(): Promise { if (cachedVegaCliExecutable) return cachedVegaCliExecutable; - if (await whichCmd('vega')) { + if (await whichCmd('vega', process.env)) { cachedVegaCliExecutable = 'vega'; return cachedVegaCliExecutable; } diff --git a/packages/platform-web/src/agent-browser-install.ts b/packages/platform-web/src/agent-browser-install.ts index 8b4093ba30..a454086635 100644 --- a/packages/platform-web/src/agent-browser-install.ts +++ b/packages/platform-web/src/agent-browser-install.ts @@ -7,7 +7,6 @@ import { writeHostTextFileSync, } from '@agent-device/host-kit/host-file'; import { - hostEnvironment, hostNodeExecutablePath, hostNodeVersion, hostPlatform, @@ -35,7 +34,7 @@ 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: process.env, timeoutMs: params.timeoutMs }); } export function writeManagedAgentBrowserManifest(params: { @@ -66,7 +65,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(process.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.ts b/packages/platform-web/src/agent-browser-lifecycle.ts index 43c3a63e59..3b405a1e24 100644 --- a/packages/platform-web/src/agent-browser-lifecycle.ts +++ b/packages/platform-web/src/agent-browser-lifecycle.ts @@ -3,7 +3,6 @@ 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 +62,7 @@ export async function cleanupManagedAgentBrowserOrphans( return skippedCleanupResult('open-web-session', { openWebSessionNames }); } - const idleTimeoutMs = resolveAgentBrowserIdleTimeoutMs(hostEnvironment()); + const idleTimeoutMs = resolveAgentBrowserIdleTimeoutMs(process.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-tool.ts b/packages/platform-web/src/agent-browser-tool.ts index e00003d72b..62d16efade 100644 --- a/packages/platform-web/src/agent-browser-tool.ts +++ b/packages/platform-web/src/agent-browser-tool.ts @@ -12,8 +12,6 @@ import { removeHostFileSync, } from '@agent-device/host-kit/host-file'; import { - hostCurrentWorkingDirectory, - hostEnvironment, hostNodeExecutablePath, hostNodeVersion, hostPlatform, @@ -126,8 +124,7 @@ export async function doctorManagedAgentBrowser(options: { export function getManagedAgentBrowserStatus(options: { stateDir?: string; }): AgentBrowserToolStatus { - const stateDir = - options.stateDir ?? hostEnvironment().AGENT_DEVICE_STATE_DIR ?? defaultStateDir(); + const stateDir = options.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR ?? defaultStateDir(); const { installDir, homeDir, runtimeHomeDir, socketDir } = managedInstallPaths( stateDir, MANAGED_AGENT_BROWSER_VERSION, @@ -163,7 +160,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, process.env), timeoutMs: options.timeoutMs, signal: options.signal, stdin: options.stdin, @@ -290,7 +287,7 @@ function assertWebNodeSupported(nodeMajor: number): void { } function defaultStateDir(): string { - return path.join(hostEnvironment().HOME ?? hostCurrentWorkingDirectory(), '.agent-device'); + return path.join(process.env.HOME ?? process.cwd(), '.agent-device'); } export function mapManagedAgentBrowserError(error: unknown): AppError { 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/host-environment.ts b/packages/test-kit/src/host-environment.ts new file mode 100644 index 0000000000..2e4b93d1ce --- /dev/null +++ b/packages/test-kit/src/host-environment.ts @@ -0,0 +1,12 @@ +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 { env: { ...(options.env ?? process.env) }, cwd: options.cwd ?? process.cwd() }; +} diff --git a/packages/test-kit/src/index.ts b/packages/test-kit/src/index.ts index 8794236629..9d4078a9d6 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 { createTestHostEnvironment } from './host-environment.ts'; export { type FilesystemBoundaryFixture, type FilesystemBoundaryOwner, diff --git a/src/__tests__/daemon-composition/http-server-limrun-uploaded-apps.test.ts b/src/__tests__/daemon-composition/http-server-limrun-uploaded-apps.test.ts index 3ff8eef2ef..74f8eddbbc 100644 --- a/src/__tests__/daemon-composition/http-server-limrun-uploaded-apps.test.ts +++ b/src/__tests__/daemon-composition/http-server-limrun-uploaded-apps.test.ts @@ -10,6 +10,7 @@ import { listenOnLoopback, mkdtempForTestSync, skipWhenLoopbackUnavailable, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; import { limrunTestDependencies } from '../../platform-runtime-gateway.fixtures.ts'; @@ -53,6 +54,7 @@ test('public HTTP rejects Limrun uploaded-app listing and allocation before prov const token = 'limrun-http-test-token'; const leaseRegistry = new LeaseRegistry(); const handleRequest = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join( mkdtempForTestSync('agent-device-limrun-http-access'), diff --git a/src/__tests__/daemon-composition/request-dispatch-disclosure.test.ts b/src/__tests__/daemon-composition/request-dispatch-disclosure.test.ts index f5467e7b8d..00b6d7b25b 100644 --- a/src/__tests__/daemon-composition/request-dispatch-disclosure.test.ts +++ b/src/__tests__/daemon-composition/request-dispatch-disclosure.test.ts @@ -24,6 +24,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 { @@ -127,6 +128,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/src/__tests__/daemon-composition/request-router-android-modal.test.ts b/src/__tests__/daemon-composition/request-router-android-modal.test.ts index 9b7176b6b1..a895c02e83 100644 --- a/src/__tests__/daemon-composition/request-router-android-modal.test.ts +++ b/src/__tests__/daemon-composition/request-router-android-modal.test.ts @@ -30,7 +30,7 @@ import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import { makeTestScreenRecordingResource } from '@agent-device/daemon/testing/__tests__/support/screen-recording-live-handle'; import { androidObservation } from '../../platform-runtime.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'; vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { const actual = await importOriginal(); @@ -157,6 +157,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', @@ -210,6 +211,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', @@ -270,6 +272,7 @@ test('generic Android gesture commands skip local dialog recovery for provider d const providers = createProviderDeviceRuntimeRequestProviders([runtime]); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/src/__tests__/daemon-composition/request-router-android-perf.test.ts b/src/__tests__/daemon-composition/request-router-android-perf.test.ts index 08ff3cf155..ecccd896cd 100644 --- a/src/__tests__/daemon-composition/request-router-android-perf.test.ts +++ b/src/__tests__/daemon-composition/request-router-android-perf.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { expect, test } from 'vitest'; import { createRequestHandler } from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; @@ -41,6 +42,7 @@ function makeHandler(sessionStore: SessionStore, androidAdbProvider: () => Andro }), }); return createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: '/tmp/daemon.log', token: 'token', diff --git a/src/__tests__/daemon-composition/request-router-android-snapshot-helper.test.ts b/src/__tests__/daemon-composition/request-router-android-snapshot-helper.test.ts index be50b47ea0..6e8709e0ec 100644 --- a/src/__tests__/daemon-composition/request-router-android-snapshot-helper.test.ts +++ b/src/__tests__/daemon-composition/request-router-android-snapshot-helper.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { beforeEach, expect, test } from 'vitest'; import { createRequestHandler } from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; @@ -40,6 +41,7 @@ function makeHandler(sessionStore: SessionStore, androidAdbProvider: () => Andro }), }); return createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: '/tmp/daemon.log', token: 'token', diff --git a/src/__tests__/daemon-composition/request-router-fill-text-stdin.test.ts b/src/__tests__/daemon-composition/request-router-fill-text-stdin.test.ts index e3527c0557..572267a60a 100644 --- a/src/__tests__/daemon-composition/request-router-fill-text-stdin.test.ts +++ b/src/__tests__/daemon-composition/request-router-fill-text-stdin.test.ts @@ -18,7 +18,11 @@ import { makeAuthoringSession, makeSession, } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { mkdtempForTestSync, WEB_DESKTOP_DEVICE } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + WEB_DESKTOP_DEVICE, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { testCreateCommandSurface } from './command-surface-fixture.ts'; import { replayScriptSourceBundleFor } from '../test-utils/replay-script-source.fixtures.ts'; import { @@ -65,6 +69,7 @@ function sendToWebSession( }; const dir = mkdtempForTestSync('agent-device-router-fill-text-stdin-runtime-'); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(dir, 'daemon.log'), token: 'test-token', diff --git a/src/__tests__/daemon-composition/request-router-lock-policy.test.ts b/src/__tests__/daemon-composition/request-router-lock-policy.test.ts index a72cda18ba..279276f238 100644 --- a/src/__tests__/daemon-composition/request-router-lock-policy.test.ts +++ b/src/__tests__/daemon-composition/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/src/__tests__/daemon-composition/request-router-macos-app-lease.test.ts b/src/__tests__/daemon-composition/request-router-macos-app-lease.test.ts index 7a7c571765..37986b5cd1 100644 --- a/src/__tests__/daemon-composition/request-router-macos-app-lease.test.ts +++ b/src/__tests__/daemon-composition/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 '@agent-device/daemon/testing/__tests__/support/store-factory'; import { makeMacOsSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; import type { DaemonRequest } from '@agent-device/daemon'; @@ -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/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts index 3e23d4b72c..19966ea2ed 100644 --- a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts +++ b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts @@ -92,7 +92,7 @@ import { makeIosDevice, openRequest, } from '@agent-device/daemon/testing/__tests__/request-router-open-harness'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); @@ -101,6 +101,7 @@ const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); function createOpenHandler(sessionStore: ReturnType) { return createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -249,7 +250,9 @@ test('open reconciles a foreign dead owner through that owner state dir, never t expect(fs.existsSync(path.join(foreignSessionDir, 'owned-processes.json'))).toBe(false); expect(fs.readFileSync(liveRecordPath, 'utf8')).toBe(liveRecordBefore); expect(isProcessAlive(orphan.marker.pid)).toBe(false); - expect(inspectDeviceClaims({ udid: device.id })[0]?.claim?.session).toBe('takeover'); + expect(inspectDeviceClaims({ udid: device.id }, process.env)[0]?.claim?.session).toBe( + 'takeover', + ); } finally { try { orphan.child.kill('SIGKILL'); diff --git a/src/__tests__/daemon-composition/request-router-provider-credentials.test.ts b/src/__tests__/daemon-composition/request-router-provider-credentials.test.ts index 8282701a77..7117333f51 100644 --- a/src/__tests__/daemon-composition/request-router-provider-credentials.test.ts +++ b/src/__tests__/daemon-composition/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 '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { providerCredentialFingerprint, readDaemonProviderCredentials, @@ -22,6 +22,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/src/__tests__/daemon-composition/request-router-record-runtime-lock.test.ts b/src/__tests__/daemon-composition/request-router-record-runtime-lock.test.ts index 6f7046ce43..3b3287bb55 100644 --- a/src/__tests__/daemon-composition/request-router-record-runtime-lock.test.ts +++ b/src/__tests__/daemon-composition/request-router-record-runtime-lock.test.ts @@ -14,6 +14,7 @@ import { createTestDeviceInventoryGateways } from '@agent-device/device-selectio import { mkdtempForTestSync, unavailableDeploymentSnapshotAndShutdownOperationFacts, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; @@ -46,6 +47,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', @@ -97,6 +99,7 @@ test.each([ }); const bind = vi.fn(async (request) => await gateway.bind(request)); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: '/tmp/daemon.log', token: 'token', diff --git a/src/__tests__/daemon-composition/request-router-recording-health.test.ts b/src/__tests__/daemon-composition/request-router-recording-health.test.ts index 363c87248e..a0d41c9d3b 100644 --- a/src/__tests__/daemon-composition/request-router-recording-health.test.ts +++ b/src/__tests__/daemon-composition/request-router-recording-health.test.ts @@ -15,7 +15,7 @@ import type { SessionState } from '@agent-device/daemon/testing/session-state'; import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; import { makeTestScreenRecordingResource } from '@agent-device/daemon/testing/__tests__/support/screen-recording-live-handle'; -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. @@ -60,6 +60,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', @@ -116,6 +117,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/src/__tests__/daemon-composition/request-router-replay-scope.test.ts b/src/__tests__/daemon-composition/request-router-replay-scope.test.ts index f4739f822e..960b2cb1df 100644 --- a/src/__tests__/daemon-composition/request-router-replay-scope.test.ts +++ b/src/__tests__/daemon-composition/request-router-replay-scope.test.ts @@ -32,7 +32,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 '@agent-device/daemon/testing/__tests__/support/session-factories'; import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; @@ -73,6 +77,7 @@ test('replay runs active-session actions inside the parent request provider scop const appleRunnerProvider = vi.fn(() => undefined); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -112,6 +117,7 @@ test('replay routes session-changing actions through the full request path', asy const appleRunnerProvider = vi.fn(() => undefined); const handler = createRequestHandler({ + host: createTestHostEnvironment(), createCommandSurface: testCreateCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', @@ -148,6 +154,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', @@ -215,6 +222,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/src/__tests__/daemon-composition/request-router-replay-test-ime.test.ts b/src/__tests__/daemon-composition/request-router-replay-test-ime.test.ts index b76d96793c..eb8c211b92 100644 --- a/src/__tests__/daemon-composition/request-router-replay-test-ime.test.ts +++ b/src/__tests__/daemon-composition/request-router-replay-test-ime.test.ts @@ -36,7 +36,11 @@ vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { }; }); -import { ANDROID_DEVICE, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + ANDROID_DEVICE, + mkdtempForTestSync, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; import { replayScriptSourceBundleFor } from '../test-utils/replay-script-source.fixtures.ts'; import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; @@ -62,6 +66,7 @@ async function runAndroidFlowReplay(session: string, flags: Record | undefined; try { assert.ok(launch.startTime); await waitForFixtureFile(paths.infoPath); - contender = launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }); + contender = launchDaemonProcess({ + env: process.env, + paths, + args, + serverMode: 'socket', + ownedStateDir, + }); assert.equal((await contender.exited).exitCode, DAEMON_STARTUP_EXIT_CODES.busy); const input = { paths, @@ -472,7 +484,8 @@ test('private retirement closes startup admission, joins the actual child and ne }; const pending = stopAndRetireDaemon(input); assert.throws( - () => launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }), + () => + launchDaemonProcess({ env: process.env, paths, args, serverMode: 'socket', ownedStateDir }), (error: { details?: { reason?: string } }) => error.details?.reason === 'daemon_startup_admission_closed', ); @@ -499,11 +512,18 @@ test('private retirement retains the directory while an earlier actual startup c entry, `import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(ready)}, 'ready'); setInterval(() => {}, 1000);`, ); - const first = launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }); + const first = launchDaemonProcess({ + env: process.env, + paths, + args, + serverMode: 'socket', + ownedStateDir, + }); let second: ReturnType | undefined; try { await waitForFixtureFile(ready); second = launchDaemonProcess({ + env: process.env, paths, args: registeredDaemonFixtureArgs(paths, fields), serverMode: 'socket', @@ -549,7 +569,13 @@ for (const [failure, reason, expectedSignals] of privateStartupFailures) { failure === 'missing-birth' ? vi.spyOn(hostProcess, 'readProcessStartTime').mockReturnValueOnce(null) : undefined; - const launch = launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }); + const launch = launchDaemonProcess({ + env: process.env, + paths, + args, + serverMode: 'socket', + ownedStateDir, + }); birthProbe?.mockRestore(); let actualStartTime: string | null = null; let exitTimer: ReturnType | undefined; @@ -637,6 +663,7 @@ test('private startup completion stays bounded without published birth proof and const paths = ownedStateDir.paths; const birthProbe = vi.spyOn(hostProcess, 'readProcessStartTime').mockReturnValueOnce(null); const launch = launchDaemonProcess({ + env: process.env, paths, args: registeredDaemonFixtureArgs(paths, fields), serverMode: 'socket', @@ -660,7 +687,14 @@ test('private startup completion stays bounded without published birth proof and startupJoinTimeoutMs: 0, }); assert.throws( - () => launchDaemonProcess({ paths, args: [], serverMode: 'socket', ownedStateDir }), + () => + launchDaemonProcess({ + env: process.env, + paths, + args: [], + serverMode: 'socket', + ownedStateDir, + }), (error: { details?: { reason?: string } }) => error.details?.reason === 'daemon_startup_admission_closed', ); @@ -707,6 +741,7 @@ for (const contents of [ const evidencePath = `${sessionDir}/repair-tombstone.json`; fs.writeFileSync(evidencePath, contents); const launch = launchDaemonProcess({ + env: process.env, paths, args: registeredDaemonFixtureArgs(paths, fields), serverMode: 'socket', @@ -912,6 +947,7 @@ test.for(['different pid', 'different birth', 'joined child'] as const)( const paths = ownedStateDir.paths; const probe = vi.spyOn(hostProcess, 'readProcessStartTime').mockReturnValueOnce(null); const launch = launchDaemonProcess({ + env: process.env, paths, args: registeredDaemonFixtureArgs(paths, fields), serverMode: 'socket', 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__/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/agent-device-client.ts b/src/agent-device-client.ts index 421b6ddc9f..906989cfbd 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -75,15 +75,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/host-environment'; +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); // A non-default responseLevel (digest/full) makes the daemon return a leveled // shape; the per-command client normalizers assume the default shape, so the @@ -209,7 +212,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..21f8c33e5f 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,12 +1,18 @@ +import { captureHostEnvironment } from '@agent-device/host-kit/host-environment'; 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'), + cli: async () => { + const { runCliProcess } = await import('./cli/process-entry.ts'); + return { runCliProcess: (argv) => runCliProcess(argv, host) }; + }, mcp: () => import('./mcp/server.ts'), version: () => import('@agent-device/host-kit/version'), processExit: () => import('./cli/process-exit.ts'), diff --git a/src/cli.ts b/src/cli.ts index 8c0e8bd4c3..34cc6c913a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,9 @@ 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/host-environment'; +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'; @@ -105,14 +108,25 @@ 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 { 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( { @@ -123,12 +137,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, @@ -164,8 +180,8 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): await runAgentCdpCommand(positionals, { flags: ctx.effectiveFlags, runtime: ctx.resolvedRuntime, - cwd: process.cwd(), - env: process.env, + cwd: host.cwd, + env: host.env, }), ); return; @@ -174,6 +190,7 @@ 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({ command, flags: ctx.effectiveFlags, @@ -204,11 +221,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', @@ -269,6 +291,7 @@ async function parseCliInputOrExit( } type CliRunContext = { + host: HostEnvironment; command: string; positionals: string[]; requestId: string; @@ -291,7 +314,13 @@ 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 { @@ -307,9 +336,13 @@ async function resolveRunContextOrExit( 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, @@ -370,13 +403,14 @@ 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), { + host: ctx.host, transport: createClientDaemonTransport(deps.sendToDaemon), }); const { materializeRemoteConnectionForCommand } = @@ -451,7 +485,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, @@ -684,6 +718,7 @@ function isDaemonStartupFailure(error: AppError): boolean { } function resolveActiveConnectionDefaults(options: { + host: HostEnvironment; command: string; explicitFlagKeys: Set; stateDir: string; @@ -708,8 +743,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), diff --git a/src/cli/commands/device.ts b/src/cli/commands/device.ts index 15b2675e0f..3bc410b302 100644 --- a/src/cli/commands/device.ts +++ b/src/cli/commands/device.ts @@ -18,12 +18,15 @@ export const deviceCommand: ClientCommandHandler = async ({ positionals, flags } 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, - }); + const inspectedClaims = inspectDeviceClaims( + { + platform: flags.platform, + device: flags.device, + udid: flags.udid, + serial: flags.serial, + }, + process.env, + ); const staleClaims = inspectedClaims.filter((claim) => deviceClaimRequiresStaleInspection(claim.classification), ); @@ -66,12 +69,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, + }, + process.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/recording.ts b/src/cli/commands/recording.ts index 27311d9515..e0f1b861af 100644 --- a/src/cli/commands/recording.ts +++ b/src/cli/commands/recording.ts @@ -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: process.cwd(), + }); + const outputPath = + typeof flags.out === 'string' ? resolveUserPath(flags.out, { cwd: process.cwd() }) : undefined; // Lazy: createAgentDevice pulls in the client-side command runtime, which only this action needs. const [{ createAgentDevice }, { createLocalArtifactAdapter }] = await Promise.all([ diff --git a/src/cli/commands/replay.ts b/src/cli/commands/replay.ts index d50c4f79c5..95cdd285df 100644 --- a/src/cli/commands/replay.ts +++ b/src/cli/commands/replay.ts @@ -37,7 +37,7 @@ async function handleReplayExportCommand({ throw new AppError('INVALID_ARGS', 'replay export requires an input path.'); } - const sourcePath = resolveUserPath(inputPath); + const sourcePath = resolveUserPath(inputPath, { cwd: process.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 +50,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: process.cwd() }) : undefined; if (outputPath) { fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, result.yaml); diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts index 0e3b291236..781347e73d 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -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: process.cwd() }); + const outputPath = + typeof flags.out === 'string' ? resolveUserPath(flags.out, { cwd: process.cwd() }) : undefined; const currentRaw = positionals[1]; if (positionals.length > 2) { throw new AppError( @@ -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: process.cwd() }) } + : { kind: 'live' }, ...(outputPath ? { out: { kind: 'path', path: outputPath } } : {}), threshold: parseCliThreshold(flags.threshold), overlayRefs: flags.overlayRefs, 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/commands/management/output.ts b/src/commands/management/output.ts index 331a4302e0..f92869e741 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -188,6 +188,8 @@ 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(process.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 +205,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/daemon-client/__tests__/daemon-client-policy-reuse.test.ts b/src/daemon-client/__tests__/daemon-client-policy-reuse.test.ts index c6c56bfef9..3440fa7bfb 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,7 @@ 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)?.digest); return true; }, ); @@ -153,7 +153,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)?.digest + : options.policyDigest, })}\n`, ); if (options.publish === 'on-spawn') { diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index 205f4784b3..47ce494731 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -815,6 +815,7 @@ function startDaemon(settings: DaemonClientSettings): DaemonStartupLaunch { return launchDaemonProcess({ paths: settings.paths, serverMode: settings.serverMode, + env: process.env, ownedStateDir: settings.ownedStateDir, args: launchSpec.useSrc ? ['--experimental-strip-types', launchSpec.srcPath] diff --git a/src/daemon-composition-credentials.test.ts b/src/daemon-composition-credentials.test.ts index 995f5cf9a8..820fdc081d 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'; @@ -37,13 +37,15 @@ test('daemon startup compares lease credentials with its own startup environment const stateDir = mkdtempForTestSync('agent-device-daemon-credential-composition-'); 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 71435dfea3..db7617016b 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'; @@ -40,7 +44,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, diff --git a/src/daemon-composition.ts b/src/daemon-composition.ts index 83cc6392a4..773763d24b 100644 --- a/src/daemon-composition.ts +++ b/src/daemon-composition.ts @@ -75,7 +75,7 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( input, ): Promise => { const { - env, + host: { env }, baseDir, sessionsDir, assertShutdownAllowed, diff --git a/src/daemon-entrypoint.test.ts b/src/daemon-entrypoint.test.ts index 7f543ea4de..eb5be6505b 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: () => {} }, @@ -309,12 +316,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; }, @@ -435,12 +444,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: () => {} }, diff --git a/src/daemon.ts b/src/daemon.ts index d6d1acafd8..45f2192fb2 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/host-environment'; // 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/platform-runtime-claim-recovery.ts b/src/platform-runtime-claim-recovery.ts index e63c5a6a33..82104cd75f 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, @@ -35,10 +36,12 @@ 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, diff --git a/src/platform-runtime-host.ts b/src/platform-runtime-host.ts index 4fd67d6dee..36a4efde35 100644 --- a/src/platform-runtime-host.ts +++ b/src/platform-runtime-host.ts @@ -17,7 +17,8 @@ import { guardedHostCommandArgv } from './platform-runtime-host-device-shell.ts' export function createDeviceInventoryHost(): DeviceInventoryHost { return Object.freeze({ commands: Object.freeze({ - which: async (executable: string) => ((await whichCmd(executable)) ? executable : undefined), + which: async (executable: string) => + (await whichCmd(executable, process.env)) ? executable : undefined, run: async (request: HostCommandRequest, signal?: AbortSignal) => { const result = await runCmd(request.executable, guardedHostCommandArgv(request), { allowFailure: request.allowFailure, diff --git a/src/platform-runtime-operation-host.ts b/src/platform-runtime-operation-host.ts index 3fbb279478..e533ee5704 100644 --- a/src/platform-runtime-operation-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -64,7 +64,8 @@ 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, process.env)) ? executable : undefined, run: async (request: HostCommandRequest, signal?: AbortSignal) => { const result = await runCmd(request.executable, guardedHostCommandArgv(request), { allowFailure: request.allowFailure, diff --git a/test/integration/nightly/concurrency-torture/real-scope-serialization.ts b/test/integration/nightly/concurrency-torture/real-scope-serialization.ts index 8644b84ade..d8480c15af 100644 --- a/test/integration/nightly/concurrency-torture/real-scope-serialization.ts +++ b/test/integration/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: composedPlatformServicesFixture(), claimRecoveryGateway: composedClaimRecoveryGatewayFixture(), req: openRequest(`real-${i}`, device.id), diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 702e3a97be..5c7500142f 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -166,6 +166,7 @@ export async function createProviderScenarioHarness( }) : unavailableDeviceRuntimeGateway); const requestHandler = createRequestHandler({ + host: { env: process.env, cwd: process.cwd() }, createCommandSurface: testCreateCommandSurface, logPath: path.join(os.tmpdir(), 'agent-device-provider-scenario-daemon.log'), token: PROVIDER_SCENARIO_TOKEN, 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}` } }); }, From f4d4970035267f0fe1ada97a37f9b1a7ac785d08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:05:22 +0200 Subject: [PATCH 02/33] fix(host): resolve relative state dirs against the captured working directory Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/request-execution-scope.test.ts | 15 +++++++++ .../http/daemon-runtime-composition.test.ts | 31 +++++++++++++++++++ .../host-kit/src/host-environment.test.ts | 16 ++++++++++ .../src/metro/metro-session-hints.ts | 4 ++- scripts/clean-daemon.ts | 4 +-- scripts/print-daemon-state-dir.ts | 2 +- src/cli/commands/auth.ts | 2 +- src/cli/commands/connection-runtime.ts | 2 +- src/cli/commands/connection.ts | 4 +-- src/cli/commands/daemon.ts | 2 +- src/cli/connection/ended-lease.ts | 2 +- src/cli/connection/limrun-profile.ts | 2 +- src/daemon-client/daemon-client-lifecycle.ts | 2 +- src/daemon-client/daemon-client-metadata.ts | 1 + 14 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 packages/host-kit/src/host-environment.test.ts 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 514fe42dda..3bb5ed85e3 100644 --- a/packages/daemon/src/server/__tests__/request-execution-scope.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-scope.test.ts @@ -71,6 +71,21 @@ test('createRequestExecutionScope applies tenant scoping and locked lease admiss expect(admittedLeaseId).toBe(lease.leaseId); }); +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 createTestRequestScope({ + host, + req: makeRequest(), + sessionStore: makeSessionStore('agent-device-request-scope-host-'), + leaseRegistry: new LeaseRegistry(), + }); + + expect(scope.host).toBe(host); +}); + test('createRequestExecutionScope is the single defaulting authority for the apps filter', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-defaults-'); const leaseRegistry = new LeaseRegistry(); 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 fc1e0c6027..98bf37f55e 100644 --- a/packages/daemon/src/server/http/daemon-runtime-composition.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-composition.test.ts @@ -105,6 +105,37 @@ test('a daemon attempt losing the lock shuts down every constructed provider', a 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 diff --git a/packages/host-kit/src/host-environment.test.ts b/packages/host-kit/src/host-environment.test.ts new file mode 100644 index 0000000000..47d916048e --- /dev/null +++ b/packages/host-kit/src/host-environment.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from 'vitest'; +import { captureHostEnvironment } from './host-environment.ts'; + +test('captureHostEnvironment copies the environment and reads the working directory once', () => { + const source = { + env: { HOME: '/home/a', EMPTY: undefined } as Record, + cwd: () => '/work', + }; + const host = captureHostEnvironment(source); + + source.env.HOME = '/home/b'; + source.env.ADDED = 'late'; + + expect(host).toEqual({ env: { HOME: '/home/a', EMPTY: undefined }, cwd: '/work' }); + expect(host.env).not.toBe(source.env); +}); 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/scripts/clean-daemon.ts b/scripts/clean-daemon.ts index ce3c39ce00..b71187b242 100644 --- a/scripts/clean-daemon.ts +++ b/scripts/clean-daemon.ts @@ -14,7 +14,7 @@ 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, { cwd: process.cwd() }); const retirement = await stopAndRetireDaemon({ paths, observed: readRegisteredDaemonIdentity(paths.infoPath), @@ -48,7 +48,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..7c838e5306 100644 --- a/scripts/print-daemon-state-dir.ts +++ b/scripts/print-daemon-state-dir.ts @@ -1,5 +1,5 @@ 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, { cwd: process.cwd() }); process.stdout.write(`${paths.baseDir}\n`); diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index ca41919870..0e0dabdb79 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -6,7 +6,7 @@ import type { ClientCommandHandler } from './router-types.ts'; export const authCommand: ClientCommandHandler = async ({ positionals, flags }) => { const subcommand = positionals[0] ?? 'status'; - const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const stateDir = resolveDaemonPaths(flags.stateDir, { cwd: process.cwd() }).baseDir; if (subcommand === 'status') { const status = summarizeCliSession({ stateDir }); await writeCommandOutput(flags, status, () => renderAuthStatus(status)); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 50de3a2dec..cc487092ed 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -74,7 +74,7 @@ export async function materializeRemoteConnectionForCommand(options: { return { flags, runtime: options.runtime }; } - const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const stateDir = resolveDaemonPaths(flags.stateDir, { cwd: process.cwd() }).baseDir; const remoteConfig = resolveRemoteConfigProfile({ configPath: flags.remoteConfig, cwd: process.cwd(), diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 22b07570e8..b8fce84b5e 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -53,7 +53,7 @@ import { } from './connection-presentation.ts'; export const connectCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { - const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const stateDir = resolveDaemonPaths(flags.stateDir, { cwd: process.cwd() }).baseDir; const provider = readConnectProvider(positionals); assertConnectProviderUsage(provider, flags); const resolved = await resolveConnectProviderProfile({ provider, flags, stateDir }); @@ -417,7 +417,7 @@ function readRequestedConnectionState(flags: CliFlags): { state: RemoteConnectionState | null; } { const session = flags.session ?? 'default'; - const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const stateDir = resolveDaemonPaths(flags.stateDir, { cwd: process.cwd() }).baseDir; return { session, stateDir, diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 0cac73a814..3f7eb97a73 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -17,7 +17,7 @@ export const daemonCommand: ClientCommandHandler = async ({ positionals, flags } if (subcommand !== 'stop' || positionals.length !== 1) { throw new AppError('INVALID_ARGS', 'daemon accepts only: stop'); } - const paths = resolveDaemonPaths(flags.stateDir); + const paths = resolveDaemonPaths(flags.stateDir, { cwd: process.cwd() }); const identity = readDaemonStopIdentity(paths.infoPath); const stopped = await stopDaemon({ paths }); const report = stopped.mode === 'graceful' ? readDaemonShutdownReport(paths.baseDir) : null; diff --git a/src/cli/connection/ended-lease.ts b/src/cli/connection/ended-lease.ts index 0076a4412d..42918144e1 100644 --- a/src/cli/connection/ended-lease.ts +++ b/src/cli/connection/ended-lease.ts @@ -17,7 +17,7 @@ export function forgetEndedProxyLease(flags: CliFlags, response: DaemonResponse) 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: process.cwd() }).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/limrun-profile.ts b/src/cli/connection/limrun-profile.ts index e2868b5901..4358f60578 100644 --- a/src/cli/connection/limrun-profile.ts +++ b/src/cli/connection/limrun-profile.ts @@ -47,7 +47,7 @@ export function resolveLimrunConnectProfile(options: { function buildLimrunRemoteProfile(options: { flags: CliFlags }): RemoteConfigProfile { const flags = options.flags; const leaseBackend = validateLimrunConnectFlags(flags); - const daemonPaths = resolveDaemonPaths(flags.stateDir); + const daemonPaths = resolveDaemonPaths(flags.stateDir, { cwd: process.cwd() }); return { stateDir: daemonPaths.baseDir, daemonTransport: 'auto', diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index 47ce494731..dadc50f169 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -94,7 +94,7 @@ export function resolveClientSettings( ? createOwnedReplayStateDir() : undefined; return { - paths: ownedStateDir?.paths ?? resolveDaemonPaths(explicitStateDir), + paths: ownedStateDir?.paths ?? resolveDaemonPaths(explicitStateDir, { cwd: process.cwd() }), transportPreference: transport.preference, serverMode: transport.serverMode, ownedStateDir, diff --git a/src/daemon-client/daemon-client-metadata.ts b/src/daemon-client/daemon-client-metadata.ts index 5f35583f15..e295d3f0be 100644 --- a/src/daemon-client/daemon-client-metadata.ts +++ b/src/daemon-client/daemon-client-metadata.ts @@ -96,6 +96,7 @@ export function resolveDaemonStartupHint( state: { hasInfo: boolean; hasLock: boolean }, paths: Pick = resolveDaemonPaths( process.env.AGENT_DEVICE_STATE_DIR, + { cwd: process.cwd() }, ), ): string { const artifacts = [state.hasInfo ? paths.infoPath : null, state.hasLock ? paths.lockPath : null] From 2ebb8869c74303ea0d88f6bea24ca9f25597af9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:26:54 +0200 Subject: [PATCH 03/33] fix(host): give platform code the command host instead of process reads Platform packages may not read process; they take the environment and working directory of the enclosing command host. Unit tests enter the live process environment as their host. Co-Authored-By: Claude Sonnet 5.5 --- .../request-execution-scope-host.test.ts | 26 +++++++ .../__tests__/request-execution-scope.test.ts | 15 ---- .../request-router-developer-dir.test.ts | 75 ++++++++----------- .../daemon/src/server/http/daemon-runtime.ts | 9 ++- packages/host-kit/package.json | 4 - packages/host-kit/src/command.ts | 2 + .../host-kit/src/host-environment.test.ts | 16 ---- packages/host-kit/src/host-environment.ts | 15 ---- packages/host-kit/src/internal/exec.ts | 22 ++++++ .../src/internal/host-process.test.ts | 15 ++++ .../host-kit/src/internal/host-process.ts | 15 ++++ packages/host-kit/src/process.ts | 2 + .../platform-android/src/app-deployment.ts | 9 ++- .../platform-apple/src/core/debug-symbols.ts | 3 +- .../platform-apple/src/core/tool-provider.ts | 3 +- .../src/os/macos/app-backend.ts | 3 +- .../platform-apple/src/os/macos/helper.ts | 7 +- packages/platform-harmonyos/src/hdc.ts | 3 +- packages/platform-linux/src/linux-env.ts | 3 +- packages/platform-linux/src/tool-provider.ts | 6 +- packages/platform-vega/src/tool-provider.ts | 3 +- .../platform-web/src/agent-browser-install.ts | 9 ++- .../src/agent-browser-lifecycle.ts | 3 +- .../platform-web/src/agent-browser-tool.ts | 12 ++- src/__tests__/host-environment-setup.ts | 20 +++++ src/agent-device-client.ts | 2 +- src/bin.ts | 14 +++- src/cli.ts | 2 +- src/daemon.ts | 2 +- vitest.config.ts | 1 + 30 files changed, 202 insertions(+), 119 deletions(-) create mode 100644 packages/daemon/src/server/__tests__/request-execution-scope-host.test.ts delete mode 100644 packages/host-kit/src/host-environment.test.ts delete mode 100644 packages/host-kit/src/host-environment.ts create mode 100644 src/__tests__/host-environment-setup.ts 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.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope.test.ts index 3bb5ed85e3..514fe42dda 100644 --- a/packages/daemon/src/server/__tests__/request-execution-scope.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-scope.test.ts @@ -71,21 +71,6 @@ test('createRequestExecutionScope applies tenant scoping and locked lease admiss expect(admittedLeaseId).toBe(lease.leaseId); }); -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 createTestRequestScope({ - host, - req: makeRequest(), - sessionStore: makeSessionStore('agent-device-request-scope-host-'), - leaseRegistry: new LeaseRegistry(), - }); - - expect(scope.host).toBe(host); -}); - test('createRequestExecutionScope is the single defaulting authority for the apps filter', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-defaults-'); const leaseRegistry = new LeaseRegistry(); 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 f836088b07..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 @@ -8,48 +8,39 @@ import { createRequestHandler } from '../../__tests__/support/test-device-runtim 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({ - host: createTestHostEnvironment(), - 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/http/daemon-runtime.ts b/packages/daemon/src/server/http/daemon-runtime.ts index 9db1626ab4..9e245c567c 100644 --- a/packages/daemon/src/server/http/daemon-runtime.ts +++ b/packages/daemon/src/server/http/daemon-runtime.ts @@ -854,7 +854,7 @@ async function startDaemonRuntimeOnHost( // 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(); @@ -932,6 +932,13 @@ async function startDaemonRuntimeOnHost( 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), + ); + if (options.registerProcessHandlers !== false) { process.on('SIGINT', () => { void shutdown(); diff --git a/packages/host-kit/package.json b/packages/host-kit/package.json index e16e742334..8e1f564f7b 100644 --- a/packages/host-kit/package.json +++ b/packages/host-kit/package.json @@ -52,10 +52,6 @@ "types": "./src/file.ts", "default": "./src/file.ts" }, - "./host-environment": { - "types": "./src/host-environment.ts", - "default": "./src/host-environment.ts" - }, "./host-file": { "types": "./src/host-file.ts", "default": "./src/host-file.ts" diff --git a/packages/host-kit/src/command.ts b/packages/host-kit/src/command.ts index 6cff3576ba..5b3bfba69d 100644 --- a/packages/host-kit/src/command.ts +++ b/packages/host-kit/src/command.ts @@ -1,5 +1,7 @@ export { coerceExecResult, + enterCommandHost, + commandHostEnvironment, commandDeveloperDir, type CommandExecutorOverride, type ExecBackgroundOptions, diff --git a/packages/host-kit/src/host-environment.test.ts b/packages/host-kit/src/host-environment.test.ts deleted file mode 100644 index 47d916048e..0000000000 --- a/packages/host-kit/src/host-environment.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, test } from 'vitest'; -import { captureHostEnvironment } from './host-environment.ts'; - -test('captureHostEnvironment copies the environment and reads the working directory once', () => { - const source = { - env: { HOME: '/home/a', EMPTY: undefined } as Record, - cwd: () => '/work', - }; - const host = captureHostEnvironment(source); - - source.env.HOME = '/home/b'; - source.env.ADDED = 'late'; - - expect(host).toEqual({ env: { HOME: '/home/a', EMPTY: undefined }, cwd: '/work' }); - expect(host.env).not.toBe(source.env); -}); diff --git a/packages/host-kit/src/host-environment.ts b/packages/host-kit/src/host-environment.ts deleted file mode 100644 index 6776993ad2..0000000000 --- a/packages/host-kit/src/host-environment.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { HostEnvironment } from '@agent-device/kernel/source-value'; - -/** The process-shaped object an entry point captures: Node's `process` satisfies it. */ -export type HostEnvironmentSource = Readonly<{ - env: HostEnvironment['env']; - cwd(): string; -}>; - -/** - * 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() }; -} diff --git a/packages/host-kit/src/internal/exec.ts b/packages/host-kit/src/internal/exec.ts index d716cf0df6..783a56c5d6 100644 --- a/packages/host-kit/src/internal/exec.ts +++ b/packages/host-kit/src/internal/exec.ts @@ -129,6 +129,28 @@ export async function withCommandHost( return await commandHostScope.run(scope, fn); } +/** + * Binds `scope` for the rest of the current async context, for a process root that has no single + * callback to wrap. Prefer {@link withCommandHost}. + */ +export function enterCommandHost(scope: CommandHostScope): void { + commandHostScope.enterWith(scope); +} + +/** + * 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 scope = commandHostScope.getStore(); diff --git a/packages/host-kit/src/internal/host-process.test.ts b/packages/host-kit/src/internal/host-process.test.ts index 887e494b1e..00996810b5 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,17 @@ 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: () => '/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); +}); diff --git a/packages/host-kit/src/internal/host-process.ts b/packages/host-kit/src/internal/host-process.ts index ef9805ea18..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,6 +43,20 @@ type HostProcessRunCommand = ( options: ExecOptions, ) => Promise; +/** The process-shaped object an entry point captures: Node's `process` satisfies it. */ +export type HostEnvironmentSource = Readonly<{ + env: EnvMap; + cwd(): string; +}>; + +/** + * 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); } diff --git a/packages/host-kit/src/process.ts b/packages/host-kit/src/process.ts index 726922a21d..aaf0448df1 100644 --- a/packages/host-kit/src/process.ts +++ b/packages/host-kit/src/process.ts @@ -1,4 +1,6 @@ export { + captureHostEnvironment, + type HostEnvironmentSource, type ConfirmedProcessStop, confirmedProcessStopDetails, type ConfirmedProcessStopDetails, diff --git a/packages/platform-android/src/app-deployment.ts b/packages/platform-android/src/app-deployment.ts index cebcf7d786..5c885774a6 100644 --- a/packages/platform-android/src/app-deployment.ts +++ b/packages/platform-android/src/app-deployment.ts @@ -1,7 +1,12 @@ import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { resolveFileOverridePath, runCmd, whichCmd } from '@agent-device/host-kit/command'; +import { + resolveFileOverridePath, + runCmd, + whichCmd, + commandHostEnvironment, +} from '@agent-device/host-kit/command'; import { waitForAndroidBoot } from './emulator-lifecycle.ts'; import { installAndroidAdbPackage, resolveAndroidAdbProvider } from './adb-executor.ts'; import { withAndroidAppResolutionCacheInvalidated } from './app-deployment-resolution.ts'; @@ -30,7 +35,7 @@ async function resolveBundletoolInvocation( return cachedBundletoolInvocation.invocation; } - if (await whichCmd('bundletool', process.env)) { + if (await whichCmd('bundletool', commandHostEnvironment().env)) { const invocation = { cmd: 'bundletool', prefixArgs: [] } as const; cachedBundletoolInvocation = { key: cacheKey, invocation }; return invocation; diff --git a/packages/platform-apple/src/core/debug-symbols.ts b/packages/platform-apple/src/core/debug-symbols.ts index d8e74d10bc..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, @@ -25,7 +26,7 @@ export async function symbolicateCrashArtifact( hint: 'Use debug symbols --artifact --dsym or --search-path --out .', }); } - const cwd = options.cwd ?? process.cwd(); + 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 ed53e4bfc1..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: async (cmd) => await whichCmd(cmd, process.env), + whichCommand: async (cmd) => await whichCmd(cmd, commandHostEnvironment().env), }; const appleToolProviderScope = createScopedProvider( diff --git a/packages/platform-apple/src/os/macos/app-backend.ts b/packages/platform-apple/src/os/macos/app-backend.ts index 8a41e983de..7e452a3328 100644 --- a/packages/platform-apple/src/os/macos/app-backend.ts +++ b/packages/platform-apple/src/os/macos/app-backend.ts @@ -1,6 +1,7 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { readMacOsAppBackend, type MacOsAppBackend } from '@agent-device/contracts/session'; /** The app-session backend the host selected through `AGENT_DEVICE_MACOS_APP_BACKEND`. */ export function hostMacOsAppBackend(): MacOsAppBackend { - return readMacOsAppBackend((name) => process.env[name]); + return readMacOsAppBackend((name) => commandHostEnvironment().env[name]); } diff --git a/packages/platform-apple/src/os/macos/helper.ts b/packages/platform-apple/src/os/macos/helper.ts index 6f73fccdac..cfae68348c 100644 --- a/packages/platform-apple/src/os/macos/helper.ts +++ b/packages/platform-apple/src/os/macos/helper.ts @@ -20,6 +20,7 @@ import { resolveExecutableOverridePath, runCmdBackground, type ExecBackgroundResult, + commandHostEnvironment, } from '@agent-device/host-kit/command'; import type { MacOsHelperSurface, SessionSurface } from '@agent-device/contracts/session'; import { @@ -116,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 (process.env[MACOS_GHOST_CURSOR_ENV]?.trim() === '0') return; + if (commandHostEnvironment().env[MACOS_GHOST_CURSOR_ENV]?.trim() === '0') return; args.push('--ghost-cursor'); } @@ -231,7 +232,7 @@ async function readInstalledMacOsHelperFingerprint(): Promise { async function ensureMacOsHelperBinary(): Promise { const configuredPath = await resolveExecutableOverridePath( - process.env[MACOS_HELPER_ENV_PATH], + commandHostEnvironment().env[MACOS_HELPER_ENV_PATH], MACOS_HELPER_ENV_PATH, ); if (configuredPath) { @@ -270,7 +271,7 @@ async function ensureMacOsHelperBinary(): Promise { } async function resolveMacOsHelperCommandPath(): Promise { - const configuredPath = process.env[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-harmonyos/src/hdc.ts b/packages/platform-harmonyos/src/hdc.ts index 7260c3e27a..09cefcee82 100644 --- a/packages/platform-harmonyos/src/hdc.ts +++ b/packages/platform-harmonyos/src/hdc.ts @@ -10,6 +10,7 @@ import { runCmd, type ExecOptions, type ExecResult, + commandHostEnvironment, } from '@agent-device/host-kit/command'; export type HarmonyHdcOptions = Pick< @@ -49,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 = process.env, + env: NodeJS.ProcessEnv = commandHostEnvironment().env, ): Promise { const toolchainRoots = [ env.HDC_SDK_PATH, diff --git a/packages/platform-linux/src/linux-env.ts b/packages/platform-linux/src/linux-env.ts index 9357327294..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. * @@ -12,7 +13,7 @@ export type DisplayServer = 'wayland' | 'x11'; export type InputTool = 'xdotool' | 'ydotool'; function detectDisplayServer(): DisplayServer { - const environment = process.env; + 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 296cb2b885..2ac2038408 100644 --- a/packages/platform-linux/src/tool-provider.ts +++ b/packages/platform-linux/src/tool-provider.ts @@ -6,6 +6,7 @@ import { whichCmd, type ExecOptions, type ExecResult, + commandHostEnvironment, } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { sleep } from '@agent-device/host-kit/retry'; @@ -78,7 +79,8 @@ export type LinuxToolProvider = { input?: LinuxInputProvider; }; -const whichOnPath = async (cmd: string): Promise => await whichCmd(cmd, process.env); +const whichOnPath = async (cmd: string): Promise => + await whichCmd(cmd, commandHostEnvironment().env); const localLinuxToolProvider: LinuxToolProvider = { runCommand: runCmd, @@ -308,6 +310,6 @@ async function resolveLocalLinuxTool( } function isLinuxWayland(): boolean { - const environment = process.env; + const environment = commandHostEnvironment().env; return Boolean(environment['WAYLAND_DISPLAY']) || environment['XDG_SESSION_TYPE'] === 'wayland'; } diff --git a/packages/platform-vega/src/tool-provider.ts b/packages/platform-vega/src/tool-provider.ts index f918b3ba77..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', process.env)) { + if (await whichCmd('vega', commandHostEnvironment().env)) { cachedVegaCliExecutable = 'vega'; return cachedVegaCliExecutable; } diff --git a/packages/platform-web/src/agent-browser-install.ts b/packages/platform-web/src/agent-browser-install.ts index a454086635..056b5d1665 100644 --- a/packages/platform-web/src/agent-browser-install.ts +++ b/packages/platform-web/src/agent-browser-install.ts @@ -1,6 +1,6 @@ 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, @@ -34,7 +34,10 @@ export async function installManagedAgentBrowserPackage(params: { '--no-save', params.packageSpec, ]); - await runCmd(npm.command, npm.args, { env: process.env, timeoutMs: params.timeoutMs }); + await runCmd(npm.command, npm.args, { + env: commandHostEnvironment().env, + timeoutMs: params.timeoutMs, + }); } export function writeManagedAgentBrowserManifest(params: { @@ -65,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(process.env); + 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.ts b/packages/platform-web/src/agent-browser-lifecycle.ts index 3b405a1e24..20d1ca450e 100644 --- a/packages/platform-web/src/agent-browser-lifecycle.ts +++ b/packages/platform-web/src/agent-browser-lifecycle.ts @@ -1,3 +1,4 @@ +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'; @@ -62,7 +63,7 @@ export async function cleanupManagedAgentBrowserOrphans( return skippedCleanupResult('open-web-session', { openWebSessionNames }); } - const idleTimeoutMs = resolveAgentBrowserIdleTimeoutMs(process.env); + 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-tool.ts b/packages/platform-web/src/agent-browser-tool.ts index 62d16efade..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, @@ -124,7 +124,8 @@ export async function doctorManagedAgentBrowser(options: { export function getManagedAgentBrowserStatus(options: { stateDir?: string; }): AgentBrowserToolStatus { - const stateDir = options.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR ?? defaultStateDir(); + const stateDir = + options.stateDir ?? commandHostEnvironment().env.AGENT_DEVICE_STATE_DIR ?? defaultStateDir(); const { installDir, homeDir, runtimeHomeDir, socketDir } = managedInstallPaths( stateDir, MANAGED_AGENT_BROWSER_VERSION, @@ -160,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, process.env), + env: managedAgentBrowserEnv(status, commandHostEnvironment().env), timeoutMs: options.timeoutMs, signal: options.signal, stdin: options.stdin, @@ -287,7 +288,10 @@ function assertWebNodeSupported(nodeMajor: number): void { } function defaultStateDir(): string { - return path.join(process.env.HOME ?? process.cwd(), '.agent-device'); + return path.join( + commandHostEnvironment().env.HOME ?? commandHostEnvironment().cwd, + '.agent-device', + ); } export function mapManagedAgentBrowserError(error: unknown): AppError { diff --git a/src/__tests__/host-environment-setup.ts b/src/__tests__/host-environment-setup.ts new file mode 100644 index 0000000000..65b8c17ef8 --- /dev/null +++ b/src/__tests__/host-environment-setup.ts @@ -0,0 +1,20 @@ +import { beforeAll, beforeEach } from 'vitest'; + +// Code that has no host of its own reads the enclosing command host, and production roots open +// one. A unit test is its own root, and many assign `process.env` directly, so each test gets the +// live process environment as its host. The module loads after the test file's own imports, so +// a suite that mocks `node:child_process` still sees its mock. +let enterCommandHost: typeof import('@agent-device/host-kit/command').enterCommandHost | undefined; + +beforeAll(async () => { + const command = await import('@agent-device/host-kit/command'); + try { + enterCommandHost = command.enterCommandHost; + } catch { + // A suite that replaces the module wholesale has no scope to enter. + } +}); + +beforeEach(() => { + enterCommandHost?.({ host: { env: process.env, cwd: process.cwd() }, requestEnv: undefined }); +}); diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 906989cfbd..47bae98192 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -75,7 +75,7 @@ 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/host-environment'; +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'; diff --git a/src/bin.ts b/src/bin.ts index 21f8c33e5f..c42815e437 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,4 +1,5 @@ -import { captureHostEnvironment } from '@agent-device/host-kit/host-environment'; +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; @@ -13,7 +14,16 @@ runEntry( const { runCliProcess } = await import('./cli/process-entry.ts'); return { runCliProcess: (argv) => runCliProcess(argv, host) }; }, - mcp: () => import('./mcp/server.ts'), + 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 34cc6c913a..02db19f261 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,7 +9,7 @@ import { } 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/host-environment'; +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'; diff --git a/src/daemon.ts b/src/daemon.ts index 45f2192fb2..264b1de596 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -1,6 +1,6 @@ import { asAppError } from '@agent-device/kernel/errors'; import { startDaemonRuntime } from '@agent-device/daemon'; -import { captureHostEnvironment } from '@agent-device/host-kit/host-environment'; +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 diff --git a/vitest.config.ts b/vitest.config.ts index eab8d3bfc8..4092b5f24d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -82,6 +82,7 @@ export const TEST_ISOLATION = { unstubEnvs: true } as const; export const SETUP_FILES = [ 'src/__tests__/hermetic-env-setup.ts', 'src/__tests__/hermetic-signal-setup.ts', + 'src/__tests__/host-environment-setup.ts', 'src/__tests__/process-memo-setup.ts', ]; From 5effcd7e3fd24af2c20e545aaafbd07c1936c743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 23:53:16 +0200 Subject: [PATCH 04/33] fix(host): answer review of the host environment foundation Co-Authored-By: Claude Sonnet 5.5 --- .../src/daemon-resolution.test.ts | 22 ++++++++++ .../src/device-claim-paths.test.ts | 12 +++++- .../src/device-claim-paths.ts | 17 ++++++-- .../daemon/src/resources/macos-app-lease.ts | 4 +- .../__tests__/request-execution-scope.test.ts | 42 ++----------------- .../server/http/daemon-session-idle-expiry.ts | 3 +- .../src/server/http/daemon-shutdown-claims.ts | 6 ++- .../handlers/session-app-deployment.ts | 6 ++- .../session-appstate-device-runtime.ts | 3 +- .../src/sessions/handlers/session-boot.ts | 3 +- .../src/sessions/handlers/session-doctor.ts | 3 +- .../src/sessions/handlers/trace-runtime.ts | 5 ++- .../daemon/src/sessions/lease-lifecycle.ts | 3 +- .../session-lifecycle/internal/inventory.ts | 12 ++++-- .../internal/session-close.ts | 3 +- .../internal/session-open-execution.ts | 11 ++--- .../internal/session-perf-runtime.ts | 16 +++++-- packages/host-kit/src/command.ts | 1 - packages/host-kit/src/internal/exec.ts | 25 ++++++----- .../src/internal/host-process.test.ts | 3 +- packages/kernel/src/source-value.ts | 2 +- .../platform-android/src/app-deployment.ts | 9 +--- scripts/clean-daemon.ts | 5 ++- scripts/print-daemon-state-dir.ts | 5 ++- src/__tests__/host-environment-setup.ts | 31 +++++++------- src/cli/commands/auth.ts | 5 ++- src/cli/commands/connection-runtime.ts | 9 ++-- src/cli/commands/connection.ts | 21 ++++++---- src/cli/commands/daemon.ts | 3 +- src/cli/commands/device.ts | 5 ++- src/cli/commands/recording.ts | 7 +++- src/cli/commands/replay.ts | 7 +++- src/cli/commands/screenshot.ts | 9 ++-- src/cli/connection/ended-lease.ts | 5 ++- src/cli/connection/limrun-profile.ts | 16 +++++-- src/commands/management/output.ts | 6 ++- src/daemon-entrypoint.test.ts | 10 ----- src/platform-runtime-host.ts | 6 +-- src/platform-runtime-operation-host.ts | 6 +-- 39 files changed, 217 insertions(+), 150 deletions(-) create mode 100644 packages/daemon-contracts/src/daemon-resolution.test.ts 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/device-claim-paths.test.ts b/packages/daemon-contracts/src/device-claim-paths.test.ts index 90df4de3d7..ac195626bd 100644 --- a/packages/daemon-contracts/src/device-claim-paths.test.ts +++ b/packages/daemon-contracts/src/device-claim-paths.test.ts @@ -1,7 +1,7 @@ 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']; @@ -13,9 +13,17 @@ test('the claim file path test-kit restates matches the production resolver', () }); 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, 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 61dc18448d..f183708fed 100644 --- a/packages/daemon-contracts/src/device-claim-paths.ts +++ b/packages/daemon-contracts/src/device-claim-paths.ts @@ -1,6 +1,7 @@ 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, @@ -27,9 +28,19 @@ function formatLocalDeviceKey( export function resolveDeviceClaimRoot(env: EnvMap): string { const override = env.AGENT_DEVICE_CLAIMS_DIR?.trim(); - return override - ? path.resolve(override) - : path.join(os.homedir(), '.agent-device', 'device-claims'); + 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, env: EnvMap): string { diff --git a/packages/daemon/src/resources/macos-app-lease.ts b/packages/daemon/src/resources/macos-app-lease.ts index 09ed8e8fea..77bbfe73f5 100644 --- a/packages/daemon/src/resources/macos-app-lease.ts +++ b/packages/daemon/src/resources/macos-app-lease.ts @@ -4,7 +4,7 @@ import { normalizeBatchCommandName } from '@agent-device/command-registry/batch- 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 { runCmd, commandHostEnvironment } from '@agent-device/host-kit/command'; import { isProcessAlive } from '@agent-device/host-kit/process'; import { isMacOs } from '@agent-device/kernel/device'; import { AppError, type DaemonError } from '@agent-device/kernel/errors'; @@ -257,7 +257,7 @@ function assertOpensLeasedApp(key: MacOsAppLeaseKey, positionals: readonly strin * every action inside the session app. */ function assertNativeBackend(): void { - if (readMacOsAppBackend((name) => process.env[name]) === 'native') return; + if (readMacOsAppBackend((name) => commandHostEnvironment().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.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope.test.ts index 514fe42dda..1c88d64006 100644 --- a/packages/daemon/src/server/__tests__/request-execution-scope.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-scope.test.ts @@ -44,7 +44,6 @@ test('createRequestExecutionScope applies tenant scoping and locked lease admiss }); const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ session: 'default', command: 'snapshot', @@ -76,7 +75,6 @@ test('createRequestExecutionScope is the single defaulting authority for the app const leaseRegistry = new LeaseRegistry(); const defaulted = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'apps' }), sessionStore, leaseRegistry, @@ -84,7 +82,6 @@ test('createRequestExecutionScope is the single defaulting authority for the app expect(defaulted.req.flags?.appsFilter).toBe('user-installed'); const overridden = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'apps', flags: { appsFilter: 'all' } }), sessionStore, leaseRegistry, @@ -92,7 +89,6 @@ test('createRequestExecutionScope is the single defaulting authority for the app expect(overridden.req.flags?.appsFilter).toBe('all'); const untouched = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -109,7 +105,6 @@ test('createRequestExecutionScope resolves session-scoped request and runner log { command: 'snapshot', requestId: 'request-logs-1', logPath: LOG_PATH }, async () => await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ meta: { cwd, requestId: 'request-logs-1' } }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -131,7 +126,6 @@ test('a relative session name is rejected before any session artifact path is wr await withDiagnosticsScope({ command: 'snapshot', requestId, logPath: LOG_PATH }, async () => { await expect( createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ session: '..', meta: { requestId } }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -156,7 +150,6 @@ test('request diagnostics flush into the effective session request log', async ( { command: 'snapshot', requestId: 'request-diag-1', logPath: LOG_PATH }, async () => { const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ meta: { cwd, requestId: 'request-diag-1' } }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -174,7 +167,6 @@ test('request diagnostics flush into the effective session request log', async ( test('runLocked rejects tenant requests without an active lease', async () => { const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ session: 'default', command: 'snapshot', @@ -221,7 +213,6 @@ test('leased session admission uses stored lease metadata and heartbeats', async now = 2_000; const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -264,13 +255,11 @@ test('leased session heartbeat is serialized with the request execution lock', a ); const first = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'click' }), sessionStore, leaseRegistry, }); const second = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'click' }), sessionStore, leaseRegistry, @@ -308,13 +297,11 @@ test('a later external command cannot interleave with replay observation finaliz sessionStore.publish('default', makeIosSession('default')); const leaseRegistry = new LeaseRegistry(); const replay = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'replay' }), sessionStore, leaseRegistry, }); const laterSnapshot = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -351,7 +338,6 @@ test('a fresh replay keeps its session lock after a nested open binds the device const sessionStore = makeSessionStore('agent-device-request-scope-'); const leaseRegistry = new LeaseRegistry(); const replay = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'replay' }), sessionStore, leaseRegistry, @@ -373,7 +359,6 @@ test('a fresh replay keeps its session lock after a nested open binds the device await sessionOpenedPromise; const laterSnapshot = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -408,7 +393,6 @@ test('leased session rejects mismatched lease id before dispatch', async () => { ); const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot', meta: { leaseId: '1'.repeat(32) } }), sessionStore, leaseRegistry, @@ -443,7 +427,6 @@ test.each([ ); const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot', meta }), sessionStore, leaseRegistry, @@ -459,7 +442,6 @@ test('local unleased session admission still succeeds', async () => { sessionStore.publish('default', makeIosSession('default')); const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -472,7 +454,6 @@ test('local unleased session ignores stale lease id without tenant scope', async const sessionStore = makeSessionStore('agent-device-request-scope-'); sessionStore.publish('default', makeIosSession('default')); const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot', meta: { leaseId: '1'.repeat(32) }, @@ -507,7 +488,6 @@ test('provider lease admission succeeds without a device key', async () => { ); const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -544,7 +524,6 @@ test('an admitted request that outlives the lease TTL keeps its lease and sessio ); const slow = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -554,7 +533,6 @@ test('an admitted request that outlives the lease TTL keeps its lease and sessio expect(await slow.runLocked(async () => (now = 1_011))).toBe(1_011); const next = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'screenshot' }), sessionStore, leaseRegistry, @@ -586,13 +564,11 @@ test('expired leased session cleanup waits for the request execution lock', asyn }), ); const first = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'click', meta: { requestId } }), sessionStore, leaseRegistry, }); const second = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'click' }), sessionStore, leaseRegistry, @@ -638,7 +614,6 @@ test('tenant lease rejection flushes diagnostics into the effective session requ await withDiagnosticsScope({ command: 'snapshot', requestId, logPath: LOG_PATH }, async () => { const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ session: 'default', command: 'snapshot', @@ -669,7 +644,6 @@ test('prepareLockedRequestScope preserves existing-session selector validation', const sessionStore = makeSessionStore('agent-device-request-scope-'); sessionStore.publish('default', makeAndroidSession('default')); const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot', flags: { @@ -701,7 +675,6 @@ test('prepareLockedRequestScope blocks commands for invalidated recordings befor }); sessionStore.publish('default', session); const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -730,7 +703,6 @@ test('prepareLockedRequestScope passes the session runner log path into handler const sessionStore = makeSessionStore('agent-device-request-scope-'); sessionStore.publish('default', makeIosSession('default')); const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -759,7 +731,6 @@ test('prepareLockedRequestScope streams ordinary diagnostics into the active tra }), ); const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -781,7 +752,6 @@ test('prepareLockedRequestScope streams ordinary diagnostics into the active tra test('runLocked rejects a canceled request before executing work', async () => { const requestId = 'request-scope-canceled-before-lock'; const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ meta: { requestId } }), sessionStore: makeSessionStore('agent-device-request-scope-'), leaseRegistry: new LeaseRegistry(), @@ -801,13 +771,11 @@ test('runLocked rejects a request canceled while waiting for its execution lock' sessionStore.publish('default', makeIosSession('default')); const leaseRegistry = new LeaseRegistry(); const first = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'click' }), sessionStore, leaseRegistry, }); const second = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command: 'click', meta: { requestId } }), sessionStore, leaseRegistry, @@ -842,7 +810,6 @@ test('router: deferred tenant connect with no daemon session closes as SESSION_N const release = vi.fn(async () => ({})); const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ session: 'default', command: 'close', @@ -881,7 +848,6 @@ test('router: deferred tenant connect still refuses an app-target close before d const leaseRegistry = new LeaseRegistry(); const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ session: 'default', command: 'close', @@ -907,7 +873,6 @@ test('router: an existing lease-less session under tenant isolation still refuse const leaseRegistry = new LeaseRegistry(); const scope = await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ session: 'default', command: 'close', @@ -926,10 +891,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(), @@ -965,7 +932,6 @@ async function createScopeAcrossTwoImplicitWorkspaceSessions(command: string) { makeAndroidSession('default', { sessionScope: { kind: 'cwd', id: scope.id } }), ); return await createTestRequestScope({ - host: createTestHostEnvironment(), req: makeRequest({ command, meta: { cwd: root, requestId: `ambiguity-${command}` } }), sessionStore, leaseRegistry: new LeaseRegistry(), 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 bc966f5105..75c3bc6e87 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 { commandHostEnvironment } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { clearDeviceClaim, @@ -546,7 +547,7 @@ async function clearExpiredClaim( ): Promise { if (!ownership) return 'absent'; try { - return await clearDeviceClaim(ownership, process.env); + return await clearDeviceClaim(ownership, commandHostEnvironment().env); } catch (error) { emitDiagnostic({ level: 'warn', diff --git a/packages/daemon/src/server/http/daemon-shutdown-claims.ts b/packages/daemon/src/server/http/daemon-shutdown-claims.ts index 057ca504ae..d8bebc1b77 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 { commandHostEnvironment } from '@agent-device/host-kit/command'; import { publicPlatformString } from '@agent-device/kernel/device'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { @@ -71,7 +72,10 @@ export function createDaemonShutdownClaimLedger(): DaemonShutdownClaimLedger { releaseClaim: async (session) => { if (!session.deviceClaim) return; try { - outcomes.set(session.name, await clearDeviceClaim(session.deviceClaim, process.env)); + outcomes.set( + session.name, + await clearDeviceClaim(session.deviceClaim, commandHostEnvironment().env), + ); } catch (error) { outcomes.set(session.name, CLEAR_UNRECORDED); emitDiagnostic({ diff --git a/packages/daemon/src/sessions/handlers/session-app-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-deployment.ts index 0554ad36cd..fe9bc81eaa 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 { commandHostEnvironment } from '@agent-device/host-kit/command'; 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'; @@ -74,7 +75,7 @@ export async function handleAppDeploymentCommand(params: { try { const appPath = uploadedArtifactId ? prepareUploadedArtifact(uploadedArtifactId, req.meta?.tenantId) - : expandSessionPath(target.appPathInput, process.cwd()); + : expandSessionPath(target.appPathInput, commandHostEnvironment().cwd); if (!fs.existsSync(appPath)) { return errorResponse('INVALID_ARGS', `App binary not found: ${appPath}`); } @@ -300,7 +301,8 @@ function resolvePushPayload(payloadArg: string, cwd?: string): string { const resolved = resolvePayloadInput(payloadArg, { subject: 'Push payload', cwd, - expandPath: (value, currentCwd) => expandSessionPath(value, currentCwd ?? process.cwd()), + expandPath: (value, currentCwd) => + expandSessionPath(value, currentCwd ?? commandHostEnvironment().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 9fafc8326e..e70b0ee79b 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 { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { appStateUse } from '@agent-device/contracts/platform-runtime-operations'; import { @@ -12,7 +13,7 @@ import type { RuntimeCommandHandlerParams } from '../../core/session-runtime-adm import { appleSessionRequiredRefusal } from './session-appstate-apple-session.ts'; function sortedAndroidSerialAllowlist(value: string | undefined): string[] | undefined { - const allowlist = resolveAndroidSerialAllowlist(value, process.env); + const allowlist = resolveAndroidSerialAllowlist(value, commandHostEnvironment().env); return allowlist ? [...allowlist].sort() : undefined; } diff --git a/packages/daemon/src/sessions/handlers/session-boot.ts b/packages/daemon/src/sessions/handlers/session-boot.ts index 35a19d77fb..cadeeded1b 100644 --- a/packages/daemon/src/sessions/handlers/session-boot.ts +++ b/packages/daemon/src/sessions/handlers/session-boot.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { resolveDeviceReadinessRuntimePlan } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -98,7 +99,7 @@ export async function handleBootCommand( const resolvedAndroidSerialAllowlist = resolveAndroidSerialAllowlist( flags.androidDeviceAllowlist, - process.env, + commandHostEnvironment().env, ); const androidSerialAllowlist = resolvedAndroidSerialAllowlist ? [...resolvedAndroidSerialAllowlist].sort() diff --git a/packages/daemon/src/sessions/handlers/session-doctor.ts b/packages/daemon/src/sessions/handlers/session-doctor.ts index 79c53d150f..dda31d35d8 100644 --- a/packages/daemon/src/sessions/handlers/session-doctor.ts +++ b/packages/daemon/src/sessions/handlers/session-doctor.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import path from 'node:path'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; @@ -276,7 +277,7 @@ async function resolveDoctorAppInventory(params: { ); const androidSerialAllowlist = resolveAndroidSerialAllowlist( req.flags?.androidDeviceAllowlist, - process.env, + commandHostEnvironment().env, ); const readyDevice = await ensureAppsRuntimeReady(runtime, { serial: req.flags?.serial, diff --git a/packages/daemon/src/sessions/handlers/trace-runtime.ts b/packages/daemon/src/sessions/handlers/trace-runtime.ts index 4aa02501f8..519143f490 100644 --- a/packages/daemon/src/sessions/handlers/trace-runtime.ts +++ b/packages/daemon/src/sessions/handlers/trace-runtime.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import fs from 'node:fs'; @@ -34,7 +35,7 @@ function startTrace( if (session.trace) return errorResponse('INVALID_ARGS', 'trace already in progress'); const outPath = expandSessionPath( req.positionals?.[1] ?? sessionStore.defaultTracePath(session), - process.cwd(), + commandHostEnvironment().cwd, ); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.appendFileSync(outPath, ''); @@ -77,7 +78,7 @@ function stopTrace( function relocateTraceOutput(currentPath: string, requestedPath: string | undefined): string { if (!requestedPath) return currentPath; - const resolved = expandSessionPath(requestedPath, process.cwd()); + const resolved = expandSessionPath(requestedPath, commandHostEnvironment().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 edb963de0b..e21a3c1827 100644 --- a/packages/daemon/src/sessions/lease-lifecycle.ts +++ b/packages/daemon/src/sessions/lease-lifecycle.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; 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'; @@ -91,7 +92,7 @@ export async function endLeasedSession( data: { reason, leaseId, session: session.name, error: errorMessage(error) }, }); }); - await clearDeviceClaim(session.deviceClaim, process.env).catch((error) => { + await clearDeviceClaim(session.deviceClaim, commandHostEnvironment().env).catch((error) => { emitDiagnostic({ level: 'warn', phase: 'leased_session_end_device_claim_clear_failed', diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts b/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts index 8dd2b2f9ff..95d104d3b7 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 { commandHostEnvironment } from '@agent-device/host-kit/command'; 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'; @@ -169,7 +170,7 @@ async function devicesInventoryResponse(req: DaemonRequest): Promise { const owners = new Map(); - for (const entry of inspectDeviceClaims({}, process.env)) { + for (const entry of inspectDeviceClaims({}, commandHostEnvironment().env)) { const claim = entry.claim; if (!claim || deviceClaimOwnerCannotRelease(entry.classification)) continue; owners.set(claim.deviceKey, { @@ -220,12 +221,15 @@ function inventoryIosSimulatorSetPath( requestedSetPath: configuredPath, platform, target, - env: process.env, + env: commandHostEnvironment().env, }); } function inventoryAndroidSerialAllowlist(configuredAllowlist: string | undefined) { - const allowlist = resolveAndroidSerialAllowlist(configuredAllowlist, process.env); + const allowlist = resolveAndroidSerialAllowlist( + configuredAllowlist, + commandHostEnvironment().env, + ); return allowlist ? Array.from(allowlist).sort() : undefined; } @@ -363,7 +367,7 @@ async function handleAppsInventory(params: { const resolvedAndroidSerialAllowlist = resolveAndroidSerialAllowlist( req.flags?.androidDeviceAllowlist, - process.env, + commandHostEnvironment().env, ); const runtimeResolution = await resolveAppsRuntime({ device, 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 783454ae65..8cc4f4d586 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 { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { DaemonPlatformServices } from '../../../core/platform-services.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; @@ -351,7 +352,7 @@ async function runCloseTeardownAndRelease(params: { }); } } else { - await clearDeviceClaim(session.deviceClaim, process.env); + await clearDeviceClaim(session.deviceClaim, commandHostEnvironment().env); } sessionStore.retire(ref); if (deviceClaimBlockingError) throw deviceClaimBlockingError; 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 5e2866745e..1ee2f45494 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 { commandHostEnvironment } from '@agent-device/host-kit/command'; import { resolveSessionRequestLogPath, resolveSessionRunnerLogPath, @@ -148,7 +149,7 @@ export async function renewOpenSessionClaim( ownership: DeviceClaimSessionOwnership | undefined, ): Promise { if (!ownership) return undefined; - const renewal = await renewDeviceClaim(ownership, process.env); + const renewal = await renewDeviceClaim(ownership, commandHostEnvironment().env); if (renewal.status === 'renewed') return undefined; if (renewal.conflict) return buildDeviceClaimConflictError(device, renewal.conflict); return errorResponse( @@ -428,9 +429,9 @@ async function acquireDeviceClaimForOwner(params: { return await acquireDeviceClaim({ device, session: sessionName, - workspace: req.meta?.cwd ?? process.cwd(), + workspace: req.meta?.cwd ?? commandHostEnvironment().cwd, stateDir: sessionStore.resolveDaemonStateDir(), - env: process.env, + env: commandHostEnvironment().env, reconcileOrphanedDeviceClaim, observeDeviceBoot: platformServices.deviceBootObservation, }); @@ -565,11 +566,11 @@ async function rollbackNewSessionClaim(params: { const { ownership, effects, sessionName, sessionStore } = params; if (!ownership) return; if (!effects.mayHaveStarted) { - await clearDeviceClaim(ownership, process.env); + await clearDeviceClaim(ownership, commandHostEnvironment().env); return; } if (sessionStore.get(sessionName)?.deviceClaim?.ownerToken === ownership.ownerToken) return; - const outcome = await abandonDeviceClaim(ownership, process.env); + const outcome = await abandonDeviceClaim(ownership, commandHostEnvironment().env); emitDiagnostic({ level: 'warn', phase: 'device_claim_open_effects_unconfirmed', 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 6d773a6088..1aed92804b 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 { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import path from 'node:path'; @@ -143,7 +144,10 @@ async function executeAdmittedPerfPlan( appId: session.appBundleId, kind: plan.request.kind, outPath: plan.request.outPath - ? expandSessionPath(plan.request.outPath, params.req.meta?.cwd ?? process.cwd()) + ? expandSessionPath( + plan.request.outPath, + params.req.meta?.cwd ?? commandHostEnvironment().cwd, + ) : undefined, artifactsDir: path.join( params.sessionStore.ensureSessionDir(params.sessionName), @@ -176,7 +180,10 @@ async function executeAdmittedPerfPlan( const data = await runtime.operations.perfProfileReport({ appId: session.appBundleId, kind: plan.request.kind, - tracePath: expandSessionPath(tracePath, params.req.meta?.cwd ?? process.cwd()), + tracePath: expandSessionPath( + tracePath, + params.req.meta?.cwd ?? commandHostEnvironment().cwd, + ), outPath, template: plan.request.template ?? (last?.kind === 'xctrace' ? last.template : undefined), profile: last, @@ -263,7 +270,7 @@ async function stopPerfCapture( if (mismatchMessage) return errorResponse('INVALID_ARGS', mismatchMessage); if (request.outPath) { capture.handle.setOutputPath( - expandSessionPath(request.outPath, params.req.meta?.cwd ?? process.cwd()), + expandSessionPath(request.outPath, params.req.meta?.cwd ?? commandHostEnvironment().cwd), ); } const completion = await finishLivePerfCapture({ @@ -395,7 +402,8 @@ function resolveNativeOutPath( requestedPath: string | undefined, fallbackFileName: string, ): string { - if (requestedPath) return expandSessionPath(requestedPath, params.req.meta?.cwd ?? process.cwd()); + if (requestedPath) + return expandSessionPath(requestedPath, params.req.meta?.cwd ?? commandHostEnvironment().cwd); return path.join( params.sessionStore.ensureSessionDir(params.sessionName), `${timestampToken()}-${fallbackFileName}`, diff --git a/packages/host-kit/src/command.ts b/packages/host-kit/src/command.ts index 5b3bfba69d..73b8aeb4f3 100644 --- a/packages/host-kit/src/command.ts +++ b/packages/host-kit/src/command.ts @@ -1,6 +1,5 @@ export { coerceExecResult, - enterCommandHost, commandHostEnvironment, commandDeveloperDir, type CommandExecutorOverride, diff --git a/packages/host-kit/src/internal/exec.ts b/packages/host-kit/src/internal/exec.ts index 783a56c5d6..71785d2c98 100644 --- a/packages/host-kit/src/internal/exec.ts +++ b/packages/host-kit/src/internal/exec.ts @@ -126,15 +126,11 @@ export async function withCommandHost( scope: CommandHostScope, fn: () => Promise, ): Promise { - return await commandHostScope.run(scope, fn); -} - -/** - * Binds `scope` for the rest of the current async context, for a process root that has no single - * callback to wrap. Prefer {@link withCommandHost}. - */ -export function enterCommandHost(scope: CommandHostScope): void { - commandHostScope.enterWith(scope); + // 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); } /** @@ -330,7 +326,7 @@ export async function whichCmd(cmd: string, env: EnvMap): Promise { return isExecutablePath(candidate); } - const pathValue = env.PATH; + const pathValue = readPathVariable(env, 'PATH'); if (!pathValue) return false; const pathExtensions = resolvePathExtensions(env); for (const directory of pathValue.split(path.delimiter)) { @@ -847,9 +843,16 @@ function normalizeExecutableLookup(cmd: string): string | null { return BARE_COMMAND_RE.test(candidate) ? candidate : null; } +/** 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 = 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 00996810b5..274fb24fdb 100644 --- a/packages/host-kit/src/internal/host-process.test.ts +++ b/packages/host-kit/src/internal/host-process.test.ts @@ -184,7 +184,7 @@ test('pid escalation returns immediately for an empty pid set', async () => { test('captureHostEnvironment copies the environment and reads the working directory once', () => { const source = { env: { HOME: '/home/a', EMPTY: undefined } as Record, - cwd: () => '/work', + cwd: vi.fn(() => '/work'), }; const host = captureHostEnvironment(source); @@ -193,4 +193,5 @@ test('captureHostEnvironment copies the environment and reads the working direct 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/kernel/src/source-value.ts b/packages/kernel/src/source-value.ts index 5392328db2..d6a0f87d10 100644 --- a/packages/kernel/src/source-value.ts +++ b/packages/kernel/src/source-value.ts @@ -145,6 +145,6 @@ export type EnvMap = Record; * directory. An entry point captures it once; everything below receives it. */ export type HostEnvironment = Readonly<{ - env: EnvMap; + env: Readonly; cwd: string; }>; diff --git a/packages/platform-android/src/app-deployment.ts b/packages/platform-android/src/app-deployment.ts index 5c885774a6..f047be46a9 100644 --- a/packages/platform-android/src/app-deployment.ts +++ b/packages/platform-android/src/app-deployment.ts @@ -1,12 +1,7 @@ import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - resolveFileOverridePath, - runCmd, - whichCmd, - commandHostEnvironment, -} from '@agent-device/host-kit/command'; +import { resolveFileOverridePath, runCmd, whichCmd } from '@agent-device/host-kit/command'; import { waitForAndroidBoot } from './emulator-lifecycle.ts'; import { installAndroidAdbPackage, resolveAndroidAdbProvider } from './adb-executor.ts'; import { withAndroidAppResolutionCacheInvalidated } from './app-deployment-resolution.ts'; @@ -35,7 +30,7 @@ async function resolveBundletoolInvocation( return cachedBundletoolInvocation.invocation; } - if (await whichCmd('bundletool', commandHostEnvironment().env)) { + if (await whichCmd('bundletool', environment)) { const invocation = { cmd: 'bundletool', prefixArgs: [] } as const; cachedBundletoolInvocation = { key: cacheKey, invocation }; return invocation; diff --git a/scripts/clean-daemon.ts b/scripts/clean-daemon.ts index b71187b242..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, { cwd: process.cwd() }); +const paths = resolveDaemonPaths(process.env.AGENT_DEVICE_STATE_DIR, { + env: process.env, + cwd: process.cwd(), +}); const retirement = await stopAndRetireDaemon({ paths, observed: readRegisteredDaemonIdentity(paths.infoPath), diff --git a/scripts/print-daemon-state-dir.ts b/scripts/print-daemon-state-dir.ts index 7c838e5306..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, { cwd: process.cwd() }); +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__/host-environment-setup.ts b/src/__tests__/host-environment-setup.ts index 65b8c17ef8..3e194a9a96 100644 --- a/src/__tests__/host-environment-setup.ts +++ b/src/__tests__/host-environment-setup.ts @@ -1,20 +1,23 @@ -import { beforeAll, beforeEach } from 'vitest'; +import { aroundEach } from 'vitest'; // Code that has no host of its own reads the enclosing command host, and production roots open -// one. A unit test is its own root, and many assign `process.env` directly, so each test gets the -// live process environment as its host. The module loads after the test file's own imports, so -// a suite that mocks `node:child_process` still sees its mock. -let enterCommandHost: typeof import('@agent-device/host-kit/command').enterCommandHost | undefined; - -beforeAll(async () => { - const command = await import('@agent-device/host-kit/command'); +// one. A unit test is its own root, and many assign `process.env` directly, so each test runs as +// a host whose environment is the live process environment. The module loads inside the hook, so +// it is the instance the test's own imports see, and a suite that mocks `node:child_process` or +// replaces the module wholesale still gets its mock. +aroundEach(async (runTest) => { + let withCommandHost: typeof import('@agent-device/host-kit/command').withCommandHost | undefined; try { - enterCommandHost = command.enterCommandHost; + ({ withCommandHost } = await import('@agent-device/host-kit/command')); } catch { - // A suite that replaces the module wholesale has no scope to enter. + // A suite that replaces the module wholesale has no scope to bind. } -}); - -beforeEach(() => { - enterCommandHost?.({ host: { env: process.env, cwd: process.cwd() }, requestEnv: undefined }); + if (!withCommandHost) { + await runTest(); + return; + } + await withCommandHost( + { host: { env: process.env, cwd: process.cwd() }, requestEnv: undefined }, + async () => await runTest(), + ); }); diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index 0e0dabdb79..82b0b19e35 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { AppError } from '@agent-device/kernel/errors'; import { loginWithDeviceAuth, removeCliSession, summarizeCliSession } from '../auth-session.ts'; @@ -6,7 +7,9 @@ import type { ClientCommandHandler } from './router-types.ts'; export const authCommand: ClientCommandHandler = async ({ positionals, flags }) => { const subcommand = positionals[0] ?? 'status'; - const stateDir = resolveDaemonPaths(flags.stateDir, { cwd: process.cwd() }).baseDir; + const stateDir = resolveDaemonPaths(flags.stateDir, { + cwd: commandHostEnvironment().cwd, + }).baseDir; if (subcommand === 'status') { const status = summarizeCliSession({ stateDir }); await writeCommandOutput(flags, status, () => renderAuthStatus(status)); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index cc487092ed..fc708d71c9 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import crypto from 'node:crypto'; import type { MetroBridgeScope } from '@agent-device/contracts/remote'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; @@ -74,11 +75,13 @@ export async function materializeRemoteConnectionForCommand(options: { return { flags, runtime: options.runtime }; } - const stateDir = resolveDaemonPaths(flags.stateDir, { cwd: process.cwd() }).baseDir; + const stateDir = resolveDaemonPaths(flags.stateDir, { + cwd: commandHostEnvironment().cwd, + }).baseDir; const remoteConfig = resolveRemoteConfigProfile({ configPath: flags.remoteConfig, - cwd: process.cwd(), - env: process.env, + cwd: commandHostEnvironment().cwd, + env: commandHostEnvironment().env, }); const profileFlags = profileToCliFlags(remoteConfig.profile); const mergedFlags = { diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index b8fce84b5e..9d1eded839 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { CloudArtifact, CloudProviderSessionResult, @@ -53,7 +54,9 @@ import { } from './connection-presentation.ts'; export const connectCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { - const stateDir = resolveDaemonPaths(flags.stateDir, { cwd: process.cwd() }).baseDir; + const stateDir = resolveDaemonPaths(flags.stateDir, { + cwd: commandHostEnvironment().cwd, + }).baseDir; const provider = readConnectProvider(positionals); assertConnectProviderUsage(provider, flags); const resolved = await resolveConnectProviderProfile({ provider, flags, stateDir }); @@ -275,8 +278,8 @@ async function cleanupForcedPreviousConnection( return await releasePreviousLease(client, previous, { nextDaemonBaseUrl: next.daemon?.baseUrl, ambientDaemonAuthToken: flags.daemonAuthToken, - cwd: process.cwd(), - env: process.env, + cwd: commandHostEnvironment().cwd, + env: commandHostEnvironment().env, }); } @@ -285,8 +288,8 @@ function readRemoteConfigConnectionMetadata( ): RemoteConnectionRequestMetadata | undefined { const profile = resolveRemoteConfigProfile({ configPath: remoteConfigPath, - cwd: process.cwd(), - env: process.env, + cwd: commandHostEnvironment().cwd, + env: commandHostEnvironment().env, }).profile; return buildRemoteConnectionRequestMetadata(profile); } @@ -417,7 +420,9 @@ function readRequestedConnectionState(flags: CliFlags): { state: RemoteConnectionState | null; } { const session = flags.session ?? 'default'; - const stateDir = resolveDaemonPaths(flags.stateDir, { cwd: process.cwd() }).baseDir; + const stateDir = resolveDaemonPaths(flags.stateDir, { + cwd: commandHostEnvironment().cwd, + }).baseDir; return { session, stateDir, @@ -535,8 +540,8 @@ function remoteConfigHasMetroSettings(remoteConfigPath: string): boolean { try { const remoteConfig = resolveRemoteConfigProfile({ configPath: remoteConfigPath, - cwd: process.cwd(), - env: process.env, + cwd: commandHostEnvironment().cwd, + env: commandHostEnvironment().env, }); const profile = remoteConfig.profile; return Boolean( diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 3f7eb97a73..8141a5be79 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { readDaemonStopIdentity, @@ -17,7 +18,7 @@ export const daemonCommand: ClientCommandHandler = async ({ positionals, flags } if (subcommand !== 'stop' || positionals.length !== 1) { throw new AppError('INVALID_ARGS', 'daemon accepts only: stop'); } - const paths = resolveDaemonPaths(flags.stateDir, { cwd: process.cwd() }); + const paths = resolveDaemonPaths(flags.stateDir, { cwd: commandHostEnvironment().cwd }); const identity = readDaemonStopIdentity(paths.infoPath); const stopped = await stopDaemon({ paths }); const report = stopped.mode === 'graceful' ? readDaemonShutdownReport(paths.baseDir) : null; diff --git a/src/cli/commands/device.ts b/src/cli/commands/device.ts index 3bc410b302..113f556398 100644 --- a/src/cli/commands/device.ts +++ b/src/cli/commands/device.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { AppError } from '@agent-device/kernel/errors'; import { publicPlatformString } from '@agent-device/kernel/device'; import { @@ -25,7 +26,7 @@ export const deviceCommand: ClientCommandHandler = async ({ positionals, flags } udid: flags.udid, serial: flags.serial, }, - process.env, + commandHostEnvironment().env, ); const staleClaims = inspectedClaims.filter((claim) => deviceClaimRequiresStaleInspection(claim.classification), @@ -76,7 +77,7 @@ async function runDeviceRelease(flags: CliFlags): Promise { udid: flags.udid, serial: flags.serial, }, - process.env, + commandHostEnvironment().env, ); const data = { released: outcomes.filter((outcome) => outcome.status === 'released').map(serializeOutcome), diff --git a/src/cli/commands/recording.ts b/src/cli/commands/recording.ts index e0f1b861af..8be1aa533f 100644 --- a/src/cli/commands/recording.ts +++ b/src/cli/commands/recording.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { AppError } from '@agent-device/kernel/errors'; import { localCommandPolicy } from '@agent-device/contracts/command-policy'; import type { RecordingContactSheetCommandResult } from '../../commands/recording/runtime/contact-sheet.ts'; @@ -18,10 +19,12 @@ export const recordingCommand: ClientCommandHandler = async ({ positionals, flag } const recordingPath = resolveUserPath(readRequiredPositional(positionals[1]), { - cwd: process.cwd(), + cwd: commandHostEnvironment().cwd, }); const outputPath = - typeof flags.out === 'string' ? resolveUserPath(flags.out, { cwd: process.cwd() }) : undefined; + typeof flags.out === 'string' + ? resolveUserPath(flags.out, { cwd: commandHostEnvironment().cwd }) + : undefined; // Lazy: createAgentDevice pulls in the client-side command runtime, which only this action needs. const [{ createAgentDevice }, { createLocalArtifactAdapter }] = await Promise.all([ diff --git a/src/cli/commands/replay.ts b/src/cli/commands/replay.ts index 95cdd285df..c979b63587 100644 --- a/src/cli/commands/replay.ts +++ b/src/cli/commands/replay.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; @@ -37,7 +38,7 @@ async function handleReplayExportCommand({ throw new AppError('INVALID_ARGS', 'replay export requires an input path.'); } - const sourcePath = resolveUserPath(inputPath, { cwd: process.cwd() }); + const sourcePath = resolveUserPath(inputPath, { cwd: commandHostEnvironment().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 @@ -51,7 +52,9 @@ async function handleReplayExportCommand({ metadata: readReplayScriptMetadata(script), }); const outputPath = - typeof flags.out === 'string' ? resolveUserPath(flags.out, { cwd: process.cwd() }) : undefined; + typeof flags.out === 'string' + ? resolveUserPath(flags.out, { cwd: commandHostEnvironment().cwd }) + : undefined; if (outputPath) { fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, result.yaml); diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts index 781347e73d..f3ee104fb7 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { localCommandPolicy } from '@agent-device/contracts/command-policy'; import { formatScreenshotDiffText, formatSnapshotDiffText } from '../../commands/output/diff.ts'; import type { ScreenshotDiffResult } from '@agent-device/capture-kit/screenshot-diff'; @@ -51,9 +52,11 @@ export const diffCommand: ClientCommandHandler = async ({ positionals, flags, cl throw new AppError('INVALID_ARGS', 'diff screenshot requires --baseline '); } - const baselinePath = resolveUserPath(baselineRaw, { cwd: process.cwd() }); + const baselinePath = resolveUserPath(baselineRaw, { cwd: commandHostEnvironment().cwd }); const outputPath = - typeof flags.out === 'string' ? resolveUserPath(flags.out, { cwd: process.cwd() }) : undefined; + typeof flags.out === 'string' + ? resolveUserPath(flags.out, { cwd: commandHostEnvironment().cwd }) + : undefined; const currentRaw = positionals[1]; if (positionals.length > 2) { throw new AppError( @@ -82,7 +85,7 @@ export const diffCommand: ClientCommandHandler = async ({ positionals, flags, cl session: flags.session, baseline: { kind: 'path', path: baselinePath }, current: currentRaw - ? { kind: 'path', path: resolveUserPath(currentRaw, { cwd: process.cwd() }) } + ? { kind: 'path', path: resolveUserPath(currentRaw, { cwd: commandHostEnvironment().cwd }) } : { kind: 'live' }, ...(outputPath ? { out: { kind: 'path', path: outputPath } } : {}), threshold: parseCliThreshold(flags.threshold), diff --git a/src/cli/connection/ended-lease.ts b/src/cli/connection/ended-lease.ts index 42918144e1..662856fb82 100644 --- a/src/cli/connection/ended-lease.ts +++ b/src/cli/connection/ended-lease.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { CliFlags } from '@agent-device/contracts/command'; import { readEndedLeases } from '@agent-device/contracts/lease-scope'; @@ -17,7 +18,9 @@ export function forgetEndedProxyLease(flags: CliFlags, response: DaemonResponse) if (!flags.remoteConfig) return; const endedLeaseIds = readEndedLeases(response).map((lease) => lease.leaseId); if (endedLeaseIds.length === 0) return; - const stateDir = resolveDaemonPaths(flags.stateDir, { cwd: process.cwd() }).baseDir; + const stateDir = resolveDaemonPaths(flags.stateDir, { + cwd: commandHostEnvironment().cwd, + }).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/limrun-profile.ts b/src/cli/connection/limrun-profile.ts index 4358f60578..552fba7b14 100644 --- a/src/cli/connection/limrun-profile.ts +++ b/src/cli/connection/limrun-profile.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import type { RemoteConfigProfile } from '@agent-device/remote/remote-config-schema'; import { AppError } from '@agent-device/kernel/errors'; @@ -21,8 +22,8 @@ export function resolveLimrunConnectProfile(options: { cwd: string; env?: EnvMap; }): { flags: CliFlags; remoteConfigPath: string } { - const env = options.env ?? process.env; - const profile = buildLimrunRemoteProfile({ flags: options.flags }); + const env = options.env ?? commandHostEnvironment().env; + 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 +45,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, { cwd: process.cwd() }); + const daemonPaths = resolveDaemonPaths(flags.stateDir, { + cwd: options.cwd, + env: options.env, + }); return { stateDir: daemonPaths.baseDir, daemonTransport: 'auto', diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index f92869e741..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, @@ -189,7 +190,10 @@ export async function doctorCliOutput( const { formatDoctorCheckDetailLines, formatDoctorCheckSummaryLine } = await import('@agent-device/host-kit/doctor-lines'); const { shouldColorStatusMarkers } = await import('@agent-device/host-kit/status-markers'); - const useColor = shouldColorStatusMarkers(process.env, Boolean(process.stderr.isTTY)); + 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}`]; diff --git a/src/daemon-entrypoint.test.ts b/src/daemon-entrypoint.test.ts index eb5be6505b..e28372b631 100644 --- a/src/daemon-entrypoint.test.ts +++ b/src/daemon-entrypoint.test.ts @@ -290,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 { @@ -342,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 }); } @@ -359,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); @@ -486,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/platform-runtime-host.ts b/src/platform-runtime-host.ts index 36a4efde35..d82922b9b9 100644 --- a/src/platform-runtime-host.ts +++ b/src/platform-runtime-host.ts @@ -11,19 +11,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 { return Object.freeze({ commands: Object.freeze({ which: async (executable: string) => - (await whichCmd(executable, process.env)) ? executable : undefined, + (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, }); diff --git a/src/platform-runtime-operation-host.ts b/src/platform-runtime-operation-host.ts index e533ee5704..b03fe1c7ce 100644 --- a/src/platform-runtime-operation-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -15,7 +15,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'; @@ -65,12 +65,12 @@ export function createPlatformRuntimeHost(options: { const appleTools = createAppleToolHost(); const commands = Object.freeze({ which: async (executable: string) => - (await whichCmd(executable, process.env)) ? executable : undefined, + (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, }); From ae204b43fd8b45ebc0c353e32e79e33a2c196612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 00:38:06 +0200 Subject: [PATCH 05/33] fix(host): keep the test host setup out of mutation scope and finish CLI env plumbing Co-Authored-By: Claude Sonnet 5.5 --- .../platform-android/src/app-deployment.ts | 2 +- .../test-kit/src/device-claim-fixtures.ts | 7 ++-- src/__tests__/cloud-connect-profile.test.ts | 32 +++++++++---------- src/__tests__/host-environment-setup.ts | 6 +++- src/cli/commands/auth.ts | 1 + src/cli/commands/connection-runtime.ts | 1 + src/cli/commands/connection.ts | 10 +++++- src/cli/commands/daemon.ts | 5 ++- src/cli/connection/ended-lease.ts | 1 + 9 files changed, 42 insertions(+), 23 deletions(-) diff --git a/packages/platform-android/src/app-deployment.ts b/packages/platform-android/src/app-deployment.ts index f047be46a9..729a6a91d3 100644 --- a/packages/platform-android/src/app-deployment.ts +++ b/packages/platform-android/src/app-deployment.ts @@ -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/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/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index 2b0a7c580f..e8aa253d5d 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -1,3 +1,4 @@ +import { withCommandHost } from '@agent-device/host-kit/command'; import { afterEach, beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -550,22 +551,21 @@ 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 withCommandHost( + { host: { env: process.env, cwd: fs.realpathSync(tempRoot) }, requestEnv: undefined }, + async () => + await connectWithGeneratedProviderProfile({ + stateDir, + 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)); diff --git a/src/__tests__/host-environment-setup.ts b/src/__tests__/host-environment-setup.ts index 3e194a9a96..a11c65f08b 100644 --- a/src/__tests__/host-environment-setup.ts +++ b/src/__tests__/host-environment-setup.ts @@ -5,10 +5,14 @@ import { aroundEach } from 'vitest'; // a host whose environment is the live process environment. The module loads inside the hook, so // it is the instance the test's own imports see, and a suite that mocks `node:child_process` or // replaces the module wholesale still gets its mock. +// Named by a variable so the module graph does not count it: a test is "related" to a source file +// through this setup otherwise, and every mutation scope would grow to the whole suite. +const COMMAND_MODULE = '@agent-device/host-kit/command'; + aroundEach(async (runTest) => { let withCommandHost: typeof import('@agent-device/host-kit/command').withCommandHost | undefined; try { - ({ withCommandHost } = await import('@agent-device/host-kit/command')); + ({ withCommandHost } = await import(/* @vite-ignore */ COMMAND_MODULE)); } catch { // A suite that replaces the module wholesale has no scope to bind. } diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index 82b0b19e35..327b75d525 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -9,6 +9,7 @@ export const authCommand: ClientCommandHandler = async ({ positionals, flags }) const subcommand = positionals[0] ?? 'status'; const stateDir = resolveDaemonPaths(flags.stateDir, { cwd: commandHostEnvironment().cwd, + env: commandHostEnvironment().env, }).baseDir; if (subcommand === 'status') { const status = summarizeCliSession({ stateDir }); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index fc708d71c9..c794dddf5b 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -77,6 +77,7 @@ export async function materializeRemoteConnectionForCommand(options: { const stateDir = resolveDaemonPaths(flags.stateDir, { cwd: commandHostEnvironment().cwd, + env: commandHostEnvironment().env, }).baseDir; const remoteConfig = resolveRemoteConfigProfile({ configPath: flags.remoteConfig, diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 9d1eded839..1d4715e8c3 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -56,10 +56,17 @@ import { export const connectCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { const stateDir = resolveDaemonPaths(flags.stateDir, { cwd: commandHostEnvironment().cwd, + env: commandHostEnvironment().env, }).baseDir; const provider = readConnectProvider(positionals); assertConnectProviderUsage(provider, flags); - const resolved = await resolveConnectProviderProfile({ provider, flags, stateDir }); + const resolved = await resolveConnectProviderProfile({ + provider, + flags, + stateDir, + cwd: commandHostEnvironment().cwd, + env: commandHostEnvironment().env, + }); const connectFlags = resolved.flags; const connectionMetadata = readRemoteConfigConnectionMetadata(resolved.remoteConfigPath); const scope = readRequiredConnectScope(connectFlags, connectionMetadata); @@ -422,6 +429,7 @@ function readRequestedConnectionState(flags: CliFlags): { const session = flags.session ?? 'default'; const stateDir = resolveDaemonPaths(flags.stateDir, { cwd: commandHostEnvironment().cwd, + env: commandHostEnvironment().env, }).baseDir; return { session, diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 8141a5be79..f5fad04be3 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -18,7 +18,10 @@ export const daemonCommand: ClientCommandHandler = async ({ positionals, flags } if (subcommand !== 'stop' || positionals.length !== 1) { throw new AppError('INVALID_ARGS', 'daemon accepts only: stop'); } - const paths = resolveDaemonPaths(flags.stateDir, { cwd: commandHostEnvironment().cwd }); + const paths = resolveDaemonPaths(flags.stateDir, { + cwd: commandHostEnvironment().cwd, + env: commandHostEnvironment().env, + }); const identity = readDaemonStopIdentity(paths.infoPath); const stopped = await stopDaemon({ paths }); const report = stopped.mode === 'graceful' ? readDaemonShutdownReport(paths.baseDir) : null; diff --git a/src/cli/connection/ended-lease.ts b/src/cli/connection/ended-lease.ts index 662856fb82..d1c84ae002 100644 --- a/src/cli/connection/ended-lease.ts +++ b/src/cli/connection/ended-lease.ts @@ -20,6 +20,7 @@ export function forgetEndedProxyLease(flags: CliFlags, response: DaemonResponse) if (endedLeaseIds.length === 0) return; const stateDir = resolveDaemonPaths(flags.stateDir, { cwd: commandHostEnvironment().cwd, + env: commandHostEnvironment().env, }).baseDir; const state = readRemoteConnectionState({ stateDir, session: flags.session ?? 'default' }); if (!state?.leaseId || !endedLeaseIds.includes(state.leaseId)) return; From fb9e4e6d1dba0ced1b62a5d1eb6797c20e836bd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 01:05:51 +0200 Subject: [PATCH 06/33] fix(host): bind the test command host per suite, not globally The global setup made every test related to every mutated source. Suites that reach host-reading code through neither a daemon request nor the CLI bind a host with bindTestCommandHost(). Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/macos-app-lease.test.ts | 3 +++ ...quest-execution-scope-lease-expiry.test.ts | 8 +++++- ...-execution-scope-lease-session-end.test.ts | 8 +++++- .../__tests__/request-execution-scope.test.ts | 8 +++++- ...mon-session-idle-expiry-scheduling.test.ts | 4 ++- .../http/daemon-session-idle-expiry.test.ts | 4 ++- .../http/daemon-shutdown-claims.test.ts | 4 ++- .../__tests__/lease-lifecycle.test.ts | 3 +++ ...ion-replay-repair-record-exclusion.test.ts | 4 ++- ...-repair-transaction-close-ordering.test.ts | 3 +++ ...on-replay-repair-transaction-force.test.ts | 3 +++ .../session-replay-repair-transaction.test.ts | 3 +++ .../replay-suite/session-test-runner.test.ts | 4 ++- .../replay-suite/session-test-suite.test.ts | 4 ++- .../session-appstate-device-runtime.test.ts | 7 ++++- ...ession-boot-shutdown-device-claims.test.ts | 8 +++++- .../__tests__/session-boot-warmup.test.ts | 3 +++ .../handlers/__tests__/session-boot.test.ts | 4 ++- .../__tests__/session-device-claims.test.ts | 9 ++++++- .../__tests__/session-push-ref-frame.test.ts | 3 +++ .../handlers/__tests__/session-push.test.ts | 4 ++- .../__tests__/session-reinstall.test.ts | 4 ++- .../handlers/__tests__/trace-runtime.test.ts | 4 ++- .../__tests__/application.test.ts | 3 +++ ...session-close-cwd-scoped-resources.test.ts | 4 ++- .../session-close-ended-lease.test.ts | 4 ++- .../session-close-error-precedence.test.ts | 3 +++ .../session-close-lifecycle-runtime.test.ts | 4 ++- .../session-close-resource-cleanup.test.ts | 4 ++- .../session-close-save-script.test.ts | 4 ++- .../session-close-shutdown-platform.test.ts | 4 ++- .../session-devices-batch-runtime.test.ts | 4 ++- .../session-inventory-appleos.test.ts | 3 +++ .../session-inventory-apps-runtime.test.ts | 7 ++++- .../session-inventory-harmonyos.test.ts | 7 ++++- .../session-open-execution-runtime.test.ts | 4 ++- .../__tests__/session-open-runtime.test.ts | 4 ++- .../session-teardown-resources.test.ts | 3 +++ .../session-close-lifecycle-teardown.test.ts | 4 ++- .../session-open-execution-relaunch.test.ts | 4 ++- .../__tests__/session-perf-runtime.test.ts | 7 ++++- packages/host-kit/src/internal/exec.test.ts | 17 ++++++++++++ .../__tests__/index-runner-requests.test.ts | 3 +++ .../interactor-macos-surface.test.ts | 3 +++ .../interactor-runner-provider.test.ts | 3 +++ .../interactor-snapshot-viewport.test.ts | 3 +++ .../__tests__/app-launch-close-quit.test.ts | 3 +++ .../src/core/__tests__/app-settings.test.ts | 3 +++ .../src/core/__tests__/debug-symbols.test.ts | 3 +++ .../platform-apple/src/gesture-facts.test.ts | 3 +++ .../src/os/macos/app-backend.test.ts | 3 +++ .../src/os/macos/helper-build.test.ts | 3 +++ .../src/os/macos/helper-resolution.test.ts | 3 +++ .../os/macos/native-app-interactor.test.ts | 3 +++ packages/platform-apple/src/runtime.test.ts | 3 +++ .../src/__tests__/hdc.test.ts | 3 +++ .../src/__tests__/atspi-bridge.test.ts | 3 +++ .../src/__tests__/clipboard.test.ts | 3 +++ .../src/__tests__/input-actions.test.ts | 3 +++ .../src/__tests__/tool-provider.test.ts | 3 +++ ...t-browser-provider-boundary-faults.test.ts | 3 +++ ...owser-provider-dispatch-disclosure.test.ts | 3 +++ ...nt-browser-provider-record-refresh.test.ts | 3 +++ ...-browser-provider-record-retention.test.ts | 3 +++ .../src/agent-browser-lifecycle.test.ts | 3 +++ .../src/agent-browser-provider-rects.test.ts | 3 +++ .../src/agent-browser-provider.test.ts | 3 +++ .../src/agent-browser-tool.test.ts | 3 +++ packages/test-kit/src/host-environment.ts | 17 ++++++++++++ packages/test-kit/src/index.ts | 2 +- src/__tests__/cli-capture.ts | 5 +++- src/__tests__/cloud-connect-auth.test.ts | 4 ++- src/__tests__/cloud-connect-profile.test.ts | 4 ++- src/__tests__/cloud-connect-testmu.test.ts | 9 ++++++- .../app-event-delivery.test.ts | 10 ++++++- .../gesture-admission-parity.test.ts | 3 +++ .../request-handler-chain.test.ts | 4 ++- src/__tests__/host-environment-setup.ts | 27 ------------------- .../remote-connection-harmonyos.test.ts | 3 +++ .../remote-connection-macos-app.test.ts | 3 +++ .../remote-connection-platform-axis.test.ts | 3 +++ ...-connection-proxy-allocate-by-spec.test.ts | 3 +++ .../remote-connection-proxy-lease-id.test.ts | 3 +++ src/__tests__/remote-connection.fixtures.ts | 5 +++- src/cli/commands/__tests__/daemon.test.ts | 4 ++- .../connection-runtime-metro-teardown.test.ts | 3 +++ src/cli/connection/ended-lease.test.ts | 3 +++ src/commands/cli-output.test.ts | 3 +++ src/commands/management/output.test.ts | 4 ++- src/platform-runtime-resource-cleanup.test.ts | 3 +++ vitest.config.ts | 1 - 91 files changed, 339 insertions(+), 68 deletions(-) delete mode 100644 src/__tests__/host-environment-setup.ts 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..24ab78ef96 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', 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 a3b579e732..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, createTestHostEnvironment } 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', 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 c21a2aaa56..71cc6fd2cf 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() }; 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 1c88d64006..5955331c06 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, createTestHostEnvironment } 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/index.ts'; +bindTestCommandHost(); + const TEST_ROOT = mkdtempForTestSync('agent-device-request-execution-scope-'); const LOG_PATH = path.join(TEST_ROOT, 'diagnostics.log'); 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..bbd16ee762 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,9 @@ 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 } 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 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..cf56496234 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,4 @@ -import { deviceClaimFilePath } from '@agent-device/test-kit'; +import { deviceClaimFilePath, bindTestCommandHost } from '@agent-device/test-kit'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -19,6 +19,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 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 0be294040c..34ddd5c7a1 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(); }); diff --git a/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts b/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts index ef2e3b8d2d..050a0bf0ee 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() }; 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 9787199b53..80a78db0c3 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,7 @@ 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 } from '@agent-device/test-kit'; vi.mock('@agent-device/platform-apple/runner/operations', () => ({ resolveRunnerAppBundleId: vi.fn(), @@ -69,6 +69,8 @@ import { legacyDispatchCapture, } from '../../../__tests__/support/legacy-snapshot-capture-fixture.ts'; +bindTestCommandHost(); + const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); 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..d6baee1e0e 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 } 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); 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..04f26d6ffe 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 } 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); 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..72df3fe4dd 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 } 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); 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..936cc2e815 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,11 @@ 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 } 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) => { 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..3e5b074199 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,11 @@ import { makeAndroidSession, makeMacOsSession, } from '../../../__tests__/support/session-factories.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; import { storeSessionForTest } from '../../../__tests__/support/store-factory.ts'; +bindTestCommandHost(); + const ANDROID_ONE: DeviceInfo = { platform: 'android', id: 'emulator-5554', 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..7d196a96fa 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,10 @@ 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, +} from '@agent-device/test-kit'; import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; import { type DeviceBinding, @@ -20,6 +23,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, 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 a1ff7329dc..e0d336d3d8 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, createTestHostEnvironment } 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 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..babc00f6cc 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 } 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', 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..5c6196f979 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts @@ -21,9 +21,11 @@ import { mockEnsureReadyRuntime, mockInspectDeviceRuntimeFacts, } from '../../../__tests__/support/handlers/session-command-harness.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, bindTestCommandHost } 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({ 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 b4b92c3a02..e768b38030 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,12 @@ 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, +} from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { @@ -66,6 +71,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); 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..aa09bafcb8 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 } 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'); }; 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..fd0980b53f 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,7 @@ 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 } from '@agent-device/test-kit'; import { handleSessionCommands, mockBindDeviceRuntime, @@ -20,6 +20,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, 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..c8532986f2 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,7 @@ 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 } from '@agent-device/test-kit'; import { trackUploadedArtifact } from '../../../resources/artifact-tracking.ts'; import type { BindDeviceRuntime, @@ -28,6 +28,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'), 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..3ff53fba7a 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,14 @@ 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 } 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')); 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 98377819ed..60d58b049f 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 } 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'; @@ -31,6 +32,8 @@ import { handleSessionOpenCommands, } from '../index.ts'; +bindTestCommandHost(); + const mockHandleSessionCloseCommands = vi.mocked(handleSessionCloseCommands); const mockHandleSessionInventoryCommands = vi.mocked(handleSessionInventoryCommands); const mockHandleSessionOpenCommands = vi.mocked(handleSessionOpenCommands); 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..aa95f1a45e 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,12 @@ import { sessionCloseShutdownFixture, type SessionState, } from './session-close-shutdown.fixtures.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, bindTestCommandHost } 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, 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..9c0ff878be 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,7 @@ 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 } 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 +13,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'; 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 98b78c5369..b374d11328 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 } 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, 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..2e1a7ba54f 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,9 @@ import { clearRuntimeHintsFixture, dispatchApplicationLifecycleEffect, } from '../../../../__tests__/support/application-lifecycle-runtime-fixture.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; + +bindTestCommandHost(); const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {} }); 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..eed1cc61f6 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,13 @@ import { sessionCloseShutdownFixture, type SessionState, } from './session-close-shutdown.fixtures.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, bindTestCommandHost } 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, 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..1087cb93e6 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,12 @@ 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 } 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, 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..c6de84dcdd 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,12 @@ import { type DeviceBinding, type PlatformRuntimeOperations, } from './session-close-shutdown.fixtures.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, bindTestCommandHost } 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, 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 56c57b34f4..65baaadc8d 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,9 @@ 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 } from '@agent-device/test-kit'; + +bindTestCommandHost(); const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, 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..11d27a38bb 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,11 @@ import { MACOS_DEVICE, TVOS_SIMULATOR, VISIONOS_SIMULATOR, + bindTestCommandHost, } from '@agent-device/test-kit'; +bindTestCommandHost(); + const mockListDeviceInventory = vi.mocked(listDeviceInventory); beforeEach(() => { 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..dc4b487c79 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,10 @@ 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, +} from '@agent-device/test-kit'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts, @@ -23,6 +26,8 @@ import { registerRequestAbort, } from '@agent-device/host-kit/request'; +bindTestCommandHost(); + const MACOS_DEVICE = { platform: 'apple' as const, appleOs: 'macos' as const, 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..5de4b3dd89 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,17 @@ 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, +} 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', 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 190d27cd11..cdbda173fc 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 @@ -76,7 +76,9 @@ import { makeSessionStore, noopInvoke, } from './session-open-runtime.fixtures.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; + +bindTestCommandHost(); const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsFixture); 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..8352b56567 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,9 @@ import { makeSessionStore, noopInvoke, } from './session-open-runtime.fixtures.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; + +bindTestCommandHost(); const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsFixture); 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..5bc7399349 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 } 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, 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..f2e6c03184 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,7 @@ 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 } 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 +17,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: {} }); 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..515e7497f3 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,7 @@ 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 } 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 +16,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) => { 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 ade44c5367..38a9b42cdc 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,10 @@ 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, +} from '@agent-device/test-kit'; import { createPerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; import type { BindDeviceRuntime, @@ -27,6 +30,8 @@ import type { } from '../../../../core/request-runtime-binding.ts'; import { handleSessionObservabilityCommands } from '../../index.ts'; +bindTestCommandHost(); + const available = Object.freeze({ available: true as const }); const unavailable = Object.freeze({ available: false as const, diff --git a/packages/host-kit/src/internal/exec.test.ts b/packages/host-kit/src/internal/exec.test.ts index da03e15f37..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, @@ -627,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/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/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/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/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/src/__tests__/hdc.test.ts b/packages/platform-harmonyos/src/__tests__/hdc.test.ts index 7fb6afeb80..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'; @@ -17,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' }; 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-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-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-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/test-kit/src/host-environment.ts b/packages/test-kit/src/host-environment.ts index 2e4b93d1ce..926ed71b8f 100644 --- a/packages/test-kit/src/host-environment.ts +++ b/packages/test-kit/src/host-environment.ts @@ -1,3 +1,5 @@ +import { aroundEach } from 'vitest'; +import { withCommandHost } from '@agent-device/host-kit/command'; import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; /** @@ -10,3 +12,18 @@ export function createTestHostEnvironment( ): HostEnvironment { return { 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 9d4078a9d6..7fa4ac3ae0 100644 --- a/packages/test-kit/src/index.ts +++ b/packages/test-kit/src/index.ts @@ -36,7 +36,7 @@ export { dispatchDisclosureRowsOwnedBy, } from './dispatch-disclosure.fixtures.ts'; export { restoreEnv } from './env.ts'; -export { createTestHostEnvironment } from './host-environment.ts'; +export { bindTestCommandHost, createTestHostEnvironment } from './host-environment.ts'; export { type FilesystemBoundaryFixture, type FilesystemBoundaryOwner, 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__/cloud-connect-auth.test.ts b/src/__tests__/cloud-connect-auth.test.ts index cfe18aef88..598e3fbf2b 100644 --- a/src/__tests__/cloud-connect-auth.test.ts +++ b/src/__tests__/cloud-connect-auth.test.ts @@ -7,7 +7,9 @@ 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 } from '@agent-device/test-kit'; + +bindTestCommandHost(); afterEach(() => { vi.unstubAllGlobals(); diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index e8aa253d5d..a10719697c 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -21,9 +21,11 @@ import { verifyAwsDeviceFarmConnection, verifyBrowserStackConnection, } from '@agent-device/provider-webdriver/connection-verification'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, bindTestCommandHost } 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(), diff --git a/src/__tests__/cloud-connect-testmu.test.ts b/src/__tests__/cloud-connect-testmu.test.ts index bb73ac1906..caf7967e04 100644 --- a/src/__tests__/cloud-connect-testmu.test.ts +++ b/src/__tests__/cloud-connect-testmu.test.ts @@ -12,12 +12,19 @@ import { verifyTestMuConnection } from '@agent-device/testmu/connection-verifica import testMuPlugin from '@agent-device/testmu'; import { createPluginHost } from '@agent-device/plugins/host'; 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 { installedPlugins } from '@agent-device/plugins/store'; import manifest from '@agent-device/testmu/package.json' with { type: 'json' }; import type { PluginConnection } 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/app-event-delivery.test.ts b/src/__tests__/daemon-composition/app-event-delivery.test.ts index c84810e602..9282badaa8 100644 --- a/src/__tests__/daemon-composition/app-event-delivery.test.ts +++ b/src/__tests__/daemon-composition/app-event-delivery.test.ts @@ -10,7 +10,15 @@ import { } from '@agent-device/daemon/testing/app-events'; import { getInteractor } from '../../core/interactors.ts'; import { AppError } from '@agent-device/kernel/errors'; -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'; + +bindTestCommandHost(); /** * The composed `trigger-app-event` path as R57 left it: the daemon's own policy resolves the diff --git a/src/__tests__/daemon-composition/gesture-admission-parity.test.ts b/src/__tests__/daemon-composition/gesture-admission-parity.test.ts index 40b45e79b0..99afe0358b 100644 --- a/src/__tests__/daemon-composition/gesture-admission-parity.test.ts +++ b/src/__tests__/daemon-composition/gesture-admission-parity.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test } from 'vitest'; import type { @@ -14,6 +15,8 @@ import { createRequestRuntimeBindings } from '@agent-device/daemon/testing/reque import { createRequestDispatchLedger } from '@agent-device/daemon/testing/request-dispatch-ledger'; import { resolveBoundGestureRuntime } from '@agent-device/daemon/testing/gesture-runtime'; +bindTestCommandHost(); + /** * The parity artifact for R42/R44 (ADR 0019 §6). * diff --git a/src/__tests__/daemon-composition/request-handler-chain.test.ts b/src/__tests__/daemon-composition/request-handler-chain.test.ts index 14f42b640e..eee842494e 100644 --- a/src/__tests__/daemon-composition/request-handler-chain.test.ts +++ b/src/__tests__/daemon-composition/request-handler-chain.test.ts @@ -9,7 +9,7 @@ import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; import { runRequestHandlerChain } from '@agent-device/daemon/testing/request-handler-chain'; import { getDaemonRouteOwnerFiles } from '@agent-device/command-registry/owner-files'; import type { DaemonRequest } from '@agent-device/daemon'; -import { LINUX_DEVICE } from '@agent-device/test-kit'; +import { LINUX_DEVICE, bindTestCommandHost } from '@agent-device/test-kit'; import { makeIosSession, makeSession, @@ -33,6 +33,8 @@ import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/ import { eagerClosureOf } from '../eager-import-closure.fixtures.ts'; import { testCreateCommandSurface } from './command-surface-fixture.ts'; +bindTestCommandHost(); + function makeRequest(command: string, positionals: string[] = []): DaemonRequest { return { command, diff --git a/src/__tests__/host-environment-setup.ts b/src/__tests__/host-environment-setup.ts deleted file mode 100644 index a11c65f08b..0000000000 --- a/src/__tests__/host-environment-setup.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { aroundEach } from 'vitest'; - -// Code that has no host of its own reads the enclosing command host, and production roots open -// one. A unit test is its own root, and many assign `process.env` directly, so each test runs as -// a host whose environment is the live process environment. The module loads inside the hook, so -// it is the instance the test's own imports see, and a suite that mocks `node:child_process` or -// replaces the module wholesale still gets its mock. -// Named by a variable so the module graph does not count it: a test is "related" to a source file -// through this setup otherwise, and every mutation scope would grow to the whole suite. -const COMMAND_MODULE = '@agent-device/host-kit/command'; - -aroundEach(async (runTest) => { - let withCommandHost: typeof import('@agent-device/host-kit/command').withCommandHost | undefined; - try { - ({ withCommandHost } = await import(/* @vite-ignore */ COMMAND_MODULE)); - } catch { - // A suite that replaces the module wholesale has no scope to bind. - } - if (!withCommandHost) { - await runTest(); - return; - } - await withCommandHost( - { host: { env: process.env, cwd: process.cwd() }, requestEnv: undefined }, - async () => await runTest(), - ); -}); diff --git a/src/__tests__/remote-connection-harmonyos.test.ts b/src/__tests__/remote-connection-harmonyos.test.ts index 776593a379..bc301bb5b2 100644 --- a/src/__tests__/remote-connection-harmonyos.test.ts +++ b/src/__tests__/remote-connection-harmonyos.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } 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( diff --git a/src/__tests__/remote-connection-macos-app.test.ts b/src/__tests__/remote-connection-macos-app.test.ts index 994f615e48..9f0e5e5bb6 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 } 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 { diff --git a/src/__tests__/remote-connection-platform-axis.test.ts b/src/__tests__/remote-connection-platform-axis.test.ts index d90973b63f..66b832e281 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 } 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(); 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..f18f74fa4b 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 } 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', diff --git a/src/__tests__/remote-connection-proxy-lease-id.test.ts b/src/__tests__/remote-connection-proxy-lease-id.test.ts index 41c32f561a..4145680887 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 } 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 = { 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/cli/commands/__tests__/daemon.test.ts b/src/cli/commands/__tests__/daemon.test.ts index 7012146f17..0a85abe6ad 100644 --- a/src/cli/commands/__tests__/daemon.test.ts +++ b/src/cli/commands/__tests__/daemon.test.ts @@ -2,7 +2,7 @@ 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 } from '@agent-device/test-kit'; const mocks = vi.hoisted(() => ({ cleanupDaemonOwner: vi.fn(async () => undefined), @@ -29,6 +29,8 @@ vi.mock('../shared.ts', () => ({ writeCommandOutput: mocks.writeCommandOutput }) import { daemonCommand } from '../daemon.ts'; +bindTestCommandHost(); + const GRACEFUL_RESULT: DaemonStopResult = { stopped: true, mode: 'graceful', diff --git a/src/cli/commands/connection-runtime-metro-teardown.test.ts b/src/cli/commands/connection-runtime-metro-teardown.test.ts index cd2a366b7b..30a0ceb3d6 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 } 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(); diff --git a/src/cli/connection/ended-lease.test.ts b/src/cli/connection/ended-lease.test.ts index c09d432a69..453b7ee489 100644 --- a/src/cli/connection/ended-lease.test.ts +++ b/src/cli/connection/ended-lease.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 { 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; 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/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/vitest.config.ts b/vitest.config.ts index 4092b5f24d..eab8d3bfc8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -82,7 +82,6 @@ export const TEST_ISOLATION = { unstubEnvs: true } as const; export const SETUP_FILES = [ 'src/__tests__/hermetic-env-setup.ts', 'src/__tests__/hermetic-signal-setup.ts', - 'src/__tests__/host-environment-setup.ts', 'src/__tests__/process-memo-setup.ts', ]; From 30bbef5f96a590ee8ba4ddb04b4ecd9edea83d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 01:18:39 +0200 Subject: [PATCH 07/33] chore(deps): declare test-kit in the HarmonyOS and Linux platform packages Co-Authored-By: Claude Sonnet 5.5 --- packages/platform-harmonyos/package.json | 3 +++ packages/platform-harmonyos/tsconfig.json | 3 ++- packages/platform-linux/package.json | 3 +++ packages/platform-linux/tsconfig.json | 3 ++- pnpm-lock.yaml | 8 ++++++++ 5 files changed, 18 insertions(+), 2 deletions(-) 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/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/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/pnpm-lock.yaml b/pnpm-lock.yaml index 8af7cc1611..27b2a09611 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -531,6 +531,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: @@ -546,6 +550,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: From d522d257de0dc9857fc9828b3a9d7f670460f698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 01:27:20 +0200 Subject: [PATCH 08/33] fix(host): bind the test command host in the stale-provider admission scenarios Co-Authored-By: Claude Sonnet 5.5 --- .../stale-provider-runtime-admission.test.ts | 3 +++ 1 file changed, 3 insertions(+) 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 69c8cce22c..f99d2144f7 100644 --- a/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts +++ b/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts @@ -18,6 +18,9 @@ import { } from './cloud-webdriver-regression-fixtures.ts'; import { inspectProviderDeploymentAdmission } from './stale-provider-runtime-admission.fixtures.ts'; import { CLOUD_WEBDRIVER_PROVIDERS } from '@agent-device/provider-webdriver/providers'; +import { bindTestCommandHost } from '@agent-device/test-kit'; + +bindTestCommandHost(); const limrunState = vi.hoisted(() => ({ androidCreate: vi.fn(async () => ({ From 756cf4e012e40c22e0da52601a5c5dab575e5b9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:15:28 +0200 Subject: [PATCH 09/33] fix(host): resolve every client read from the client's host createAgentDeviceClient's default transport, the daemon launch and the CLI run's transport read the host the client was given, not the process. Co-Authored-By: Claude Sonnet 5.5 --- packages/test-kit/src/host-environment.ts | 6 ++- .../daemon-registration-owner.test.ts | 16 +++--- src/agent-device-client.ts | 4 +- src/cli.ts | 21 +++++--- src/cli/commands/__tests__/proxy.test.ts | 3 ++ src/cli/commands/proxy.ts | 25 ++++++---- .../daemon-client-lease-beat.test.ts | 1 + src/daemon-client/daemon-client-host.test.ts | 44 +++++++++++++++++ src/daemon-client/daemon-client-lifecycle.ts | 48 ++++++++++++------ src/daemon-client/daemon-client-metadata.ts | 6 +-- src/daemon-client/daemon-client.ts | 49 ++++++++++++++++--- 11 files changed, 169 insertions(+), 54 deletions(-) create mode 100644 src/daemon-client/daemon-client-host.test.ts diff --git a/packages/test-kit/src/host-environment.ts b/packages/test-kit/src/host-environment.ts index 926ed71b8f..10655324a9 100644 --- a/packages/test-kit/src/host-environment.ts +++ b/packages/test-kit/src/host-environment.ts @@ -1,5 +1,6 @@ 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'; /** @@ -10,7 +11,10 @@ import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value' export function createTestHostEnvironment( options: { env?: EnvMap; cwd?: string } = {}, ): HostEnvironment { - return { env: { ...(options.env ?? process.env) }, cwd: options.cwd ?? process.cwd() }; + return captureHostEnvironment({ + env: options.env ?? process.env, + cwd: () => options.cwd ?? process.cwd(), + }); } /** diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index 5a93dfbcc7..e6c9838dd2 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -827,12 +827,16 @@ test('cutover refuses an already-running legacy daemon before signaling or chang assert.equal(disposition.exitCode, DAEMON_STARTUP_EXIT_CODES.unproven); await assert.rejects( ensureDaemon( - resolveClientSettings({ - session: 'default', - command: 'devices', - positionals: [], - flags: { stateDir: paths.baseDir }, - }), + resolveClientSettings( + { + session: 'default', + command: 'devices', + positionals: [], + flags: { stateDir: paths.baseDir }, + }, + undefined, + { env: process.env, cwd: process.cwd() }, + ), ), (error: unknown) => { assert.ok(error instanceof AppError); diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 47bae98192..f32247ecf1 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -58,7 +58,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, @@ -85,8 +85,8 @@ export function createAgentDeviceClient( config: AgentDeviceClientConfig = {}, deps: { transport?: AgentDeviceDaemonTransport; host?: HostEnvironment } = {}, ): AgentDeviceClient { - const transport = deps.transport ?? sendToDaemon; const host = deps.host ?? captureHostEnvironment(process); + const transport = deps.transport ?? createDaemonTransport(host); // A non-default responseLevel (digest/full) makes the daemon return a leveled // shape; the per-command client normalizers assume the default shape, so the diff --git a/src/cli.ts b/src/cli.ts index 02db19f261..dc42a9bcec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,7 +14,11 @@ 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 { sendToDaemon } from './daemon-client/daemon-client.ts'; +import { + createDaemonTransport, + 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'; @@ -54,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; @@ -61,6 +67,11 @@ type CliDaemonRequest = Parameters[0]; type CliDaemonTransportOptions = Parameters[1]; type ClientDaemonRequest = Parameters[0]; +const DEFAULT_CLI_DEPS: CliDeps = { + sendToDaemon, + transportForHost: createDaemonTransport, +}; + const printHumanError = ( err: AppError | NormalizedError, options: { showDetails?: boolean } = {}, @@ -72,10 +83,6 @@ const printJson = ( ): Promise => import('./commands/output/json.ts').then(({ printJson: write }) => write(result)); -const DEFAULT_CLI_DEPS: CliDeps = { - sendToDaemon, -}; - const METRO_RUNTIME_OVERRIDE_FLAG_KEYS = new Set([ 'launchUrl', 'kind', @@ -196,7 +203,7 @@ async function runCliOnHost(argv: string[], deps: CliDeps, host: HostEnvironment flags: ctx.effectiveFlags, replayTestReporterRuntime, commandProgress, - transport: deps.sendToDaemon, + transport: resolveRunTransport(deps, host), }), }); await dispatchCliCommand(ctx, client, replayTestReporterRuntime, commandProgress); @@ -411,7 +418,7 @@ async function resolveRemoteContext(ctx: CliRunContext, deps: CliDeps): Promise< if (ctx.effectiveFlags.remoteConfig && shouldMaterializeRemoteConnection(ctx.command)) { const materializationClient = createAgentDeviceClient(buildClientConfig(ctx), { host: ctx.host, - transport: createClientDaemonTransport(deps.sendToDaemon), + transport: createClientDaemonTransport(resolveRunTransport(deps, ctx.host)), }); const { materializeRemoteConnectionForCommand } = await import('./cli/commands/connection-runtime.ts'); diff --git a/src/cli/commands/__tests__/proxy.test.ts b/src/cli/commands/__tests__/proxy.test.ts index ef33cd29e5..b72a5870d5 100644 --- a/src/cli/commands/__tests__/proxy.test.ts +++ b/src/cli/commands/__tests__/proxy.test.ts @@ -10,6 +10,7 @@ import { listenOnLoopback, mkdtempForTestSync, skipWhenLoopbackUnavailable, + bindTestCommandHost, } from '@agent-device/test-kit'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; @@ -46,6 +47,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, { diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index 0d3036ab71..e4f9f9e08b 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { randomBytes } from 'node:crypto'; import { createDaemonProxyServer } from '@agent-device/proxy'; import { buildDaemonHttpBaseUrl } from '@agent-device/contracts/daemon-http'; @@ -30,17 +31,21 @@ export const proxyCommand: ClientCommandHandler = async ({ positionals, flags }) }; async function startProxy(flags: CliFlags): Promise { - const settings = resolveClientSettings({ - session: 'default', - command: 'proxy', - positionals: [], - flags: { - stateDir: flags.stateDir, - daemonBaseUrl: '', - daemonTransport: 'http', - daemonServerMode: 'http', + const settings = resolveClientSettings( + { + session: 'default', + command: 'proxy', + positionals: [], + flags: { + stateDir: flags.stateDir, + daemonBaseUrl: '', + daemonTransport: 'http', + daemonServerMode: 'http', + }, }, - }); + undefined, + commandHostEnvironment(), + ); const daemon = await ensureDaemon(settings); const upstreamBaseUrl = resolveLocalDaemonBaseUrl(daemon.info.httpPort); const token = resolveProxyClientToken(flags); 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 6c2881b2b1..b199fd3b59 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, serverMode: 'socket' as const, 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..801588c34a --- /dev/null +++ b/src/daemon-client/daemon-client-host.test.ts @@ -0,0 +1,44 @@ +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; + +const ensured = vi.hoisted(() => ({ settings: undefined as unknown })); + +vi.mock('./daemon-client-lifecycle.ts', async (importOriginal) => ({ + ...(await importOriginal()), + ensureDaemon: async (settings: unknown) => { + ensured.settings = settings; + throw new Error('stop before talking to a daemon'); + }, +})); + +import { createAgentDeviceClient } from '../agent-device-client.ts'; +import { createDaemonTransport } from './daemon-client.ts'; +import type { DaemonClientSettings } from './daemon-client-lifecycle.ts'; + +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'); +}); diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index dadc50f169..d22edaa659 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -8,6 +8,7 @@ import { AppError, normalizeError, type NormalizedError } from '@agent-device/ke import { readReplayDivergenceResume } from '@agent-device/ad-replay/divergence'; import { type ExecDetachedExit } from '@agent-device/host-kit/command'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; +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'; @@ -53,6 +54,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; transportPreference: DaemonTransportPreference; serverMode: DaemonServerMode; @@ -85,16 +88,20 @@ LOOPBACK_BLOCK_LIST.addSubnet('::ffff:127.0.0.0', 104, 'ipv6'); export function resolveClientSettings( req: Omit, - suppliedAuthToken?: string, + suppliedAuthToken: string | undefined, + host: HostEnvironment, ): DaemonClientSettings { - const explicitStateDir = resolveExplicitStateDir(req); - const remote = resolveRemoteClientSettings(req, suppliedAuthToken); - const transport = resolveTransportClientSettings(req, remote.remoteBaseUrl); + const explicitStateDir = resolveExplicitStateDir(req, host.env); + const remote = resolveRemoteClientSettings(req, suppliedAuthToken, host.env); + const transport = resolveTransportClientSettings(req, remote.remoteBaseUrl, host.env); const ownedStateDir = shouldUseOwnedReplayStateDir(req, explicitStateDir, remote.rawBaseUrl) ? createOwnedReplayStateDir() : undefined; return { - paths: ownedStateDir?.paths ?? resolveDaemonPaths(explicitStateDir, { cwd: process.cwd() }), + host, + paths: + ownedStateDir?.paths ?? + resolveDaemonPaths(explicitStateDir, { env: host.env, cwd: host.cwd }), transportPreference: transport.preference, serverMode: transport.serverMode, ownedStateDir, @@ -103,21 +110,25 @@ export function resolveClientSettings( }; } -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 }; } @@ -125,8 +136,9 @@ function resolveRemoteClientSettings( function resolveTransportClientSettings( req: Omit, remoteBaseUrl: string | undefined, + env: Readonly, ): { preference: DaemonTransportPreference; serverMode: DaemonServerMode } { - const rawTransport = req.flags?.daemonTransport ?? process.env.AGENT_DEVICE_DAEMON_TRANSPORT; + const rawTransport = req.flags?.daemonTransport ?? env.AGENT_DEVICE_DAEMON_TRANSPORT; const preference = resolveDaemonTransportPreference(rawTransport); if (remoteBaseUrl && preference === 'socket') { throw new AppError( @@ -137,7 +149,7 @@ function resolveTransportClientSettings( } const rawServerMode = req.flags?.daemonServerMode ?? - process.env.AGENT_DEVICE_DAEMON_SERVER_MODE ?? + env.AGENT_DEVICE_DAEMON_SERVER_MODE ?? (rawTransport === 'dual' ? 'dual' : undefined); return { preference, @@ -160,7 +172,7 @@ export async function ensureDaemon(settings: DaemonClientSettings): Promise { - if (!process.env.AGENT_DEVICE_DAEMON_POLICY?.trim()) return; +async function assertDaemonPolicyMatches( + existing: DaemonInfo, + stateDir: string, + env: Readonly, +): Promise { + 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)?.digest; if (expected === existing.policyDigest) return; throw new AppError( 'COMMAND_FAILED', @@ -815,7 +831,7 @@ function startDaemon(settings: DaemonClientSettings): DaemonStartupLaunch { return launchDaemonProcess({ paths: settings.paths, serverMode: settings.serverMode, - env: process.env, + env: settings.host.env, 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 e295d3f0be..6eb4e29284 100644 --- a/src/daemon-client/daemon-client-metadata.ts +++ b/src/daemon-client/daemon-client-metadata.ts @@ -4,7 +4,6 @@ import { isProcessPid } from '@agent-device/host-kit/process'; import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; import { - resolveDaemonPaths, type DaemonPaths, type DaemonServerMode, } from '@agent-device/daemon-contracts/daemon-resolution'; @@ -94,10 +93,7 @@ export function isRemoteDaemon(info: DaemonInfo): boolean { export function resolveDaemonStartupHint( state: { hasInfo: boolean; hasLock: boolean }, - paths: Pick = resolveDaemonPaths( - process.env.AGENT_DEVICE_STATE_DIR, - { cwd: process.cwd() }, - ), + 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.ts b/src/daemon-client/daemon-client.ts index b8e103c0b6..ac701d253f 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, @@ -26,9 +28,37 @@ 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 every read of the environment and working directory is `host`'s. */ +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, ensureDaemon, attachSessionAddressHints } = await import('./daemon-client-lifecycle.ts'); @@ -45,6 +75,7 @@ export async function sendToDaemon( const settings = resolveClientSettings( requestWithoutAuthFlag, options.authToken ?? flagAuthToken, + host, ); const requestTimeoutMs = resolveCommandRequestTimeoutMs( resolveCommandTimeoutPolicy(requestWithoutAuthFlag.command), @@ -70,7 +101,7 @@ export async function sendToDaemon( options.signal, ), ); - writeInstallInProgressNotice(requestWithoutAuthFlag.command); + writeInstallInProgressNotice(requestWithoutAuthFlag.command, host.env); const request = buildTransportRequest( requestWithoutAuthFlag, @@ -78,7 +109,8 @@ export async function sendToDaemon( info, requestId, debug, - await readLocalProviderCredentialFingerprint(requestWithoutAuthFlag, info), + await readLocalProviderCredentialFingerprint(requestWithoutAuthFlag, info, host.env), + host.env, ); emitDiagnostic({ level: 'info', @@ -119,6 +151,7 @@ function buildTransportRequest( requestId: string, debug: boolean, providerCredentialFingerprint: string | undefined, + env: Readonly, ): DaemonRequest { return { ...request, @@ -127,7 +160,7 @@ function buildTransportRequest( token: info.token, meta: { ...buildTransportRequestMeta(request, preparedRemoteRequest, requestId, debug), - ...buildLocalHostEnvMeta(info), + ...buildLocalHostEnvMeta(info, env), providerCredentialFingerprint, }, }; @@ -138,19 +171,21 @@ 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); if (!provider) return undefined; const { providerCredentialFingerprint } = await import('../provider-credential-fingerprint.ts'); - return providerCredentialFingerprint(provider, process.env, leaseBackend); + return providerCredentialFingerprint(provider, env, leaseBackend); } // 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 } : {}; } @@ -258,8 +293,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', ); From 63b984bcafcb5cba20ff3a1cc26bf1fbf93e49e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:39:06 +0200 Subject: [PATCH 10/33] fix(host): resolve the daemon policy path against the host working directory Co-Authored-By: Claude Sonnet 5.5 --- .../src/daemon-policy-file.test.ts | 19 +++++++++--- .../src/daemon-policy-file.ts | 4 +-- .../daemon/src/server/http/daemon-runtime.ts | 2 +- .../daemon-registration-owner.test.ts | 4 +-- .../daemon-client-policy-reuse.test.ts | 4 +-- src/daemon-client/daemon-client-host.test.ts | 31 ++++++++++++++++++- src/daemon-client/daemon-client-lifecycle.ts | 7 +++-- src/daemon-client/daemon-client.ts | 5 ++- 8 files changed, 59 insertions(+), 17 deletions(-) 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 dc4d0d50df..06947932ca 100644 --- a/packages/daemon-contracts/src/daemon-policy-file.ts +++ b/packages/daemon-contracts/src/daemon-policy-file.ts @@ -73,10 +73,10 @@ export function resolveDaemonPolicyCommandName(command: string): string | undefi const POLICY_KEYS = new Set(['version', 'devices', 'commands', 'capabilities', 'leases']); -export function loadDaemonPolicy(env: EnvMap): 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/src/server/http/daemon-runtime.ts b/packages/daemon/src/server/http/daemon-runtime.ts index 9e245c567c..4d8643be27 100644 --- a/packages/daemon/src/server/http/daemon-runtime.ts +++ b/packages/daemon/src/server/http/daemon-runtime.ts @@ -417,7 +417,7 @@ async function startDaemonRuntimeOnHost( // 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); diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index e6c9838dd2..f8dd1e7bee 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -20,7 +20,7 @@ import { } from '@agent-device/daemon-contracts/daemon-resolution'; 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 { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; import { spawnLegacyDaemonFixture } from './daemon-registration-owner.fixtures.ts'; import { registeredDaemonFixtureArgs, @@ -835,7 +835,7 @@ test('cutover refuses an already-running legacy daemon before signaling or chang flags: { stateDir: paths.baseDir }, }, undefined, - { env: process.env, cwd: process.cwd() }, + createTestHostEnvironment(), ), ), (error: unknown) => { 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 3440fa7bfb..c8470db576 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,7 @@ 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(process.env)?.digest); + assert.equal(error.details?.expectedPolicyDigest, loadDaemonPolicy(process.env, process.cwd())?.digest); return true; }, ); @@ -154,7 +154,7 @@ async function startReusableDaemon(options: { processStartTime: readProcessStartTime(process.pid) ?? undefined, policyDigest: options.policyDigest === 'matching' - ? loadDaemonPolicy(process.env)?.digest + ? loadDaemonPolicy(process.env, process.cwd())?.digest : options.policyDigest, })}\n`, ); diff --git a/src/daemon-client/daemon-client-host.test.ts b/src/daemon-client/daemon-client-host.test.ts index 801588c34a..7f2200d6ea 100644 --- a/src/daemon-client/daemon-client-host.test.ts +++ b/src/daemon-client/daemon-client-host.test.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { expect, test, vi } from 'vitest'; +import { afterEach, expect, test, vi } from 'vitest'; const ensured = vi.hoisted(() => ({ settings: undefined as unknown })); @@ -11,10 +11,15 @@ vi.mock('./daemon-client-lifecycle.ts', async (importOriginal) => ({ }, })); +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 () => { @@ -42,3 +47,27 @@ test('a client reports and uses the same state dir when its host and the process 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-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index d22edaa659..028686c8f0 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -172,7 +172,7 @@ export async function ensureDaemon(settings: DaemonClientSettings): Promise, + host: HostEnvironment, ): Promise { + 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(env)?.digest; + const expected = loadDaemonPolicy(env, host.cwd)?.digest; if (expected === existing.policyDigest) return; throw new AppError( 'COMMAND_FAILED', diff --git a/src/daemon-client/daemon-client.ts b/src/daemon-client/daemon-client.ts index ac701d253f..395cbf2f35 100644 --- a/src/daemon-client/daemon-client.ts +++ b/src/daemon-client/daemon-client.ts @@ -36,7 +36,10 @@ export async function sendToDaemon( return await sendToDaemonOnHost(req, options, captureHostEnvironment(process)); } -/** A transport whose every read of the environment and working directory is `host`'s. */ +/** + * 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, From f4616b915bd84ba0d2618844c7bc714191311e89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:49:45 +0200 Subject: [PATCH 11/33] style: format the daemon policy reuse test Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/daemon-client-policy-reuse.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 c8470db576..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(process.env, process.cwd())?.digest); + assert.equal( + error.details?.expectedPolicyDigest, + loadDaemonPolicy(process.env, process.cwd())?.digest, + ); return true; }, ); From 7c68072558557bad488ddd7edf87ce5e4e57670b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 09:14:07 +0200 Subject: [PATCH 12/33] test: merge the duplicate test-kit import in the handler chain routing test Co-Authored-By: Claude Sonnet 5.5 --- .../daemon/src/sessions/request-handler-chain-routing.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 c5b5b236ee..54b038e54b 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,7 @@ 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 } 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,7 +31,6 @@ 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'; -import { bindTestCommandHost } from '@agent-device/test-kit'; bindTestCommandHost(); const testCreateCommandSurface = createTouchCommandSurface(); From 3c622c3ef3698fb804b03c5b32382c2dae7978f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 09:50:18 +0200 Subject: [PATCH 13/33] fix(host): start a launched daemon in the launching host's working directory Co-Authored-By: Claude Sonnet 5.5 --- .../src/daemon-registration-owner.ts | 3 ++ .../daemon-registration-owner.test.ts | 36 ++++++++++++++++++- src/daemon-client/daemon-client-lifecycle.ts | 1 + 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/daemon-contracts/src/daemon-registration-owner.ts b/packages/daemon-contracts/src/daemon-registration-owner.ts index b08caa1357..820eeb21b4 100644 --- a/packages/daemon-contracts/src/daemon-registration-owner.ts +++ b/packages/daemon-contracts/src/daemon-registration-owner.ts @@ -249,6 +249,8 @@ export function launchDaemonProcess( 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 { @@ -261,6 +263,7 @@ export function launchDaemonProcess( const logFd = fs.openSync(input.paths.logPath, 'a'); try { const monitored = runCmdDetachedMonitored(process.execPath, input.args, { + cwd: input.cwd, env: { ...input.env, AGENT_DEVICE_STATE_DIR: input.paths.baseDir, diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index f8dd1e7bee..8e7e3be0c1 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -459,6 +459,7 @@ test('private retirement closes startup admission, joins the actual child and ne const args = registeredDaemonFixtureArgs(paths, fields); const launch = launchDaemonProcess({ env: process.env, + cwd: process.cwd(), paths, args, serverMode: 'socket', @@ -470,6 +471,7 @@ test('private retirement closes startup admission, joins the actual child and ne await waitForFixtureFile(paths.infoPath); contender = launchDaemonProcess({ env: process.env, + cwd: process.cwd(), paths, args, serverMode: 'socket', @@ -485,7 +487,14 @@ test('private retirement closes startup admission, joins the actual child and ne const pending = stopAndRetireDaemon(input); assert.throws( () => - launchDaemonProcess({ env: process.env, paths, args, serverMode: 'socket', ownedStateDir }), + launchDaemonProcess({ + env: process.env, + cwd: process.cwd(), + paths, + args, + serverMode: 'socket', + ownedStateDir, + }), (error: { details?: { reason?: string } }) => error.details?.reason === 'daemon_startup_admission_closed', ); @@ -514,6 +523,7 @@ test('private retirement retains the directory while an earlier actual startup c ); const first = launchDaemonProcess({ env: process.env, + cwd: process.cwd(), paths, args, serverMode: 'socket', @@ -524,6 +534,7 @@ test('private retirement retains the directory while an earlier actual startup c await waitForFixtureFile(ready); second = launchDaemonProcess({ env: process.env, + cwd: process.cwd(), paths, args: registeredDaemonFixtureArgs(paths, fields), serverMode: 'socket', @@ -549,6 +560,24 @@ test('private retirement retains the directory while an earlier actual startup c } }); +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); +}); + const privateStartupFailures = [ ['missing-birth', 'ownership-unproven', []], ['unverified-birth', 'exit-unconfirmed', []], @@ -571,6 +600,7 @@ for (const [failure, reason, expectedSignals] of privateStartupFailures) { : undefined; const launch = launchDaemonProcess({ env: process.env, + cwd: process.cwd(), paths, args, serverMode: 'socket', @@ -664,6 +694,7 @@ test('private startup completion stays bounded without published birth proof and const birthProbe = vi.spyOn(hostProcess, 'readProcessStartTime').mockReturnValueOnce(null); const launch = launchDaemonProcess({ env: process.env, + cwd: process.cwd(), paths, args: registeredDaemonFixtureArgs(paths, fields), serverMode: 'socket', @@ -690,6 +721,7 @@ test('private startup completion stays bounded without published birth proof and () => launchDaemonProcess({ env: process.env, + cwd: process.cwd(), paths, args: [], serverMode: 'socket', @@ -742,6 +774,7 @@ for (const contents of [ fs.writeFileSync(evidencePath, contents); const launch = launchDaemonProcess({ env: process.env, + cwd: process.cwd(), paths, args: registeredDaemonFixtureArgs(paths, fields), serverMode: 'socket', @@ -952,6 +985,7 @@ test.for(['different pid', 'different birth', 'joined child'] as const)( const probe = vi.spyOn(hostProcess, 'readProcessStartTime').mockReturnValueOnce(null); const launch = launchDaemonProcess({ env: process.env, + cwd: process.cwd(), paths, args: registeredDaemonFixtureArgs(paths, fields), serverMode: 'socket', diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index 028686c8f0..e69bfb04f5 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -833,6 +833,7 @@ function startDaemon(settings: DaemonClientSettings): DaemonStartupLaunch { 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] From ab5ee6e3fef9a73c34170291cad0602e08603ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 09:56:19 +0200 Subject: [PATCH 14/33] test: keep the registration-owner test under its size ratchet Co-Authored-By: Claude Sonnet 5.5 --- src/__tests__/daemon-launch-cwd.test.ts | 25 ++++ .../daemon-registration-owner.fixtures.ts | 18 +++ .../daemon-registration-owner.test.ts | 117 +++--------------- 3 files changed, 61 insertions(+), 99 deletions(-) create mode 100644 src/__tests__/daemon-launch-cwd.test.ts 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 8e7e3be0c1..59109325ec 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'; @@ -20,14 +19,18 @@ import { } from '@agent-device/daemon-contracts/daemon-resolution'; import { readRegisteredDaemonOwnership } from '@agent-device/daemon-contracts/daemon-registration'; import { readDaemonShutdownReport } from '@agent-device/daemon-contracts/daemon-shutdown-report'; -import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; -import { spawnLegacyDaemonFixture } from './daemon-registration-owner.fixtures.ts'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + devicesClientSettings, + launchDaemonProcess, + spawnLegacyDaemonFixture, +} from './daemon-registration-owner.fixtures.ts'; import { registeredDaemonFixtureArgs, spawnRegisteredDaemonFixture, finishRegisteredDaemonFixture, } from './test-utils/registered-daemon-fixture.ts'; -import { ensureDaemon, resolveClientSettings } from '../daemon-client/daemon-client-lifecycle.ts'; +import { ensureDaemon } 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'; @@ -457,26 +460,12 @@ test('private retirement closes startup admission, joins the actual child and ne const ownedStateDir = createOwnedReplayStateDir(); const paths = ownedStateDir.paths; const args = registeredDaemonFixtureArgs(paths, fields); - const launch = launchDaemonProcess({ - env: process.env, - cwd: process.cwd(), - paths, - args, - serverMode: 'socket', - ownedStateDir, - }); + const launch = launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }); let contender: ReturnType | undefined; try { assert.ok(launch.startTime); await waitForFixtureFile(paths.infoPath); - contender = launchDaemonProcess({ - env: process.env, - cwd: process.cwd(), - paths, - args, - serverMode: 'socket', - ownedStateDir, - }); + contender = launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }); assert.equal((await contender.exited).exitCode, DAEMON_STARTUP_EXIT_CODES.busy); const input = { paths, @@ -486,15 +475,7 @@ test('private retirement closes startup admission, joins the actual child and ne }; const pending = stopAndRetireDaemon(input); assert.throws( - () => - launchDaemonProcess({ - env: process.env, - cwd: process.cwd(), - paths, - args, - serverMode: 'socket', - ownedStateDir, - }), + () => launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }), (error: { details?: { reason?: string } }) => error.details?.reason === 'daemon_startup_admission_closed', ); @@ -521,20 +502,11 @@ test('private retirement retains the directory while an earlier actual startup c entry, `import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(ready)}, 'ready'); setInterval(() => {}, 1000);`, ); - const first = launchDaemonProcess({ - env: process.env, - cwd: process.cwd(), - paths, - args, - serverMode: 'socket', - ownedStateDir, - }); + const first = launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }); let second: ReturnType | undefined; try { await waitForFixtureFile(ready); second = launchDaemonProcess({ - env: process.env, - cwd: process.cwd(), paths, args: registeredDaemonFixtureArgs(paths, fields), serverMode: 'socket', @@ -560,24 +532,6 @@ test('private retirement retains the directory while an earlier actual startup c } }); -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); -}); - const privateStartupFailures = [ ['missing-birth', 'ownership-unproven', []], ['unverified-birth', 'exit-unconfirmed', []], @@ -598,14 +552,7 @@ for (const [failure, reason, expectedSignals] of privateStartupFailures) { failure === 'missing-birth' ? vi.spyOn(hostProcess, 'readProcessStartTime').mockReturnValueOnce(null) : undefined; - const launch = launchDaemonProcess({ - env: process.env, - cwd: process.cwd(), - paths, - args, - serverMode: 'socket', - ownedStateDir, - }); + const launch = launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }); birthProbe?.mockRestore(); let actualStartTime: string | null = null; let exitTimer: ReturnType | undefined; @@ -693,8 +640,6 @@ test('private startup completion stays bounded without published birth proof and const paths = ownedStateDir.paths; const birthProbe = vi.spyOn(hostProcess, 'readProcessStartTime').mockReturnValueOnce(null); const launch = launchDaemonProcess({ - env: process.env, - cwd: process.cwd(), paths, args: registeredDaemonFixtureArgs(paths, fields), serverMode: 'socket', @@ -718,15 +663,7 @@ test('private startup completion stays bounded without published birth proof and startupJoinTimeoutMs: 0, }); assert.throws( - () => - launchDaemonProcess({ - env: process.env, - cwd: process.cwd(), - paths, - args: [], - serverMode: 'socket', - ownedStateDir, - }), + () => launchDaemonProcess({ paths, args: [], serverMode: 'socket', ownedStateDir }), (error: { details?: { reason?: string } }) => error.details?.reason === 'daemon_startup_admission_closed', ); @@ -773,8 +710,6 @@ for (const contents of [ const evidencePath = `${sessionDir}/repair-tombstone.json`; fs.writeFileSync(evidencePath, contents); const launch = launchDaemonProcess({ - env: process.env, - cwd: process.cwd(), paths, args: registeredDaemonFixtureArgs(paths, fields), serverMode: 'socket', @@ -858,25 +793,11 @@ 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( - ensureDaemon( - resolveClientSettings( - { - session: 'default', - command: 'devices', - positionals: [], - flags: { stateDir: paths.baseDir }, - }, - undefined, - createTestHostEnvironment(), - ), - ), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.details?.reason, 'daemon_registration_unproven'); - return true; - }, - ); + await assert.rejects(ensureDaemon(devicesClientSettings(paths.baseDir)), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, 'daemon_registration_unproven'); + return true; + }); assert.equal(process.kill(legacy.pid, 0), true); assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), metadata); assert.equal(fs.readFileSync(paths.lockPath, 'utf8'), lock); @@ -984,8 +905,6 @@ test.for(['different pid', 'different birth', 'joined child'] as const)( const paths = ownedStateDir.paths; const probe = vi.spyOn(hostProcess, 'readProcessStartTime').mockReturnValueOnce(null); const launch = launchDaemonProcess({ - env: process.env, - cwd: process.cwd(), paths, args: registeredDaemonFixtureArgs(paths, fields), serverMode: 'socket', From 665af8ae86ba907af62733c895830fcb056eb3da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 10:54:03 +0200 Subject: [PATCH 15/33] test: merge the duplicate test-kit import in the stale provider admission scenarios Co-Authored-By: Claude Sonnet 5.5 --- .../stale-provider-runtime-admission.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 3c7ae39d43..050f6c98cb 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 '@agent-device/provider-webd 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'; @@ -24,7 +24,6 @@ import { } from './cloud-webdriver-regression-fixtures.ts'; import { inspectProviderDeploymentAdmission } from './stale-provider-runtime-admission.fixtures.ts'; import { CLOUD_WEBDRIVER_PROVIDERS } from '@agent-device/provider-webdriver/providers'; -import { bindTestCommandHost } from '@agent-device/test-kit'; bindTestCommandHost(); From 7eb7379cbb779d1bc5abbaaea7b88f14af5e98d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 11:17:18 +0200 Subject: [PATCH 16/33] refactor(daemon): read configuration from the request host instead of the ambient scope Handlers, claim writers and lease admission take the host (or its env) from the request execution scope, runtime-owned timers take it at construction. commandHostEnvironment() stays for command execution until the platform and root packages convert. Co-Authored-By: Claude Sonnet 5.5 --- .../src/core/session-runtime-admission.ts | 2 + .../session-selector-dispatch.test.ts | 12 +- .../__tests__/macos-app-lease.test.ts | 135 +++++++++++++++--- .../daemon/src/resources/macos-app-lease.ts | 23 ++- ...-execution-scope-lease-session-end.test.ts | 7 +- .../__tests__/request-execution-scope.test.ts | 1 + .../daemon/src/server/http/daemon-runtime.ts | 4 +- ...mon-session-idle-expiry-scheduling.test.ts | 16 +++ .../http/daemon-session-idle-expiry.test.ts | 10 ++ .../server/http/daemon-session-idle-expiry.ts | 11 +- .../http/daemon-shutdown-claims.test.ts | 12 +- .../src/server/http/daemon-shutdown-claims.ts | 11 +- .../src/server/request-execution-scope.ts | 4 + packages/daemon/src/server/request-router.ts | 1 + .../__tests__/lease-lifecycle.test.ts | 2 + ...n-replay-maestro-remote-evalscript.test.ts | 3 +- ...ion-replay-repair-record-exclusion.test.ts | 8 +- ...-repair-transaction-close-ordering.test.ts | 10 +- ...on-replay-repair-transaction-force.test.ts | 6 +- .../session-replay-repair-transaction.test.ts | 6 +- .../session-test-fail-fast.test.ts | 3 +- .../replay-suite/session-test-runner.test.ts | 15 +- .../session-test-suite-infrastructure.test.ts | 4 +- .../replay-suite/session-test-suite.test.ts | 23 ++- .../__tests__/request-admission.test.ts | 20 ++- .../__tests__/request-handler-catalog.test.ts | 3 +- ...quest-handler-chain-provider-scope.test.ts | 3 + .../handlers/__tests__/install-source.test.ts | 9 ++ .../session-appstate-device-runtime.test.ts | 4 + .../__tests__/session-appstate-facts.test.ts | 7 +- .../__tests__/session-appstate-input.test.ts | 12 +- .../__tests__/session-appstate.test.ts | 14 +- ...ession-boot-shutdown-device-claims.test.ts | 1 + .../__tests__/session-boot-warmup.test.ts | 3 +- .../handlers/__tests__/session-boot.test.ts | 18 ++- .../__tests__/session-command-replay.test.ts | 9 +- .../__tests__/session-device-claims.test.ts | 13 ++ .../session-doctor-app-runtime.test.ts | 3 + .../session-install-source-ref-frame.test.ts | 2 + .../__tests__/session-push-ref-frame.test.ts | 3 +- .../handlers/__tests__/session-push.test.ts | 12 +- .../__tests__/session-reinstall.test.ts | 13 +- .../__tests__/session-relaunch-guards.test.ts | 6 +- .../__tests__/session-replay-command.test.ts | 4 +- .../__tests__/session-runtime-command.test.ts | 11 +- .../__tests__/session-shutdown.test.ts | 7 +- .../__tests__/session-trigger.test.ts | 2 + .../__tests__/session-uninstall.test.ts | 8 +- .../handlers/__tests__/session.test.ts | 3 +- .../handlers/__tests__/trace-runtime.test.ts | 47 +++++- .../src/sessions/handlers/record-trace.ts | 4 +- .../handlers/session-app-deployment-route.ts | 9 +- .../handlers/session-app-deployment.ts | 18 +-- .../handlers/session-app-source-deployment.ts | 2 + .../session-appstate-device-runtime.ts | 14 +- .../src/sessions/handlers/session-boot.ts | 3 +- .../handlers/session-command-input.ts | 2 + .../handlers/session-doctor-warmup.test.ts | 3 + .../src/sessions/handlers/session-doctor.ts | 16 ++- .../handlers/session-progress.test.ts | 3 +- .../handlers/session-replay-command.ts | 5 + .../daemon/src/sessions/handlers/session.ts | 8 ++ .../src/sessions/handlers/trace-runtime.ts | 21 ++- .../daemon/src/sessions/lease-lifecycle.ts | 16 ++- .../daemon/src/sessions/request-admission.ts | 13 +- .../request-handler-chain-routing.test.ts | 8 +- .../src/sessions/request-handler-chain.ts | 5 + .../__tests__/application.test.ts | 5 +- ...on-capabilities-install-projection.test.ts | 3 +- .../__tests__/session-capabilities.test.ts | 10 ++ ...session-close-cwd-scoped-resources.test.ts | 7 +- .../session-close-ended-lease.test.ts | 7 +- .../session-close-error-precedence.test.ts | 7 +- .../session-close-lifecycle-runtime.test.ts | 7 +- .../session-close-resource-cleanup.test.ts | 11 +- .../session-close-save-script.test.ts | 9 +- .../session-close-shutdown-platform.test.ts | 12 +- ...session-close-without-session-mode.test.ts | 3 +- .../session-devices-batch-runtime.test.ts | 8 +- .../session-inventory-appleos.test.ts | 2 + .../session-inventory-apps-runtime.test.ts | 5 + .../session-inventory-harmonyos.test.ts | 2 + .../session-inventory-scoped-paths.test.ts | 5 +- .../session-open-execution-runtime.test.ts | 18 ++- .../__tests__/session-open-existing.test.ts | 8 +- .../__tests__/session-open-runtime.test.ts | 13 +- .../session-teardown-resources.test.ts | 4 +- .../session-lifecycle/internal/inventory.ts | 53 ++++--- .../session-close-lifecycle-teardown.test.ts | 7 +- .../internal/session-close.ts | 7 +- .../session-open-execution-relaunch.test.ts | 7 +- .../internal/session-open-execution.ts | 22 +-- .../internal/session-open.ts | 5 + .../__tests__/application.test.ts | 3 + .../internal/__tests__/session-logs.test.ts | 2 + .../session-network-runtime-parity.test.ts | 4 + .../__tests__/session-network.test.ts | 3 + .../__tests__/session-observability.test.ts | 5 + .../__tests__/session-perf-runtime.test.ts | 10 ++ .../internal/session-observability.ts | 2 + .../internal/session-perf-runtime.ts | 17 +-- 101 files changed, 835 insertions(+), 174 deletions(-) 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/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/resources/__tests__/macos-app-lease.test.ts b/packages/daemon/src/resources/__tests__/macos-app-lease.test.ts index 24ab78ef96..6fe57b8724 100644 --- a/packages/daemon/src/resources/__tests__/macos-app-lease.test.ts +++ b/packages/daemon/src/resources/__tests__/macos-app-lease.test.ts @@ -68,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( @@ -105,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', ); @@ -113,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', ); @@ -120,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', ); } @@ -134,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', ); @@ -143,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', ); } @@ -152,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', ); @@ -161,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', ); @@ -172,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', ); @@ -184,6 +231,7 @@ test('close cannot name another app', () => { lease, request('close', ['com.apple.finder']), leasedSession, + process.env, ), 'app', ); @@ -192,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', ); }); @@ -209,6 +267,7 @@ test('screenshots stay on the app window', () => { lease, request('screenshot', [], { screenshotFullscreen: true }), leasedSession, + process.env, ), 'capture', ); @@ -221,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', ); } @@ -239,6 +298,7 @@ test('a batch is refused whole when any step is not allowed', () => { ], }), leasedSession, + process.env, ), 'app', ); @@ -250,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', ); @@ -265,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( @@ -281,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', ); @@ -288,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', ); } @@ -310,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', ); @@ -318,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/macos-app-lease.ts b/packages/daemon/src/resources/macos-app-lease.ts index 77bbfe73f5..eed27fb66b 100644 --- a/packages/daemon/src/resources/macos-app-lease.ts +++ b/packages/daemon/src/resources/macos-app-lease.ts @@ -1,10 +1,11 @@ +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'; 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, commandHostEnvironment } from '@agent-device/host-kit/command'; +import { runCmd } from '@agent-device/host-kit/command'; import { isProcessAlive } from '@agent-device/host-kit/process'; import { isMacOs } from '@agent-device/kernel/device'; import { AppError, type DaemonError } from '@agent-device/kernel/errors'; @@ -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((name) => commandHostEnvironment().env[name]) === '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-lease-session-end.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope-lease-session-end.test.ts index 71cc6fd2cf..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 @@ -71,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.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope.test.ts index 5955331c06..77fc1ec9e3 100644 --- a/packages/daemon/src/server/__tests__/request-execution-scope.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-scope.test.ts @@ -828,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, diff --git a/packages/daemon/src/server/http/daemon-runtime.ts b/packages/daemon/src/server/http/daemon-runtime.ts index c4fccc1082..300599f256 100644 --- a/packages/daemon/src/server/http/daemon-runtime.ts +++ b/packages/daemon/src/server/http/daemon-runtime.ts @@ -528,7 +528,7 @@ async function startDaemonRuntimeOnHost( }); }; - const shutdownClaimLedger = createDaemonShutdownClaimLedger(); + const shutdownClaimLedger = createDaemonShutdownClaimLedger(env); const teardownDaemonSession = async (ref: SessionRef): Promise => { const session = sessionStore.resolveCurrent(ref) ?? ref.session; @@ -596,6 +596,7 @@ async function startDaemonRuntimeOnHost( }; const leaseSessionEnd = createLeaseSessionEnd({ + env, sessionStore, executionLocks: getLeaseRegistryExecutionLocks(leaseRegistry), teardownSession: settleIdleExpiredSession, @@ -608,6 +609,7 @@ async function startDaemonRuntimeOnHost( }; const sessionIdleExpiry = createSessionIdleExpiry({ + env, sessionStore, idleExpiryMs: resolveSessionIdleExpiryMs(env), executionLocks: getLeaseRegistryExecutionLocks(leaseRegistry), 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 bbd16ee762..1863fa2f8a 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 @@ -46,6 +46,7 @@ test('a session still inside its window survives a sweep that actually ran', asy const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -73,6 +74,7 @@ test('the policy being off arms nothing at all, even for a long-dead session', a let settleCalls = 0; const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: 0, executionLocks: new Map(), @@ -102,6 +104,7 @@ test('a window longer than a timer can express does not sweep a session that is let sweeps = 0; const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: LONG_WINDOW_MS, executionLocks: new Map(), @@ -133,6 +136,7 @@ test('a window longer than a timer can express still expires once the window ela session.lastActivityAtMs = NOW; const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: LONG_WINDOW_MS, executionLocks: new Map(), @@ -168,6 +172,7 @@ test('an expiry waits for the session lock a command of its own holds', async () const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, @@ -209,6 +214,7 @@ test('an expiry waits for the DEVICE lock a command on another session holds', a const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, @@ -251,6 +257,7 @@ test('a command that re-stamped activity while the expiry queued cancels it', as let sweepsCompleted = 0; const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, @@ -299,6 +306,7 @@ test('a session that moved to another device is fenced by that device, not the s const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, @@ -357,6 +365,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: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -379,6 +388,7 @@ test('a sweep runs inside the composition diagnostics scope it is given', async const scopes: string[] = []; const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -413,6 +423,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: process.env, sessionStore, idleExpiryMs: 40, executionLocks: new Map(), @@ -474,6 +485,7 @@ test('a daemon beginning to leave does not start settling the next session', asy }); const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -514,6 +526,7 @@ test('an over-budget expiry keeps the locks the release itself needs', async () }); const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, @@ -564,6 +577,7 @@ test('an expiry that lands after its budget still reports the session it release const expired: string[] = []; const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -603,6 +617,7 @@ test('a stuck expiry does not leave the timer firing with no delay', async () => let clock = NOW; const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: 20, executionLocks: new Map(), @@ -663,6 +678,7 @@ test('a settle that outlives its window is retried a full window after it ends, let settledAt = 0; const controller = createSessionIdleExpiry({ + env: process.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 cf56496234..28ff76dc55 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 @@ -49,6 +49,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: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -84,6 +85,7 @@ test('a settle that cannot confirm the claim gone keeps the session record stand try { const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -123,6 +125,7 @@ test('a claim that yields no attributable owner keeps the session record standin const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -154,6 +157,7 @@ test('a settle that fails keeps the session and its claim, and retries on the ne const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -194,6 +198,7 @@ test('a session shutdown finalized mid-settle is not also reported as expired', const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -222,6 +227,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: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -264,6 +270,7 @@ test('a settled session of another kind is never touched by the sweep', async () const settledNames: string[] = []; const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -300,6 +307,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: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -361,6 +369,7 @@ test('a committed expiry finalizes the repair transaction it ends', async () => const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ + env: process.env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -395,6 +404,7 @@ test('an idle settle cannot finalize or retire a replacement at its scoped addre }); let entered = false; const controller = createSessionIdleExpiry({ + env: process.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 75c3bc6e87..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,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { clearDeviceClaim, @@ -76,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>; @@ -216,6 +217,7 @@ export function createSessionIdleExpiry(params: { } type IdleExpirySweepParams = { + env: HostEnvironment['env']; sessionStore: SessionStore; idleExpiryMs: number; executionLocks: Map>; @@ -361,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, @@ -453,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; @@ -464,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', @@ -544,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, commandHostEnvironment().env); + 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 34ddd5c7a1..59e7fb02a4 100644 --- a/packages/daemon/src/server/http/daemon-shutdown-claims.test.ts +++ b/packages/daemon/src/server/http/daemon-shutdown-claims.test.ts @@ -52,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); @@ -68,7 +68,7 @@ test('a claim cleared after clean teardown is reported released', async () => { 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); @@ -98,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); @@ -121,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); @@ -135,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. @@ -156,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 d8bebc1b77..ca12d58f67 100644 --- a/packages/daemon/src/server/http/daemon-shutdown-claims.ts +++ b/packages/daemon/src/server/http/daemon-shutdown-claims.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +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 { @@ -59,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: [], @@ -72,10 +74,7 @@ export function createDaemonShutdownClaimLedger(): DaemonShutdownClaimLedger { releaseClaim: async (session) => { if (!session.deviceClaim) return; try { - outcomes.set( - session.name, - await clearDeviceClaim(session.deviceClaim, commandHostEnvironment().env), - ); + 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 49775cc01a..4fdb372c95 100644 --- a/packages/daemon/src/server/request-execution-scope.ts +++ b/packages/daemon/src/server/request-execution-scope.ts @@ -291,6 +291,7 @@ export async function createRequestExecutionScope(params: { throwIfRequestCanceled(scopedReq.meta?.requestId); try { await cleanupExpiredLeasedSession({ + env: params.host.env, sessionName, sessionStore, leaseRegistry, @@ -304,6 +305,7 @@ export async function createRequestExecutionScope(params: { }), }); scopedReq = admitRequestLeaseForLockedScope({ + env: params.host.env, req: scopedReq, sessionName, sessionStore, @@ -660,6 +662,7 @@ export type LeaseSessionEnd = Readonly<{ }>; export function createLeaseSessionEnd(params: { + env: HostEnvironment['env']; sessionStore: SessionStore; executionLocks: Map>; teardownSession: SessionTeardown; @@ -685,6 +688,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 b7a2f6878b..2abff59011 100644 --- a/packages/daemon/src/server/request-router.ts +++ b/packages/daemon/src/server/request-router.ts @@ -349,6 +349,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, diff --git a/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts b/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts index 050a0bf0ee..7e2a974c1b 100644 --- a/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts +++ b/packages/daemon/src/sessions/__tests__/lease-lifecycle.test.ts @@ -49,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, @@ -85,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 80a78db0c3..ecc647ec8b 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, bindTestCommandHost } 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(), @@ -242,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', @@ -321,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 d6baee1e0e..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,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +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'; /** @@ -110,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', @@ -151,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', @@ -193,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', @@ -221,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', @@ -292,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, @@ -304,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', @@ -336,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', @@ -358,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 04f26d6ffe..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,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +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'; /** @@ -120,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', @@ -373,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, @@ -413,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', @@ -457,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 72df3fe4dd..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,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +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'; /** @@ -189,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', @@ -254,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', @@ -435,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, @@ -469,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 936cc2e815..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,7 +13,11 @@ import { import type { DaemonRequest } from '../../../core/daemon-request.ts'; import { handleSessionCommands } from '../../../__tests__/support/handlers/session-command-harness.ts'; -import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; bindTestCommandHost(); @@ -70,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: [] }, @@ -107,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: { @@ -179,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: { @@ -219,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: { @@ -260,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: { @@ -315,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: { @@ -384,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: { @@ -417,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: { @@ -448,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 3e5b074199..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,7 +38,11 @@ import { makeAndroidSession, makeMacOsSession, } from '../../../__tests__/support/session-factories.ts'; -import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { storeSessionForTest } from '../../../__tests__/support/store-factory.ts'; bindTestCommandHost(); @@ -67,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: { @@ -116,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: { @@ -164,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: { @@ -200,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: { @@ -237,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: { @@ -346,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: { @@ -399,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: { @@ -444,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: { @@ -498,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: { @@ -558,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: { @@ -632,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: { @@ -699,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: { @@ -776,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: { @@ -826,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: { @@ -854,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: { @@ -885,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: { @@ -921,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 f58e1804a3..b6521aaf0e 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'; @@ -515,6 +515,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/handlers/__tests__/install-source.test.ts b/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts index 186e6f6547..a90af5c847 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts @@ -29,6 +29,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { mkdtempForTestSync, unavailableApplicationLifecycleOperationFacts, + createTestHostEnvironment, } from '@agent-device/test-kit'; import type { BindDeviceRuntime, @@ -91,6 +92,7 @@ test('install_source materializes and deploys through one admitted runtime bindi const runtime = createSourceRuntime(session.device, materialize, deploy); const response = await handleInstallFromSourceDeploymentCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'url', url: 'https://example.com/app.zip', headers: {} }), sessionName: session.name, @@ -137,6 +139,7 @@ test('install_source rejects unavailable facts before binding or materializing', }); const response = await handleInstallFromSourceDeploymentCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'url', url: 'https://example.com/app.zip' }), sessionName: session.name, @@ -175,6 +178,7 @@ test('install_source fails closed when a provider owner does not expose readines }); const response = await handleInstallFromSourceDeploymentCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'url', url: 'https://example.com/app.zip' }), sessionName: session.name, @@ -232,6 +236,7 @@ test('install_source cleans materialized paths when deployment fails after admis ); const response = await handleInstallFromSourceDeploymentCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'url', url: 'https://example.com/app.zip' }), sessionName: session.name, @@ -264,6 +269,7 @@ test('install_source preserves the Android identity failure when its runtime can ); const response = await handleInstallFromSourceDeploymentCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'url', url: 'https://example.com/app.zip' }), sessionName: session.name, @@ -300,6 +306,7 @@ test('install_source returns the typed iOS artifact identity supplied by its run ); const response = await handleInstallFromSourceDeploymentCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'path', path: '/tmp/AgentDeviceTester.app' }), sessionName: session.name, @@ -347,6 +354,7 @@ test('install_source accepts the public leaf selector of an Apple session it is ); const response = await handleInstallFromSourceDeploymentCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'path', path: '/tmp/App.app' }, { platform: 'ios' }), sessionName: session.name, @@ -376,6 +384,7 @@ test('install_source still refuses a leaf selector that names a different platfo ); const response = await handleInstallFromSourceDeploymentCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'path', path: '/tmp/App.app' }, { platform: 'android' }), sessionName: session.name, 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 7d196a96fa..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 @@ -7,6 +7,7 @@ import { withTestDeviceInventory } from '@agent-device/device-selection/inventor import { unavailableDeploymentSnapshotAndShutdownOperationFacts, bindTestCommandHost, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; import { @@ -45,6 +46,7 @@ test('appstate rejects web before Android app-state backend dispatch', async () }, async () => await handleAppStateCommand({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -168,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', @@ -266,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 e0d336d3d8..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 @@ -93,6 +93,7 @@ async function runStateCommand( }); try { const handlerParams = { + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req, sessionName: 'default', 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 babc00f6cc..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,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; import { makeSessionStore, @@ -24,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 5c6196f979..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,7 +21,11 @@ import { mockEnsureReadyRuntime, mockInspectDeviceRuntimeFacts, } from '../../../__tests__/support/handlers/session-command-harness.ts'; -import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; bindTestCommandHost(); @@ -29,6 +33,7 @@ bindTestCommandHost(); test('boot requires session or explicit selector', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -73,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: { @@ -119,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: { @@ -154,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: { @@ -191,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: { @@ -237,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: { @@ -275,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: { @@ -314,6 +325,7 @@ test('boot forwards Android serial admission policy to readiness', async () => { }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -350,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: { @@ -387,6 +400,7 @@ test('boot --headless rejects non-Android selectors', async () => { booted: false, }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -422,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: { @@ -459,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 e768b38030..665562489a 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 @@ -9,6 +9,7 @@ import { mkdtempForTestSync, pinOwnProcessStartTime, bindTestCommandHost, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; @@ -149,6 +150,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: { @@ -178,6 +180,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: { @@ -210,6 +213,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: { @@ -238,6 +242,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: { @@ -265,6 +270,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: { @@ -286,6 +292,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: { @@ -319,6 +326,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: { @@ -351,6 +359,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: { @@ -390,6 +399,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: { @@ -447,6 +457,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', @@ -493,6 +504,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', @@ -568,6 +580,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: { 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 aa09bafcb8..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,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +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'; @@ -94,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 fd0980b53f..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, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { handleSessionCommands, mockBindDeviceRuntime, @@ -32,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: { @@ -71,6 +76,7 @@ test('push validates payload before runtime facts admission', async () => { await expect( handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -113,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: { @@ -180,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: { @@ -220,6 +228,7 @@ test('push stops at unavailable facts without binding', async () => { }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -276,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 c8532986f2..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, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { trackUploadedArtifact } from '../../../resources/artifact-tracking.ts'; import type { BindDeviceRuntime, @@ -53,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: { @@ -89,6 +94,7 @@ test('reinstall validates required args before runtime admission', async () => { }), ); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -127,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: { @@ -192,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: { @@ -238,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: { @@ -274,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: { @@ -325,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 3ff53fba7a..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,7 +1,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; -import { mkdtempForTestSync, bindTestCommandHost } 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'; @@ -42,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, @@ -51,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, @@ -65,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, @@ -98,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, @@ -107,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, @@ -118,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/record-trace.ts b/packages/daemon/src/sessions/handlers/record-trace.ts index c0d90c1348..12994fde36 100644 --- a/packages/daemon/src/sessions/handlers/record-trace.ts +++ b/packages/daemon/src/sessions/handlers/record-trace.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 type { DaemonRequest } from '../../core/daemon-request.ts'; @@ -12,6 +13,7 @@ import type { PlatformRequestScope } from '@agent-device/contracts/platform-runt import { handleTraceCommand } from './trace-runtime.ts'; export async function handleRecordTraceCommands(params: { + host: HostEnvironment; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -43,7 +45,7 @@ export async function handleRecordTraceCommands(params: { } if (command === 'trace') { - return handleTraceCommand({ req, sessionName, sessionStore }); + return handleTraceCommand({ host: params.host, req, sessionName, sessionStore }); } return null; diff --git a/packages/daemon/src/sessions/handlers/session-app-deployment-route.ts b/packages/daemon/src/sessions/handlers/session-app-deployment-route.ts index 2e3dcf7cff..4e70cf1289 100644 --- a/packages/daemon/src/sessions/handlers/session-app-deployment-route.ts +++ b/packages/daemon/src/sessions/handlers/session-app-deployment-route.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 { BindDeviceRuntime, @@ -15,6 +16,7 @@ import { handleInstallFromSourceDeploymentCommand } from './session-app-source-d /** The canonical install-family descriptors share one daemon route boundary. */ export async function handleSessionAppDeploymentCommand(params: { + host: HostEnvironment; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -22,9 +24,11 @@ export async function handleSessionAppDeploymentCommand(params: { platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): 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, @@ -36,6 +40,7 @@ export async function handleSessionAppDeploymentCommand(params: { } if (req.command === 'install_source') { return await handleInstallFromSourceDeploymentCommand({ + host, req, sessionName, sessionStore, @@ -46,6 +51,7 @@ export async function handleSessionAppDeploymentCommand(params: { } if (req.command === 'uninstall') { return await handleUninstallCommand({ + host, req, sessionName, sessionStore, @@ -56,6 +62,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 fe9bc81eaa..65ca2d0671 100644 --- a/packages/daemon/src/sessions/handlers/session-app-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-deployment.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +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'; @@ -54,6 +54,7 @@ type DeployResult = Readonly<{ }>; export async function handleAppDeploymentCommand(params: { + host: HostEnvironment; req: DaemonRequest; command: DeployCommand; sessionName: string; @@ -62,7 +63,7 @@ export async function handleAppDeploymentCommand(params: { platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { - const { req, command, sessionName, sessionStore } = params; + const { host, req, command, sessionName, sessionStore } = params; const ref = sessionStore.lookup(sessionName); const session = ref?.session; const flags = req.flags ?? {}; @@ -75,7 +76,7 @@ export async function handleAppDeploymentCommand(params: { try { const appPath = uploadedArtifactId ? prepareUploadedArtifact(uploadedArtifactId, req.meta?.tenantId) - : expandSessionPath(target.appPathInput, commandHostEnvironment().cwd); + : expandSessionPath(target.appPathInput, host.cwd); if (!fs.existsSync(appPath)) { return errorResponse('INVALID_ARGS', `App binary not found: ${appPath}`); } @@ -114,7 +115,7 @@ export async function handleAppDeploymentCommand(params: { export async function handlePushNotificationCommand( params: RuntimeCommandHandlerParams, ): Promise { - const { req, sessionName, sessionStore } = params; + const { host, req, sessionName, sessionStore } = params; const ref = sessionStore.lookup(sessionName); const session = ref?.session; const flags = req.flags ?? {}; @@ -129,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 ?? host.cwd), + ); const device = await resolveCommandDevice({ session, flags, @@ -297,12 +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 ?? commandHostEnvironment().cwd), + expandPath: (value, currentCwd) => expandSessionPath(value, currentCwd ?? cwd), }); return resolved.kind === 'file' ? resolved.path : resolved.text; } diff --git a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts index a7e5f6717f..c5e6a3a851 100644 --- a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-source-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 type { @@ -49,6 +50,7 @@ type InstallFromSourceResult = Readonly<{ }>; export async function handleInstallFromSourceDeploymentCommand(params: { + host: HostEnvironment; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; 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 07d108808a..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,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { appStateUse } from '@agent-device/contracts/platform-runtime-operations'; import { isIosFamily, @@ -15,8 +15,11 @@ import { appleSessionRequiredRefusal, } from './session-appstate-apple-session.ts'; -function sortedAndroidSerialAllowlist(value: string | undefined): string[] | undefined { - const allowlist = resolveAndroidSerialAllowlist(value, commandHostEnvironment().env); +function sortedAndroidSerialAllowlist( + value: string | undefined, + env: HostEnvironment['env'], +): string[] | undefined { + const allowlist = resolveAndroidSerialAllowlist(value, env); return allowlist ? [...allowlist].sort() : undefined; } @@ -53,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 cadeeded1b..6a94f549f3 100644 --- a/packages/daemon/src/sessions/handlers/session-boot.ts +++ b/packages/daemon/src/sessions/handlers/session-boot.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { resolveDeviceReadinessRuntimePlan } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -99,7 +98,7 @@ export async function handleBootCommand( const resolvedAndroidSerialAllowlist = resolveAndroidSerialAllowlist( flags.androidDeviceAllowlist, - commandHostEnvironment().env, + 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-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 dda31d35d8..90813af791 100644 --- a/packages/daemon/src/sessions/handlers/session-doctor.ts +++ b/packages/daemon/src/sessions/handlers/session-doctor.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +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'; @@ -50,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; @@ -100,6 +101,7 @@ export async function handleDoctorCommand(params: { inspectFacts, bindDevice, req, + env: params.host.env, }); const warmupDevice = session?.device ?? resolveHostWarmupDevice(inventory, appCheckDevice); if (warmupDevice) { @@ -172,6 +174,7 @@ async function appendLocalDoctorChecks(params: { inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; req: DaemonRequest; + env: HostEnvironment['env']; }): Promise { const { checks, @@ -183,6 +186,7 @@ async function appendLocalDoctorChecks(params: { inspectFacts, bindDevice, req, + env, } = params; const appCheckDevice = session?.device ?? resolveDoctorDeviceForAppCheck(checks, inventory, options.targetApp); @@ -196,6 +200,7 @@ async function appendLocalDoctorChecks(params: { inspectFacts, bindDevice, req, + env, }); } if (options.shouldProbeMetro) { @@ -216,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 { @@ -228,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 @@ -260,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; } @@ -277,7 +285,7 @@ async function resolveDoctorAppInventory(params: { ); const androidSerialAllowlist = resolveAndroidSerialAllowlist( req.flags?.androidDeviceAllowlist, - commandHostEnvironment().env, + env, ); const readyDevice = await ensureAppsRuntimeReady(runtime, { serial: req.flags?.serial, 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 12833f230a..c5d0ca3188 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 94a8461f2d..4ccd26b142 100644 --- a/packages/daemon/src/sessions/handlers/session.ts +++ b/packages/daemon/src/sessions/handlers/session.ts @@ -41,6 +41,7 @@ const handleSessionInventoryCommandGroup: SessionCommandHandler = ( const handleSessionOpenCommandGroup: SessionCommandHandler = (params) => handleSessionOpenCommands({ + host: params.host, req: params.req, createCommandSurface: params.createCommandSurface, sessionName: params.sessionName, @@ -54,6 +55,7 @@ const handleSessionOpenCommandGroup: SessionCommandHandler = (params) => const handleSessionCloseCommandGroup: SessionCommandHandler = (params) => handleSessionCloseCommands({ + host: params.host, req: params.req, sessionName: params.sessionName, logPath: params.logPath, @@ -67,6 +69,7 @@ const handleSessionCloseCommandGroup: SessionCommandHandler = (params) => } satisfies SessionCloseCommandInput); const handleSessionObservabilityCommandGroup: SessionCommandHandler = async ({ + host, req, sessionName, sessionStore, @@ -79,6 +82,7 @@ const handleSessionObservabilityCommandGroup: SessionCommandHandler = async ({ throwIfCanceled, }) => await handleSessionObservabilityCommands({ + host, req, sessionName, sessionStore, @@ -107,6 +111,7 @@ const SESSION_COMMAND_HANDLER_IMPLS = { capabilities: handleSessionInventoryCommandGroup, apps: handleSessionInventoryCommandGroup, doctor: async ({ + host, req, sessionName, sessionStore, @@ -116,6 +121,7 @@ const SESSION_COMMAND_HANDLER_IMPLS = { bindDevice, }) => await handleDoctorCommand({ + host, req, sessionName, sessionStore, @@ -208,6 +214,7 @@ export async function handleSessionCommands( params: SessionCommandInput, ): Promise { const { + host, req, sessionName, logPath, @@ -239,6 +246,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 519143f490..54a6ddaf94 100644 --- a/packages/daemon/src/sessions/handlers/trace-runtime.ts +++ b/packages/daemon/src/sessions/handlers/trace-runtime.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +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'; @@ -11,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; @@ -22,11 +23,12 @@ 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, @@ -35,7 +37,7 @@ function startTrace( if (session.trace) return errorResponse('INVALID_ARGS', 'trace already in progress'); const outPath = expandSessionPath( req.positionals?.[1] ?? sessionStore.defaultTracePath(session), - commandHostEnvironment().cwd, + host.cwd, ); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.appendFileSync(outPath, ''); @@ -48,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; @@ -76,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, commandHostEnvironment().cwd); + 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 e21a3c1827..2f44339e88 100644 --- a/packages/daemon/src/sessions/lease-lifecycle.ts +++ b/packages/daemon/src/sessions/lease-lifecycle.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +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'; @@ -55,6 +55,7 @@ export async function releaseExpiredProviderLease( } export async function cleanupExpiredLeasedSession(params: { + env: HostEnvironment['env']; sessionName: string; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; @@ -64,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; } @@ -77,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; @@ -92,7 +100,7 @@ export async function endLeasedSession( data: { reason, leaseId, session: session.name, error: errorMessage(error) }, }); }); - await clearDeviceClaim(session.deviceClaim, commandHostEnvironment().env).catch((error) => { + await clearDeviceClaim(session.deviceClaim, env).catch((error) => { emitDiagnostic({ level: 'warn', phase: 'leased_session_end_device_claim_clear_failed', @@ -108,6 +116,7 @@ export async function endLeasedSession( } export function admitRequestLeaseForLockedScope(params: { + env: HostEnvironment['env']; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -119,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 54b038e54b..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 { bindTestCommandHost, 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'; @@ -50,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 8239803da6..a4d641ebd5 100644 --- a/packages/daemon/src/sessions/request-handler-chain.ts +++ b/packages/daemon/src/sessions/request-handler-chain.ts @@ -25,11 +25,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 environment and working directory this request's handlers read configuration from. */ + host: HostEnvironment; req: DaemonRequest; sessionName: string; logPath: string; @@ -174,6 +177,7 @@ async function runSessionHandler( params.req.command, 'session', await handleSessionCommands({ + host: params.host, req: params.req, sessionName: params.sessionName, logPath: params.logPath, @@ -256,6 +260,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 60d58b049f..317c3c1cea 100644 --- a/packages/daemon/src/sessions/session-lifecycle/__tests__/application.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/__tests__/application.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +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'; @@ -56,6 +56,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), @@ -115,6 +116,7 @@ test('open routes only its lifecycle input through the public facade', async () expect(Object.keys(forwarded ?? {}).sort()).toEqual( [ 'bindDevice', + 'host', 'createCommandSurface', 'inspectFacts', 'logPath', @@ -142,6 +144,7 @@ test('close routes only its lifecycle input through the public facade', 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 aa95f1a45e..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,7 +14,11 @@ import { sessionCloseShutdownFixture, type SessionState, } from './session-close-shutdown.fixtures.ts'; -import { mkdtempForTestSync, bindTestCommandHost } 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'; @@ -55,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 9c0ff878be..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, bindTestCommandHost } 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'; @@ -73,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 b374d11328..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,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +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 { @@ -81,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: { @@ -197,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: { @@ -285,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: { @@ -333,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, @@ -387,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 2e1a7ba54f..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,11 @@ import { clearRuntimeHintsFixture, dispatchApplicationLifecycleEffect, } from '../../../../__tests__/support/application-lifecycle-runtime-fixture.ts'; -import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; bindTestCommandHost(); @@ -63,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 eed1cc61f6..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,7 +7,11 @@ import { sessionCloseShutdownFixture, type SessionState, } from './session-close-shutdown.fixtures.ts'; -import { mkdtempForTestSync, bindTestCommandHost } 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'; @@ -47,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, @@ -145,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: { @@ -171,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: { @@ -224,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', @@ -304,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 1087cb93e6..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,7 +1,11 @@ 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, bindTestCommandHost } 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'; @@ -42,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: { @@ -79,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, @@ -109,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 c6de84dcdd..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,7 +5,11 @@ import { type DeviceBinding, type PlatformRuntimeOperations, } from './session-close-shutdown.fixtures.ts'; -import { mkdtempForTestSync, bindTestCommandHost } 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'; @@ -76,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: { @@ -143,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: { @@ -187,6 +193,7 @@ test('close --shutdown calls shutdownAndroidEmulator for Android emulator and in }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -240,6 +247,7 @@ test('close --shutdown returns success and failure payload when shutdownAndroidE }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -282,6 +290,7 @@ test('close --shutdown returns success and failure payload when shutdownSimulato }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -331,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 6a1fba0c4e..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,11 @@ 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, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; bindTestCommandHost(); @@ -44,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', @@ -395,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 11d27a38bb..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 @@ -27,6 +27,7 @@ import { TVOS_SIMULATOR, VISIONOS_SIMULATOR, bindTestCommandHost, + createTestHostEnvironment, } from '@agent-device/test-kit'; bindTestCommandHost(); @@ -48,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 dc4b487c79..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 @@ -16,6 +16,7 @@ import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform import { unavailableDeploymentSnapshotAndShutdownOperationFacts, bindTestCommandHost, + createTestHostEnvironment, } from '@agent-device/test-kit'; import type { BindDeviceRuntime, @@ -123,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, @@ -153,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, @@ -201,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, @@ -229,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 5de4b3dd89..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 @@ -17,6 +17,7 @@ import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform import { unavailableDeploymentSnapshotAndShutdownOperationFacts, bindTestCommandHost, + createTestHostEnvironment, } from '@agent-device/test-kit'; import type { BindDeviceRuntime, @@ -117,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 8d77c71078..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,11 @@ import { makeSessionStore, noopInvoke, } from './session-open-runtime.fixtures.ts'; -import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; bindTestCommandHost(); @@ -134,6 +138,7 @@ test('open runtime payload replaces stored session runtime atomically', async () }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -215,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: { @@ -268,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: { @@ -310,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: { @@ -355,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: { @@ -398,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: { @@ -444,6 +454,7 @@ test('open reports the launch confirmation its platform answered', async () => { }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -497,6 +508,7 @@ function invokeHeldOpen( req?: Partial, ) { return handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), req: { @@ -681,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, @@ -709,6 +722,7 @@ async function openAndroidApp(params: { ); const { loader } = lifecycleOperationsSpy({ openApplication }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), lifecycleOperationsLoader: loader, @@ -836,6 +850,7 @@ test.each([ const { loader } = lifecycleOperationsSpy({ prepareApplicationOpen }); const response = await handleSessionCommands({ + host: createTestHostEnvironment(), createCommandSurface: fakeCreateCommandSurface, platformServices: daemonPlatformServicesFixture(), lifecycleOperationsLoader: loader, @@ -881,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 8352b56567..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,11 @@ import { makeSessionStore, noopInvoke, } from './session-open-runtime.fixtures.ts'; -import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; bindTestCommandHost(); @@ -117,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: { @@ -182,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: { @@ -235,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: { @@ -279,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: { @@ -312,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: { @@ -345,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: { @@ -379,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 5bc7399349..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,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { beforeEach, expect, test, vi } from 'vitest'; import { sessionCloseShutdownFixture, @@ -199,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, @@ -235,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 95d104d3b7..ad01c3bd5b 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +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'; @@ -61,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; @@ -78,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, @@ -89,6 +90,7 @@ export async function handleSessionInventoryCommands( }); case 'apps': return await handleAppsInventory({ + env: params.host.env, req, sessionName, sessionStore, @@ -143,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), })), @@ -168,9 +173,11 @@ async function devicesInventoryResponse(req: DaemonRequest): Promise { +function blockingClaimOwnersByDevice( + env: HostEnvironment['env'], +): Map { const owners = new Map(); - for (const entry of inspectDeviceClaims({}, commandHostEnvironment().env)) { + for (const entry of inspectDeviceClaims({}, env)) { const claim = entry.claim; if (!claim || deviceClaimOwnerCannotRelease(entry.classification)) continue; owners.set(claim.deviceKey, { @@ -189,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, @@ -207,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), }; } @@ -216,20 +231,21 @@ function inventoryIosSimulatorSetPath( configuredPath: string | undefined, platform: PlatformSelector | undefined, target: DeviceInfo['target'], + env: HostEnvironment['env'], ) { return resolveRequestAppleSimulatorSetPath({ requestedSetPath: configuredPath, platform, target, - env: commandHostEnvironment().env, + env, }); } -function inventoryAndroidSerialAllowlist(configuredAllowlist: string | undefined) { - const allowlist = resolveAndroidSerialAllowlist( - configuredAllowlist, - commandHostEnvironment().env, - ); +function inventoryAndroidSerialAllowlist( + configuredAllowlist: string | undefined, + env: HostEnvironment['env'], +) { + const allowlist = resolveAndroidSerialAllowlist(configuredAllowlist, env); return allowlist ? Array.from(allowlist).sort() : undefined; } @@ -340,6 +356,7 @@ function hasAvailableFact( } async function handleAppsInventory(params: { + env: HostEnvironment['env']; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -367,7 +384,7 @@ async function handleAppsInventory(params: { const resolvedAndroidSerialAllowlist = resolveAndroidSerialAllowlist( req.flags?.androidDeviceAllowlist, - commandHostEnvironment().env, + 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 f2e6c03184..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, bindTestCommandHost } 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'; @@ -90,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 a58b95c23d..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,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +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'; @@ -44,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; @@ -260,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, @@ -294,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; @@ -353,7 +356,7 @@ async function runCloseTeardownAndRelease(params: { }); } } else { - await clearDeviceClaim(session.deviceClaim, commandHostEnvironment().env); + 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 515e7497f3..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, bindTestCommandHost } 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'; @@ -117,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 1ee2f45494..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,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { resolveSessionRequestLogPath, resolveSessionRunnerLogPath, @@ -147,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, commandHostEnvironment().env); + const renewal = await renewDeviceClaim(ownership, env); if (renewal.status === 'renewed') return undefined; if (renewal.conflict) return buildDeviceClaimConflictError(device, renewal.conflict); return errorResponse( @@ -405,6 +406,7 @@ function findNewSessionDeviceConflict(params: { } async function acquireDeviceClaimForOwner(params: { + host: HostEnvironment; req: DaemonRequest; device: DeviceInfo; owner: OpenApplicationRuntime['owner']; @@ -429,9 +431,9 @@ async function acquireDeviceClaimForOwner(params: { return await acquireDeviceClaim({ device, session: sessionName, - workspace: req.meta?.cwd ?? commandHostEnvironment().cwd, + workspace: req.meta?.cwd ?? params.host.cwd, stateDir: sessionStore.resolveDaemonStateDir(), - env: commandHostEnvironment().env, + env: params.host.env, reconcileOrphanedDeviceClaim, observeDeviceBoot: platformServices.deviceBootObservation, }); @@ -439,6 +441,7 @@ async function acquireDeviceClaimForOwner(params: { } export async function openNewSessionWithDeviceClaim(params: { + host: HostEnvironment; req: DaemonRequest; sessionName: string; logPath: string; @@ -475,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, @@ -497,6 +501,7 @@ export async function openNewSessionWithDeviceClaim(params: { effects, sessionName, sessionStore, + env: params.host.env, }); try { requireOpenSessionAdmission(sessionStore, sessionName, undefined); @@ -518,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 ?? []; @@ -562,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, commandHostEnvironment().env); + await clearDeviceClaim(ownership, env); return; } if (sessionStore.get(sessionName)?.deviceClaim?.ownerToken === ownership.ownerToken) return; - const outcome = await abandonDeviceClaim(ownership, commandHostEnvironment().env); + 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 acbd73af4d..76b1b74e1a 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 02b6af5e6c..a8c6574507 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 8bc7f84be6..e625aa9234 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 @@ -22,6 +22,7 @@ import { makeSessionStore } from '../../../../__tests__/support/store-factory.ts import { unavailableDeploymentSnapshotAndShutdownOperationFacts, bindTestCommandHost, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { createPerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; import type { @@ -41,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', @@ -69,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', @@ -99,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', @@ -130,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', @@ -197,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: { @@ -215,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: { @@ -236,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: { @@ -269,6 +277,7 @@ test.each(['shutdown', 'retire'] as const)( return bound; }; const response = await handleSessionObservabilityCommands({ + host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: { token: 't', @@ -375,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 1aed92804b..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,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +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'; @@ -47,6 +47,7 @@ import { import { errorResponse } from '@agent-device/kernel/contracts'; export type PerfRuntimeHandlerParams = Readonly<{ + host: HostEnvironment; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; @@ -144,10 +145,7 @@ async function executeAdmittedPerfPlan( appId: session.appBundleId, kind: plan.request.kind, outPath: plan.request.outPath - ? expandSessionPath( - plan.request.outPath, - params.req.meta?.cwd ?? commandHostEnvironment().cwd, - ) + ? expandSessionPath(plan.request.outPath, params.req.meta?.cwd ?? params.host.cwd) : undefined, artifactsDir: path.join( params.sessionStore.ensureSessionDir(params.sessionName), @@ -180,10 +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 ?? commandHostEnvironment().cwd, - ), + tracePath: expandSessionPath(tracePath, params.req.meta?.cwd ?? params.host.cwd), outPath, template: plan.request.template ?? (last?.kind === 'xctrace' ? last.template : undefined), profile: last, @@ -270,7 +265,7 @@ async function stopPerfCapture( if (mismatchMessage) return errorResponse('INVALID_ARGS', mismatchMessage); if (request.outPath) { capture.handle.setOutputPath( - expandSessionPath(request.outPath, params.req.meta?.cwd ?? commandHostEnvironment().cwd), + expandSessionPath(request.outPath, params.req.meta?.cwd ?? params.host.cwd), ); } const completion = await finishLivePerfCapture({ @@ -403,7 +398,7 @@ function resolveNativeOutPath( fallbackFileName: string, ): string { if (requestedPath) - return expandSessionPath(requestedPath, params.req.meta?.cwd ?? commandHostEnvironment().cwd); + return expandSessionPath(requestedPath, params.req.meta?.cwd ?? params.host.cwd); return path.join( params.sessionStore.ensureSessionDir(params.sessionName), `${timestampToken()}-${fallbackFileName}`, From 225e0bdc40899a589c8649a635be932359fd2786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 11:32:30 +0200 Subject: [PATCH 17/33] fix(daemon): drop the unused host from install-from-source and tighten test envs Co-Authored-By: Claude Sonnet 5.5 --- ...mon-session-idle-expiry-scheduling.test.ts | 38 ++++++++++--------- .../http/daemon-session-idle-expiry.test.ts | 26 +++++++------ .../handlers/__tests__/install-source.test.ts | 9 ----- .../handlers/session-app-deployment-route.ts | 1 - .../handlers/session-app-source-deployment.ts | 2 - .../src/sessions/request-handler-chain.ts | 2 +- 6 files changed, 37 insertions(+), 41 deletions(-) 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 1863fa2f8a..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,11 @@ import { WINDOW_MS, } from '../__tests__/session-idle-expiry-harness.ts'; import { makeIosSession } from '../../__tests__/support/session-factories.ts'; -import { IOS_SIMULATOR, bindTestCommandHost } from '@agent-device/test-kit'; +import { + IOS_SIMULATOR, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; bindTestCommandHost(); @@ -46,7 +50,7 @@ test('a session still inside its window survives a sweep that actually ran', asy const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -74,7 +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: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: 0, executionLocks: new Map(), @@ -104,7 +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: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: LONG_WINDOW_MS, executionLocks: new Map(), @@ -136,7 +140,7 @@ test('a window longer than a timer can express still expires once the window ela session.lastActivityAtMs = NOW; const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: LONG_WINDOW_MS, executionLocks: new Map(), @@ -172,7 +176,7 @@ test('an expiry waits for the session lock a command of its own holds', async () const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, @@ -214,7 +218,7 @@ test('an expiry waits for the DEVICE lock a command on another session holds', a const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, @@ -257,7 +261,7 @@ test('a command that re-stamped activity while the expiry queued cancels it', as let sweepsCompleted = 0; const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, @@ -306,7 +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: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, @@ -365,7 +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: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -388,7 +392,7 @@ test('a sweep runs inside the composition diagnostics scope it is given', async const scopes: string[] = []; const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -423,7 +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: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: 40, executionLocks: new Map(), @@ -485,7 +489,7 @@ test('a daemon beginning to leave does not start settling the next session', asy }); const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -526,7 +530,7 @@ test('an over-budget expiry keeps the locks the release itself needs', async () }); const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, @@ -577,7 +581,7 @@ test('an expiry that lands after its budget still reports the session it release const expired: string[] = []; const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -617,7 +621,7 @@ test('a stuck expiry does not leave the timer firing with no delay', async () => let clock = NOW; const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: 20, executionLocks: new Map(), @@ -678,7 +682,7 @@ test('a settle that outlives its window is retried a full window after it ends, let settledAt = 0; const controller = createSessionIdleExpiry({ - env: process.env, + 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 28ff76dc55..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, bindTestCommandHost } 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'; @@ -49,7 +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: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -85,7 +89,7 @@ test('a settle that cannot confirm the claim gone keeps the session record stand try { const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -125,7 +129,7 @@ test('a claim that yields no attributable owner keeps the session record standin const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -157,7 +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: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -198,7 +202,7 @@ test('a session shutdown finalized mid-settle is not also reported as expired', const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -227,7 +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: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -270,7 +274,7 @@ test('a settled session of another kind is never touched by the sweep', async () const settledNames: string[] = []; const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -307,7 +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: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -369,7 +373,7 @@ test('a committed expiry finalizes the repair transaction it ends', async () => const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), @@ -404,7 +408,7 @@ test('an idle settle cannot finalize or retire a replacement at its scoped addre }); let entered = false; const controller = createSessionIdleExpiry({ - env: process.env, + env: createTestHostEnvironment().env, sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), diff --git a/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts b/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts index a90af5c847..186e6f6547 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts @@ -29,7 +29,6 @@ import { AppError } from '@agent-device/kernel/errors'; import { mkdtempForTestSync, unavailableApplicationLifecycleOperationFacts, - createTestHostEnvironment, } from '@agent-device/test-kit'; import type { BindDeviceRuntime, @@ -92,7 +91,6 @@ test('install_source materializes and deploys through one admitted runtime bindi const runtime = createSourceRuntime(session.device, materialize, deploy); const response = await handleInstallFromSourceDeploymentCommand({ - host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'url', url: 'https://example.com/app.zip', headers: {} }), sessionName: session.name, @@ -139,7 +137,6 @@ test('install_source rejects unavailable facts before binding or materializing', }); const response = await handleInstallFromSourceDeploymentCommand({ - host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'url', url: 'https://example.com/app.zip' }), sessionName: session.name, @@ -178,7 +175,6 @@ test('install_source fails closed when a provider owner does not expose readines }); const response = await handleInstallFromSourceDeploymentCommand({ - host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'url', url: 'https://example.com/app.zip' }), sessionName: session.name, @@ -236,7 +232,6 @@ test('install_source cleans materialized paths when deployment fails after admis ); const response = await handleInstallFromSourceDeploymentCommand({ - host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'url', url: 'https://example.com/app.zip' }), sessionName: session.name, @@ -269,7 +264,6 @@ test('install_source preserves the Android identity failure when its runtime can ); const response = await handleInstallFromSourceDeploymentCommand({ - host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'url', url: 'https://example.com/app.zip' }), sessionName: session.name, @@ -306,7 +300,6 @@ test('install_source returns the typed iOS artifact identity supplied by its run ); const response = await handleInstallFromSourceDeploymentCommand({ - host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'path', path: '/tmp/AgentDeviceTester.app' }), sessionName: session.name, @@ -354,7 +347,6 @@ test('install_source accepts the public leaf selector of an Apple session it is ); const response = await handleInstallFromSourceDeploymentCommand({ - host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'path', path: '/tmp/App.app' }, { platform: 'ios' }), sessionName: session.name, @@ -384,7 +376,6 @@ test('install_source still refuses a leaf selector that names a different platfo ); const response = await handleInstallFromSourceDeploymentCommand({ - host: createTestHostEnvironment(), platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'path', path: '/tmp/App.app' }, { platform: 'android' }), sessionName: session.name, diff --git a/packages/daemon/src/sessions/handlers/session-app-deployment-route.ts b/packages/daemon/src/sessions/handlers/session-app-deployment-route.ts index 4e70cf1289..c5b98b37c7 100644 --- a/packages/daemon/src/sessions/handlers/session-app-deployment-route.ts +++ b/packages/daemon/src/sessions/handlers/session-app-deployment-route.ts @@ -40,7 +40,6 @@ export async function handleSessionAppDeploymentCommand(params: { } if (req.command === 'install_source') { return await handleInstallFromSourceDeploymentCommand({ - host, req, sessionName, sessionStore, diff --git a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts index c5e6a3a851..a7e5f6717f 100644 --- a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts @@ -1,4 +1,3 @@ -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 type { @@ -50,7 +49,6 @@ type InstallFromSourceResult = Readonly<{ }>; export async function handleInstallFromSourceDeploymentCommand(params: { - host: HostEnvironment; req: DaemonRequest; sessionName: string; sessionStore: SessionStore; diff --git a/packages/daemon/src/sessions/request-handler-chain.ts b/packages/daemon/src/sessions/request-handler-chain.ts index a4d641ebd5..39f1ef0fa9 100644 --- a/packages/daemon/src/sessions/request-handler-chain.ts +++ b/packages/daemon/src/sessions/request-handler-chain.ts @@ -31,7 +31,7 @@ import type { PlatformResourceCleanup } from '../core/platform-resource-cleanup. import type { DaemonProviderCredentials } from '@agent-device/daemon-contracts/provider-credentials'; type RequestHandlerChainParams = { - /** The environment and working directory this request's handlers read configuration from. */ + /** The environment and working directory the session and record-trace routes read configuration from. */ host: HostEnvironment; req: DaemonRequest; sessionName: string; From 5816abec18f3afff7f3cfb9dcd4af509db88c066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 12:42:43 +0200 Subject: [PATCH 18/33] refactor(cli): resolve one transport per run and one host per MCP tool call; default the lease provider cwd to the bound host Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/request-handler-catalog.test.ts | 10 +++++ .../__tests__/lease-artifacts.test.ts | 5 +++ .../sessions/handlers/__tests__/lease.test.ts | 43 ++++++++++++++++++- .../lease-provider-credentials.test.ts | 2 + .../daemon/src/sessions/handlers/lease.ts | 12 +++--- .../src/sessions/request-handler-chain.ts | 1 + src/cli.ts | 14 ++++-- src/mcp/command-tools.ts | 6 ++- 8 files changed, 82 insertions(+), 11 deletions(-) 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 b6521aaf0e..bf98dcf5b5 100644 --- a/packages/daemon/src/sessions/__tests__/request-handler-catalog.test.ts +++ b/packages/daemon/src/sessions/__tests__/request-handler-catalog.test.ts @@ -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', @@ -166,6 +168,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', @@ -200,6 +203,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, @@ -218,6 +222,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', @@ -239,6 +244,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', @@ -297,6 +303,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', @@ -358,6 +365,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, @@ -406,6 +414,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, @@ -452,6 +461,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, 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 e84b45ed54..65e89f9db3 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', @@ -244,6 +246,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, @@ -262,6 +265,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, @@ -287,6 +291,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 57efb854a6..cb12031f76 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/lease.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/lease.test.ts @@ -7,7 +7,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, @@ -29,6 +29,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-'), @@ -61,6 +62,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', @@ -95,6 +97,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-'), @@ -121,6 +124,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', @@ -163,6 +167,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-'), @@ -196,6 +201,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-'), @@ -216,6 +222,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-'), @@ -276,6 +283,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-'), @@ -318,6 +326,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-'), @@ -340,6 +349,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, @@ -386,6 +396,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, @@ -442,6 +453,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-'), @@ -495,6 +507,7 @@ async function allocateAgainstInventory( }, async () => await handleLeaseCommands({ + host: createTestHostEnvironment(), req: request, sessionName: request.session, sessionStore: makeSessionStore('agent-device-proxy-allocate-by-spec-'), @@ -591,3 +604,31 @@ test("a proxy allocation naming another owner's lease id still picks and is refu error.code === 'INVALID_ARGS' && error.details?.reason === 'LEASE_ID_IN_USE', ); }); + +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']); +}); 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 14f129aa42..c7fea1e6c3 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 b15e7112e9..86a7cec2ff 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 { DaemonResponse } from '@agent-device/kernel/contracts'; import type { DeviceLease, @@ -40,6 +41,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; @@ -102,7 +104,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): 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); @@ -197,7 +200,7 @@ async function runCliOnHost(argv: string[], deps: CliDeps, host: HostEnvironment stateDir: ctx.daemonPaths.baseDir, flags: ctx.effectiveFlags, }); - await resolveRemoteContext(ctx, deps); + await resolveRemoteContext(ctx, transport); registerDaemonAuthDiagnosticValue(ctx.effectiveFlags); if (command === 'cdp') { await exitAfterFlush( @@ -220,7 +223,7 @@ async function runCliOnHost(argv: string[], deps: CliDeps, host: HostEnvironment flags: ctx.effectiveFlags, replayTestReporterRuntime, commandProgress, - transport: resolveRunTransport(deps, host), + transport, }), }); await dispatchCliCommand(ctx, client, replayTestReporterRuntime, commandProgress); @@ -414,7 +417,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.'); @@ -435,7 +441,7 @@ async function resolveRemoteContext(ctx: CliRunContext, deps: CliDeps): Promise< if (ctx.effectiveFlags.remoteConfig && shouldMaterializeRemoteConnection(ctx.command)) { const materializationClient = createAgentDeviceClient(buildClientConfig(ctx), { host: ctx.host, - transport: createClientDaemonTransport(resolveRunTransport(deps, ctx.host)), + transport: createClientDaemonTransport(transport), }); const { materializeRemoteConnectionForCommand } = await import('./cli/commands/connection-runtime.ts'); 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) => From 9a4e0812f421d9b798dd43729eba7bcc48fa1264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 13:53:40 +0200 Subject: [PATCH 19/33] test: pin the one-transport CLI run and one-host MCP call; document the bound host Co-Authored-By: Claude Sonnet 5.5 --- .../src/sessions/request-handler-chain.ts | 2 +- src/__tests__/cli-run-transport.test.ts | 75 +++++++++++++++++++ .../command-tools-daemon-lifecycle.test.ts | 6 ++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/cli-run-transport.test.ts diff --git a/packages/daemon/src/sessions/request-handler-chain.ts b/packages/daemon/src/sessions/request-handler-chain.ts index 5421fa010a..db2de9e703 100644 --- a/packages/daemon/src/sessions/request-handler-chain.ts +++ b/packages/daemon/src/sessions/request-handler-chain.ts @@ -31,7 +31,7 @@ import type { PlatformResourceCleanup } from '../core/platform-resource-cleanup. import type { DaemonProviderCredentials } from '@agent-device/daemon-contracts/provider-credentials'; type RequestHandlerChainParams = { - /** The environment and working directory the session and record-trace routes read configuration from. */ + /** The request's bound host: the environment and working directory its routes read configuration from. */ host: HostEnvironment; req: DaemonRequest; sessionName: string; diff --git a/src/__tests__/cli-run-transport.test.ts b/src/__tests__/cli-run-transport.test.ts new file mode 100644 index 0000000000..31cc0f1e0e --- /dev/null +++ b/src/__tests__/cli-run-transport.test.ts @@ -0,0 +1,75 @@ +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(); +}); + +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 }; + vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('exit'); + }) as never); + vi.spyOn(process.stdout, 'write').mockReturnValue(true); + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + await runCli( + ['open', '--state-dir', stateDir, '--json'], + { sendToDaemon: vi.fn(), transportForHost }, + host, + ).catch(() => undefined); + + 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); + fs.rmSync(root, { recursive: true, force: true }); +}); 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 { From f7445541efc1a77077a4a1734f64cd6fd871b648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 14:10:07 +0200 Subject: [PATCH 20/33] test: keep the failure that explains a missed materialization in the transport test Co-Authored-By: Claude Sonnet 5.5 --- src/__tests__/cli-run-transport.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/__tests__/cli-run-transport.test.ts b/src/__tests__/cli-run-transport.test.ts index 31cc0f1e0e..62aefe1d98 100644 --- a/src/__tests__/cli-run-transport.test.ts +++ b/src/__tests__/cli-run-transport.test.ts @@ -62,13 +62,21 @@ test('a CLI run builds one transport, shared by the remote-materialization clien vi.spyOn(process.stdout, 'write').mockReturnValue(true); vi.spyOn(process.stderr, 'write').mockReturnValue(true); + // The run ends through the stubbed process.exit; any other failure is kept to explain a miss below. + let failure: unknown; await runCli( ['open', '--state-dir', stateDir, '--json'], { sendToDaemon: vi.fn(), transportForHost }, host, - ).catch(() => undefined); + ).catch((error: unknown) => { + failure = error; + }); + const cause = failure instanceof Error && failure.message !== 'exit' ? failure.message : 'none'; - assert.ok(commands.includes('lease_allocate'), `materialization ran: ${commands.join(',')}`); + assert.ok( + commands.includes('lease_allocate'), + `materialization ran: ${commands.join(',')}; failure: ${cause}`, + ); assert.ok(commands.length > 1, 'the command client sent through the same transport'); assert.equal(transportForHost.mock.calls.length, 1); fs.rmSync(root, { recursive: true, force: true }); From acc42ba6d130dd9a63437fedbbf866e83757b6c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 15:33:24 +0200 Subject: [PATCH 21/33] refactor(daemon): read the host cwd where the deploy handlers use it, leaving their shared prologue untouched Co-Authored-By: Claude Sonnet 5.5 --- .../src/sessions/handlers/session-app-deployment.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/daemon/src/sessions/handlers/session-app-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-deployment.ts index 65ca2d0671..393e08f256 100644 --- a/packages/daemon/src/sessions/handlers/session-app-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-deployment.ts @@ -63,7 +63,7 @@ export async function handleAppDeploymentCommand(params: { platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { - const { host, req, command, sessionName, sessionStore } = params; + const { req, command, sessionName, sessionStore } = params; const ref = sessionStore.lookup(sessionName); const session = ref?.session; const flags = req.flags ?? {}; @@ -76,7 +76,7 @@ export async function handleAppDeploymentCommand(params: { try { const appPath = uploadedArtifactId ? prepareUploadedArtifact(uploadedArtifactId, req.meta?.tenantId) - : expandSessionPath(target.appPathInput, host.cwd); + : expandSessionPath(target.appPathInput, params.host.cwd); if (!fs.existsSync(appPath)) { return errorResponse('INVALID_ARGS', `App binary not found: ${appPath}`); } @@ -115,7 +115,7 @@ export async function handleAppDeploymentCommand(params: { export async function handlePushNotificationCommand( params: RuntimeCommandHandlerParams, ): Promise { - const { host, req, sessionName, sessionStore } = params; + const { req, sessionName, sessionStore } = params; const ref = sessionStore.lookup(sessionName); const session = ref?.session; const flags = req.flags ?? {}; @@ -131,7 +131,7 @@ export async function handlePushNotificationCommand( ); } const payload = await readNotificationPayload( - resolvePushPayload(payloadArg, req.meta?.cwd ?? host.cwd), + resolvePushPayload(payloadArg, req.meta?.cwd ?? params.host.cwd), ); const device = await resolveCommandDevice({ session, From db55a5dfbdff4dfbdd3ea55a839291b55f5b72f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 15:47:07 +0200 Subject: [PATCH 22/33] test: give the iOS recording scenario's daemon host the PATH and Swift cache its process already had Co-Authored-By: Claude Sonnet 5.5 --- test/integration/provider-scenarios/harness.ts | 4 ++++ test/integration/provider-scenarios/ios-record-trace.test.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index ad2f26a2a6..7d2505f5a3 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -97,6 +97,8 @@ type ProviderScenarioDependencies = Partial>; }; export async function createProviderScenarioHarness( @@ -114,6 +116,7 @@ export async function createProviderScenarioHarness( AGENT_DEVICE_CLAIMS_DIR: process.env.AGENT_DEVICE_CLAIMS_DIR ?? path.join(testRoot, 'claims'), AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '3600000', + ...deps.env, }; const { deviceInventoryProvider, @@ -122,6 +125,7 @@ export async function createProviderScenarioHarness( platformRuntime, providerRuntimes, leaseTtlMs, + env: _env, requestPlatformProviders: configuredRequestPlatformProviders, androidAdbProvider, appleRunnerProvider, 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, From ede74e126413eea4454ece67a949cc6fd9163b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 15:52:41 +0200 Subject: [PATCH 23/33] test: fail the transport test on an unexpected exit or throw and always remove its workspace Co-Authored-By: Claude Sonnet 5.5 --- src/__tests__/cli-run-transport.test.ts | 41 +++++++++++++------------ 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/__tests__/cli-run-transport.test.ts b/src/__tests__/cli-run-transport.test.ts index 62aefe1d98..cfbd1d17d2 100644 --- a/src/__tests__/cli-run-transport.test.ts +++ b/src/__tests__/cli-run-transport.test.ts @@ -14,6 +14,8 @@ 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'); @@ -56,28 +58,29 @@ test('a CLI run builds one transport, shared by the remote-materialization clien ) satisfies DaemonResponse; }); const host = { env: { HOME: home, PATH: process.env.PATH }, cwd: project }; - vi.spyOn(process, 'exit').mockImplementation((() => { - throw new Error('exit'); + 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); - // The run ends through the stubbed process.exit; any other failure is kept to explain a miss below. - let failure: unknown; - await runCli( - ['open', '--state-dir', stateDir, '--json'], - { sendToDaemon: vi.fn(), transportForHost }, - host, - ).catch((error: unknown) => { - failure = error; - }); - const cause = failure instanceof Error && failure.message !== 'exit' ? failure.message : 'none'; + 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.ok( - commands.includes('lease_allocate'), - `materialization ran: ${commands.join(',')}; failure: ${cause}`, - ); - assert.ok(commands.length > 1, 'the command client sent through the same transport'); - assert.equal(transportForHost.mock.calls.length, 1); - fs.rmSync(root, { recursive: true, force: true }); + 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 }); + } }); From e2e3dc7f1b18a03187b49322fb5e8079307b56c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 15:57:30 +0200 Subject: [PATCH 24/33] test: keep the scenario harness's state dir and transport out of reach of its env override Co-Authored-By: Claude Sonnet 5.5 --- test/integration/provider-scenarios/harness.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 7d2505f5a3..6072967982 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -111,12 +111,13 @@ export async function createProviderScenarioHarness( PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, - AGENT_DEVICE_STATE_DIR: stateDir, // 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_SERVER_MODE: 'http', 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_DAEMON_SERVER_MODE: 'http', }; const { deviceInventoryProvider, From d8a9bd40c03b691ba61ce06155cc400da56e8157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 16:11:38 +0200 Subject: [PATCH 25/33] fix(daemon): abandon a transient device claim under the same env that cleared it Co-Authored-By: Claude Sonnet 5.5 --- packages/daemon/src/resources/device/device-claim-admission.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/daemon/src/resources/device/device-claim-admission.ts b/packages/daemon/src/resources/device/device-claim-admission.ts index 496160d0cd..5e5c080284 100644 --- a/packages/daemon/src/resources/device/device-claim-admission.ts +++ b/packages/daemon/src/resources/device/device-claim-admission.ts @@ -128,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, process.env); + await abandonDeviceClaim(ownership, params.env); } catch (abandonError) { emitDiagnostic({ level: 'error', From 597f79326051cf06f3e9cc34f51e24e8d23b6933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 16:51:02 +0200 Subject: [PATCH 26/33] refactor(host): read host settings from HostEnvironment in root src and remaining packages (#3775) * refactor(host): thread the client host through remote artifact preparation and materialization Co-Authored-By: Claude Sonnet 5.5 * refactor(host): pass the composition's env to the platform runtime, diagnostics, toolchain and deployment hosts Co-Authored-By: Claude Sonnet 5.5 * refactor(host): refuse a relative provider app path that names no working directory Co-Authored-By: Claude Sonnet 5.5 * refactor(host): require the host's env and cwd where the CLI resolves options, binding and auth Co-Authored-By: Claude Sonnet 5.5 * test(host): keep the upload client suite under its size ratchet Co-Authored-By: Claude Sonnet 5.5 * refactor(host): pass the run's host to CLI command handlers Co-Authored-By: Claude Sonnet 5.5 * test(host): bind host-taking commands once in a shared helper Co-Authored-By: Claude Sonnet 5.5 * docs(changelog): resolveHubAppReference needs cwd for a relative app path Co-Authored-By: Claude Sonnet 5.5 * test(host): give the new handler suites a host Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + .../src/webdriver-utils.test.ts | 21 ++++++ .../provider-webdriver/src/webdriver-utils.ts | 13 +++- .../daemon-artifacts-save-script.test.ts | 24 +++++- .../daemon-artifacts-test-command.test.ts | 5 ++ .../upload-client-cancellation.test.ts | 3 + .../src/__tests__/upload-client.test.ts | 12 ++- .../src/__tests__/upload-progress.test.ts | 2 +- packages/remote/src/daemon-artifacts.ts | 73 +++++++++++++------ packages/remote/src/remote-config-core.ts | 24 +++--- packages/remote/src/upload-client-artifact.ts | 12 ++- packages/remote/src/upload-client.ts | 5 +- packages/remote/src/upload-progress.ts | 7 +- src/__tests__/cli-agent-cdp.test.ts | 2 + src/__tests__/cli-client-commands.test.ts | 2 +- src/__tests__/cli-install-from-source.test.ts | 2 + src/__tests__/cloud-connect-auth.test.ts | 8 +- src/__tests__/cloud-connect-profile.test.ts | 38 ++++++---- .../platform-services-fixture.ts | 4 +- .../remote-connection-harmonyos.test.ts | 7 +- .../remote-connection-macos-app.test.ts | 7 +- .../remote-connection-platform-axis.test.ts | 14 +++- ...-connection-proxy-allocate-by-spec.test.ts | 9 ++- .../remote-connection-proxy-lease-id.test.ts | 5 +- src/__tests__/remote-connection.test.ts | 4 +- src/__tests__/takeover-command.test.ts | 37 ++++++++-- src/__tests__/test-utils/connect-command.ts | 4 + .../test-utils/host-bound-commands.ts | 17 +++++ src/__tests__/test-utils/with-test-host.ts | 9 +++ src/cli.ts | 10 ++- src/cli/auth-session.ts | 15 ++-- src/cli/commands/__tests__/daemon.test.ts | 13 +++- src/cli/commands/__tests__/generic.test.ts | 5 +- src/cli/commands/__tests__/proxy.test.ts | 4 + src/cli/commands/__tests__/recording.test.ts | 2 + src/cli/commands/__tests__/screenshot.test.ts | 9 ++- src/cli/commands/agent-cdp.ts | 10 +-- src/cli/commands/auth.ts | 8 +- .../connection-runtime-metro-teardown.test.ts | 3 +- ...ection-runtime-plugin-capabilities.test.ts | 8 +- src/cli/commands/connection-runtime.ts | 13 ++-- src/cli/commands/connection.ts | 69 +++++++++++------- src/cli/commands/daemon.ts | 7 +- src/cli/commands/device.ts | 12 +-- src/cli/commands/proxy.ts | 14 ++-- src/cli/commands/recording.ts | 11 +-- src/cli/commands/replay.ts | 8 +- src/cli/commands/router-types.ts | 3 + src/cli/commands/router.ts | 1 + src/cli/commands/screenshot.ts | 13 ++-- src/cli/connection/cloud-profile.ts | 3 +- .../connection/connect-provider-adapters.ts | 10 +-- src/cli/connection/ended-lease.test.ts | 3 +- src/cli/connection/ended-lease.ts | 12 ++- src/cli/connection/generated-config.ts | 4 +- src/cli/connection/limrun-profile.ts | 5 +- src/cli/connection/proxy-profile.ts | 4 +- src/cli/resolve-cli-options.ts | 9 +-- src/cli/session-binding.ts | 6 +- .../boundary-fault-acceptance.test.ts | 7 +- .../boundary-fault-transport.test.ts | 7 ++ .../daemon-client-abort-timeout.test.ts | 7 +- .../daemon-client-timeout-route.test.ts | 15 +++- .../__tests__/daemon-client-transport.test.ts | 3 + src/daemon-client/daemon-client-lease-beat.ts | 2 +- src/daemon-client/daemon-client-quiesce.ts | 2 +- src/daemon-client/daemon-client-rpc.test.ts | 2 + src/daemon-client/daemon-client-rpc.ts | 11 ++- src/daemon-client/daemon-client-transport.ts | 8 +- src/daemon-client/daemon-client.ts | 3 + src/daemon-composition.ts | 8 +- ...atform-runtime-android-adb-binding.test.ts | 4 +- ...untime-android-deployment-executor.test.ts | 4 +- ...orm-runtime-android-deployment-executor.ts | 7 +- src/platform-runtime-claim-recovery.ts | 17 +++-- ...-runtime-gateway-gesture-admission.test.ts | 1 + ...platform-runtime-gateway-recording.test.ts | 1 + src/platform-runtime-host-diagnostics.test.ts | 6 +- src/platform-runtime-host-diagnostics.ts | 5 +- src/platform-runtime-host.test.ts | 2 +- src/platform-runtime-host.ts | 5 +- src/platform-runtime-operation-host.test.ts | 1 + src/platform-runtime-operation-host.ts | 6 +- src/platform-runtime-perf-host.test.ts | 1 + src/platform-runtime-toolchain-host.test.ts | 2 +- src/platform-runtime-toolchain-host.ts | 5 +- src/platform-runtime.test.ts | 11 ++- src/platform-runtime.ts | 47 ++++++------ src/sdk/remote-config.test.ts | 24 ++++++ src/sdk/remote-config.ts | 14 +++- .../integration/provider-scenarios/harness.ts | 2 + .../provider-scenarios/linux-world.ts | 2 +- .../remote-daemon-client.test.ts | 4 + .../remote-proxy-artifact-tenant.test.ts | 3 +- ...ote-test-artifacts-materialization.test.ts | 2 + .../smoke-tvos-platform-coverage.test.ts | 1 + test/wire-compat/closure-policy.ts | 2 + test/wire-compat/ledger.json | 24 +++--- 98 files changed, 664 insertions(+), 279 deletions(-) create mode 100644 src/__tests__/test-utils/host-bound-commands.ts create mode 100644 src/__tests__/test-utils/with-test-host.ts create mode 100644 src/sdk/remote-config.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a9c8b4468..1569ffaeee 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. - 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. - Provider plugins have a new contract. A package exports a factory at `./agent-device` that returns `{ apiVersion: 2, provider, credentials, connection?, setup(host) }`, typed by `AgentDevicePluginFactory` from `agent-device/plugins`. The store calls the factory with the package's `config.json` options, and a JavaScript host can call it directly. Version 1 plugins (`agentDevicePlugin.apiVersion: 1`) keep loading. 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/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/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-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__/cloud-connect-auth.test.ts b/src/__tests__/cloud-connect-auth.test.ts index 598e3fbf2b..c5472fd85e 100644 --- a/src/__tests__/cloud-connect-auth.test.ts +++ b/src/__tests__/cloud-connect-auth.test.ts @@ -7,7 +7,11 @@ 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, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; bindTestCommandHost(); @@ -54,7 +58,6 @@ test('cloud access with no-login reports auth requirement after reuse options ar }, env: {}, io: { - env: {}, fetch: vi.fn(), }, }), @@ -87,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 a10719697c..e10b3312f9 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -1,4 +1,3 @@ -import { withCommandHost } from '@agent-device/host-kit/command'; import { afterEach, beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -21,7 +20,11 @@ import { verifyAwsDeviceFarmConnection, verifyBrowserStackConnection, } from '@agent-device/provider-webdriver/connection-verification'; -import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { connectWithGeneratedProviderProfile } from './test-utils/connect-command.ts'; bindTestCommandHost(); @@ -164,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, @@ -217,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, @@ -237,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, @@ -295,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, @@ -554,20 +561,17 @@ test('connect browserstack persists a local app artifact as an absolute path', a try { // The host the command runs in decides what `./sample.apk` is relative to. - await withCommandHost( - { host: { env: process.env, cwd: fs.realpathSync(tempRoot) }, requestEnv: undefined }, - async () => - await connectWithGeneratedProviderProfile({ - stateDir, - positionals: ['browserstack'], - flags: { - platform: 'android', - device: 'Google Pixel 8', - providerOsVersion: '14.0', - providerApp: './sample.apk', - }, - }), - ); + 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)); @@ -795,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, @@ -903,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__/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__/remote-connection-harmonyos.test.ts b/src/__tests__/remote-connection-harmonyos.test.ts index bc301bb5b2..6e2f955a71 100644 --- a/src/__tests__/remote-connection-harmonyos.test.ts +++ b/src/__tests__/remote-connection-harmonyos.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { expect, test } from 'vitest'; import fs from 'node:fs'; import { @@ -45,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: { @@ -73,6 +74,7 @@ async function expectRejectedSelection( ): Promise { await expect( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', client: createTestClient(), flags: { @@ -176,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, @@ -189,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 9f0e5e5bb6..ecf9582a97 100644 --- a/src/__tests__/remote-connection-macos-app.test.ts +++ b/src/__tests__/remote-connection-macos-app.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +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'; @@ -68,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, @@ -110,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, @@ -126,6 +128,7 @@ async function connectHostedMacosApp( const stdoutCapture = beginStdoutCapture(); try { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { ...flagsFor(workspace.stateDir, workspace.remoteConfigPath), @@ -165,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' }, @@ -245,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 66b832e281..92793830ce 100644 --- a/src/__tests__/remote-connection-platform-axis.test.ts +++ b/src/__tests__/remote-connection-platform-axis.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +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 @@ -54,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, @@ -114,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, @@ -173,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, @@ -228,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, @@ -291,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, @@ -376,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, @@ -438,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, @@ -501,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, @@ -562,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, @@ -625,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, @@ -674,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, @@ -758,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 f18f74fa4b..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,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -99,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({ @@ -152,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. @@ -172,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' }]), @@ -213,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, @@ -236,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({ @@ -270,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({ @@ -302,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 4145680887..6cfff6fc47 100644 --- a/src/__tests__/remote-connection-proxy-lease-id.test.ts +++ b/src/__tests__/remote-connection-proxy-lease-id.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -82,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, @@ -124,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.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/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/cli.ts b/src/cli.ts index 586d4f7a97..aaecb8c0b5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -219,6 +219,7 @@ async function runCliOnHost(argv: string[], deps: CliDeps, host: HostEnvironment const client = createAgentDeviceClient(buildClientConfig(ctx), { host: ctx.host, transport: createCliDaemonTransport({ + host, command, flags: ctx.effectiveFlags, replayTestReporterRuntime, @@ -352,6 +353,7 @@ async function resolveRunContextOrExit( 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, @@ -359,6 +361,7 @@ 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, @@ -446,6 +449,7 @@ async function resolveRemoteContext( const { materializeRemoteConnectionForCommand } = await import('./cli/commands/connection-runtime.ts'); const materialized = await materializeRemoteConnectionForCommand({ + host: ctx.host, command: ctx.command, flags: ctx.effectiveFlags, client: materializationClient, @@ -562,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, @@ -570,6 +575,7 @@ async function dispatchCliCommand( })); if ( await tryRunClientBackedCommand({ + host: ctx.host, command, positionals, flags: { ...effectiveFlags, batchSteps }, @@ -588,6 +594,7 @@ async function dispatchCliCommand( ); } else if ( await tryRunClientBackedCommand({ + host: ctx.host, command, positionals, flags: effectiveFlags, @@ -849,6 +856,7 @@ function hasExplicitMetroRuntimeOverrides(explicitFlagKeys: Set): boole } type CliDaemonTransportSetup = { + host: HostEnvironment; command: string; flags: CliFlags; replayTestReporterRuntime?: ReplayTestReporterRuntime; @@ -866,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 d10e2f4197..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, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; const mocks = vi.hoisted(() => ({ cleanupDaemonOwner: vi.fn(async () => undefined), @@ -56,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, @@ -65,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, @@ -93,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, @@ -132,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, @@ -169,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, @@ -206,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, @@ -260,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 3ed9d958a7..1957a7164a 100644 --- a/src/cli/commands/__tests__/proxy.test.ts +++ b/src/cli/commands/__tests__/proxy.test.ts @@ -11,6 +11,7 @@ import { mkdtempForTestSync, skipWhenLoopbackUnavailable, bindTestCommandHost, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; @@ -70,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, @@ -93,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, @@ -110,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 327b75d525..797f127335 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -1,15 +1,14 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { AppError } from '@agent-device/kernel/errors'; import { loginWithDeviceAuth, removeCliSession, summarizeCliSession } from '../auth-session.ts'; 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, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).baseDir; if (subcommand === 'status') { const status = summarizeCliSession({ stateDir }); @@ -21,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 30a0ceb3d6..5c68b32452 100644 --- a/src/cli/commands/connection-runtime-metro-teardown.test.ts +++ b/src/cli/commands/connection-runtime-metro-teardown.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +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'; @@ -60,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 a99feeb2e6..28a8a36e32 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +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'; @@ -61,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; @@ -73,19 +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, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).baseDir; const remoteConfig = resolveRemoteConfigProfile({ configPath: flags.remoteConfig, - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().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 7a5083d445..0f50ded8e0 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { CloudArtifact, CloudProviderSessionResult, @@ -54,10 +54,15 @@ import { type RuntimePreparationNotice, } from './connection-presentation.ts'; -export const connectCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { +export const connectCommand: ClientCommandHandler = async ({ + positionals, + flags, + client, + host, +}) => { const stateDir = resolveDaemonPaths(flags.stateDir, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).baseDir; await primeProviderConnection(positionals[0]); const provider = readConnectProvider(positionals); @@ -66,11 +71,11 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, provider, flags, stateDir, - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + 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, @@ -86,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, @@ -100,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( @@ -280,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); @@ -287,24 +294,25 @@ async function cleanupForcedPreviousConnection( return await releasePreviousLease(client, previous, { nextDaemonBaseUrl: next.daemon?.baseUrl, ambientDaemonAuthToken: flags.daemonAuthToken, - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }); } function readRemoteConfigConnectionMetadata( remoteConfigPath: string, + host: HostEnvironment, ): RemoteConnectionRequestMetadata | undefined { const profile = resolveRemoteConfigProfile({ configPath: remoteConfigPath, - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().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; @@ -346,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}".`, @@ -424,15 +432,18 @@ 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, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).baseDir; return { session, @@ -518,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); @@ -528,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); } @@ -547,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: commandHostEnvironment().cwd, - env: commandHostEnvironment().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 6ec23edaa1..8d3499452f 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { readPluginInventory, type PluginInventoryEntry, @@ -22,14 +21,14 @@ 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, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }); if (subcommand === 'status') { const status = readDaemonStatus(paths); diff --git a/src/cli/commands/device.ts b/src/cli/commands/device.ts index 113f556398..2e2a40ea95 100644 --- a/src/cli/commands/device.ts +++ b/src/cli/commands/device.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { AppError } from '@agent-device/kernel/errors'; import { publicPlatformString } from '@agent-device/kernel/device'; import { @@ -14,11 +14,11 @@ 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); + if (positionals[0] === 'release') return await runDeviceRelease(flags, host); const inspectedClaims = inspectDeviceClaims( { platform: flags.platform, @@ -26,7 +26,7 @@ export const deviceCommand: ClientCommandHandler = async ({ positionals, flags } udid: flags.udid, serial: flags.serial, }, - commandHostEnvironment().env, + host.env, ); const staleClaims = inspectedClaims.filter((claim) => deviceClaimRequiresStaleInspection(claim.classification), @@ -59,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', @@ -77,7 +77,7 @@ async function runDeviceRelease(flags: CliFlags): Promise { udid: flags.udid, serial: flags.serial, }, - commandHostEnvironment().env, + host.env, ); const data = { released: outcomes.filter((outcome) => outcome.status === 'released').map(serializeOutcome), diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index 5358ec58d4..4cfb6fe3c8 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +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'; @@ -20,17 +20,21 @@ 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 { +async function startProxy(flags: CliFlags, clientHost: HostEnvironment): Promise { const settings = resolveClientSettings( { session: 'default', @@ -44,7 +48,7 @@ async function startProxy(flags: CliFlags): Promise { }, }, undefined, - commandHostEnvironment(), + clientHost, ); const daemon = await ensureManagedDaemon(settings); const upstreamBaseUrl = resolveLocalDaemonBaseUrl(daemon.info.httpPort); diff --git a/src/cli/commands/recording.ts b/src/cli/commands/recording.ts index 8be1aa533f..868ce38b24 100644 --- a/src/cli/commands/recording.ts +++ b/src/cli/commands/recording.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { AppError } from '@agent-device/kernel/errors'; import { localCommandPolicy } from '@agent-device/contracts/command-policy'; import type { RecordingContactSheetCommandResult } from '../../commands/recording/runtime/contact-sheet.ts'; @@ -9,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( @@ -19,12 +18,10 @@ export const recordingCommand: ClientCommandHandler = async ({ positionals, flag } const recordingPath = resolveUserPath(readRequiredPositional(positionals[1]), { - cwd: commandHostEnvironment().cwd, + cwd: host.cwd, }); const outputPath = - typeof flags.out === 'string' - ? resolveUserPath(flags.out, { cwd: commandHostEnvironment().cwd }) - : undefined; + 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([ @@ -33,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 c979b63587..96d8caf968 100644 --- a/src/cli/commands/replay.ts +++ b/src/cli/commands/replay.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; @@ -31,6 +30,7 @@ function handleReplayRunCommand({ positionals, flags }: ReplayCommandParams): fa async function handleReplayExportCommand({ positionals, flags, + host, }: ReplayCommandParams): Promise { validateReplayExportOptions(positionals, flags); const inputPath = positionals[1]; @@ -38,7 +38,7 @@ async function handleReplayExportCommand({ throw new AppError('INVALID_ARGS', 'replay export requires an input path.'); } - const sourcePath = resolveUserPath(inputPath, { cwd: commandHostEnvironment().cwd }); + 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 @@ -52,9 +52,7 @@ async function handleReplayExportCommand({ metadata: readReplayScriptMetadata(script), }); const outputPath = - typeof flags.out === 'string' - ? resolveUserPath(flags.out, { cwd: commandHostEnvironment().cwd }) - : undefined; + 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 f3ee104fb7..f12fe61f20 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { localCommandPolicy } from '@agent-device/contracts/command-policy'; import { formatScreenshotDiffText, formatSnapshotDiffText } from '../../commands/output/diff.ts'; import type { ScreenshotDiffResult } from '@agent-device/capture-kit/screenshot-diff'; @@ -38,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)); @@ -52,11 +51,9 @@ export const diffCommand: ClientCommandHandler = async ({ positionals, flags, cl throw new AppError('INVALID_ARGS', 'diff screenshot requires --baseline '); } - const baselinePath = resolveUserPath(baselineRaw, { cwd: commandHostEnvironment().cwd }); + const baselinePath = resolveUserPath(baselineRaw, { cwd: host.cwd }); const outputPath = - typeof flags.out === 'string' - ? resolveUserPath(flags.out, { cwd: commandHostEnvironment().cwd }) - : undefined; + typeof flags.out === 'string' ? resolveUserPath(flags.out, { cwd: host.cwd }) : undefined; const currentRaw = positionals[1]; if (positionals.length > 2) { throw new AppError( @@ -73,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: () => {}, @@ -85,7 +82,7 @@ export const diffCommand: ClientCommandHandler = async ({ positionals, flags, cl session: flags.session, baseline: { kind: 'path', path: baselinePath }, current: currentRaw - ? { kind: 'path', path: resolveUserPath(currentRaw, { cwd: commandHostEnvironment().cwd }) } + ? { kind: 'path', path: resolveUserPath(currentRaw, { cwd: host.cwd }) } : { kind: 'live' }, ...(outputPath ? { out: { kind: 'path', path: outputPath } } : {}), threshold: parseCliThreshold(flags.threshold), 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 453b7ee489..d73f4b4eeb 100644 --- a/src/cli/connection/ended-lease.test.ts +++ b/src/cli/connection/ended-lease.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { test } from 'vitest'; @@ -46,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 d1c84ae002..0415d5a12c 100644 --- a/src/cli/connection/ended-lease.ts +++ b/src/cli/connection/ended-lease.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +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'; @@ -14,13 +14,17 @@ 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, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).baseDir; const state = readRemoteConnectionState({ stateDir, session: flags.session ?? 'default' }); if (!state?.leaseId || !endedLeaseIds.includes(state.leaseId)) 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 552fba7b14..2586e6b383 100644 --- a/src/cli/connection/limrun-profile.ts +++ b/src/cli/connection/limrun-profile.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import type { RemoteConfigProfile } from '@agent-device/remote/remote-config-schema'; import { AppError } from '@agent-device/kernel/errors'; @@ -20,9 +19,9 @@ export function resolveLimrunConnectProfile(options: { flags: CliFlags; stateDir: string; cwd: string; - env?: EnvMap; + env: EnvMap; }): { flags: CliFlags; remoteConfigPath: string } { - const env = options.env ?? commandHostEnvironment().env; + const { env } = options; const profile = buildLimrunRemoteProfile({ flags: options.flags, cwd: options.cwd, env }); const credentials = readLimrunCredentials(env); const platform = limrunPlatformForLeaseBackend(profile.leaseBackend) ?? 'android'; 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/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/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-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-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-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 49257ab3b0..22d4a5b7a7 100644 --- a/src/daemon-client/daemon-client.ts +++ b/src/daemon-client/daemon-client.ts @@ -146,6 +146,7 @@ async function sendToDaemonOnHost( settings.paths, requestTimeoutMs, { + host, onProgress: options.onProgress, signal: options.signal, timeoutRecovery: @@ -373,6 +374,7 @@ async function protectArtifactUploadWithLeaseBeats( request, info, callerSignal ?? new AbortController().signal, + settings.host, ); } const { buildUploadLeaseHeartbeat, runProtectedLeaseWork } = @@ -386,6 +388,7 @@ async function protectArtifactUploadWithLeaseBeats( request, info, callerSignal ? AbortSignal.any([leaseSignal, callerSignal]) : leaseSignal, + settings.host, ), callerSignal, }); diff --git a/src/daemon-composition.ts b/src/daemon-composition.ts index 60d81b5594..13cc987ce0 100644 --- a/src/daemon-composition.ts +++ b/src/daemon-composition.ts @@ -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/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 82104cd75f..6be124a168 100644 --- a/src/platform-runtime-claim-recovery.ts +++ b/src/platform-runtime-claim-recovery.ts @@ -20,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 @@ -44,7 +47,7 @@ export async function runStaleDeviceClaimRelease( 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 d51ff59765..2ae0d5212c 100644 --- a/src/platform-runtime-gateway-gesture-admission.test.ts +++ b/src/platform-runtime-gateway-gesture-admission.test.ts @@ -6,6 +6,7 @@ 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 d82922b9b9..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, @@ -14,7 +15,7 @@ import { createHostToolchainPreparer } from './platform-runtime-toolchain-host.t 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) => @@ -35,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 b03fe1c7ce..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 { @@ -53,6 +54,7 @@ export async function loadMacOsSurfaceSnapshot( } export function createPlatformRuntimeHost(options: { + env: Readonly; sessionsDir: string; resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; shutdownLoaders: DeviceShutdownRuntimeLoaders; @@ -92,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), @@ -114,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-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 6072967982..cfbad57b7e 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -148,6 +148,7 @@ export async function createProviderScenarioHarness( platformRuntime?.providerModules ?? createProviderPlatformRuntimeRegistrations(configuredRuntimes); const defaultDeviceRuntimeGateway = createPlatformRuntimeGateway({ + env: process.env, assertShutdownAllowed: input.assertShutdownAllowed, providerRuntimes: configuredRuntimes, providerModules, @@ -241,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/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/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/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." } ] } From 9f3e58ffbbcb65faa4124ab735f5f429327bf425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 17:54:54 +0200 Subject: [PATCH 27/33] test: build the scenario gateway on the daemon host's env and pin the host claims directory on a failed release Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/device-claim-admission.test.ts | 44 +++++++++++++++++++ .../integration/provider-scenarios/harness.ts | 2 +- .../ios-record-trace.test.ts | 2 + 3 files changed, 47 insertions(+), 1 deletion(-) 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 f81b782943..7a6a3df3f0 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 @@ -2,6 +2,7 @@ import { ANDROID_EMULATOR, deviceClaimFilePath, createTestHostEnvironment, + mkdtempForTestSync, } from '@agent-device/test-kit'; import { expect, test, vi } from 'vitest'; import { @@ -467,6 +468,49 @@ test('a failed release abandons the claim so the next command is not locked out' expect(inspectDeviceClaims({}, process.env)).toEqual([]); }); +test("a failed release abandons the claim in the host's claims directory, not the process's", async () => { + const { stateDir } = setup(); + const hostClaimsDir = mkdtempForTestSync('agent-device-host-claims-'); + const hostEnv = { ...process.env, AGENT_DEVICE_CLAIMS_DIR: hostClaimsDir }; + const admission = createDeviceClaimAdmission({ + env: hostEnv, + policy: 'transient-exclusive', + command: 'install', + session: 'own-session', + workspace: '/worktrees/current', + callerCwd: '/worktrees/current', + resolveScopeRoot: resolveCallerScopeRoot, + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + await admission.admit(ANDROID_EMULATOR, localAndroid); + const claimFiles = fs + .readdirSync(hostClaimsDir, { recursive: true }) + .map((entry) => path.join(hostClaimsDir, String(entry))) + .filter((entry) => fs.statSync(entry).isFile() && entry.endsWith('.json')); + expect(claimFiles).toHaveLength(1); + + const originalUnlink = fs.unlinkSync; + const unlink = vi.spyOn(fs, 'unlinkSync').mockImplementation((target) => { + if (String(target) === claimFiles[0]) { + throw Object.assign(new Error('read-only file system'), { code: 'EROFS' }); + } + return originalUnlink(target as Parameters[0]); + }); + try { + await admission[Symbol.asyncDispose](); + } finally { + unlink.mockRestore(); + } + + // The abandon went to the host's directory, where the claim was written; the process's own + // claims directory never held it. + const survivor = inspectDeviceClaims({}, hostEnv); + expect(survivor.map((entry) => entry.claim?.session)).toEqual(['transient:install']); + expect(survivor[0]?.claim?.abandonedAtMs).toBeTypeOf('number'); + 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({ diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index cfbad57b7e..d30578a6dd 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -148,7 +148,7 @@ export async function createProviderScenarioHarness( platformRuntime?.providerModules ?? createProviderPlatformRuntimeRegistrations(configuredRuntimes); const defaultDeviceRuntimeGateway = createPlatformRuntimeGateway({ - env: process.env, + env: input.host.env, assertShutdownAllowed: input.assertShutdownAllowed, providerRuntimes: configuredRuntimes, providerModules, diff --git a/test/integration/provider-scenarios/ios-record-trace.test.ts b/test/integration/provider-scenarios/ios-record-trace.test.ts index ae1d06ebca..fe3aaf273c 100644 --- a/test/integration/provider-scenarios/ios-record-trace.test.ts +++ b/test/integration/provider-scenarios/ios-record-trace.test.ts @@ -112,6 +112,8 @@ test('iOS simulator recording reports host contention and recovers through the f appleSimulatorScreenRecordingTransport: () => recordingTransport, deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], }); + // The recording finalizer and Swift helper still read PATH and the cache from the process + // (capture-kit and the root helpers move to the host in #3777), so the scenario sets both. const previousPath = process.env.PATH; const previousSwiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; process.env.PATH = tmpDir; From 779dcab21d4c5eb4d75915e0bdb0f60d3890b523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 18:05:23 +0200 Subject: [PATCH 28/33] test: say which package still reads PATH from the process in the recording scenario Co-Authored-By: Claude Sonnet 5.5 --- test/integration/provider-scenarios/ios-record-trace.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/provider-scenarios/ios-record-trace.test.ts b/test/integration/provider-scenarios/ios-record-trace.test.ts index fe3aaf273c..9622d71ec5 100644 --- a/test/integration/provider-scenarios/ios-record-trace.test.ts +++ b/test/integration/provider-scenarios/ios-record-trace.test.ts @@ -112,8 +112,8 @@ test('iOS simulator recording reports host contention and recovers through the f appleSimulatorScreenRecordingTransport: () => recordingTransport, deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], }); - // The recording finalizer and Swift helper still read PATH and the cache from the process - // (capture-kit and the root helpers move to the host in #3777), so the scenario sets both. + // capture-kit's recording finalizer still reads PATH and the Swift cache from the process + // (#3777 moves capture-kit to the host), so the scenario sets both there as well. const previousPath = process.env.PATH; const previousSwiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; process.env.PATH = tmpDir; From 9c1e9ec7cd679c79b814c86f97606d97b9bb082b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 20:22:50 +0200 Subject: [PATCH 29/33] refactor(platforms): read host settings from HostEnvironment in platform and capture packages (#3777) * refactor(capture-kit): read swift cache, app-log and PNG worker settings from the command host The PNG worker starts with an empty environment and reports unavailability through diagnostics instead of a process warning. Co-Authored-By: Claude Sonnet 5.5 * refactor(provision-kit): read proxy settings from the command host Co-Authored-By: Claude Sonnet 5.5 * refactor(react-native): read dev-server settings from the command host The detached companion process starts with an explicit env and cwd, and its process entry captures its host from its own spawn environment. Co-Authored-By: Claude Sonnet 5.5 * fix(host): run the dev-server helpers inside the client's and the SDK's host The helpers moved from process reads to the command host, so the released agent-device/metro functions and the client bind one at their boundary. Co-Authored-By: Claude Sonnet 5.5 * refactor(platform-apple): give detached runner and bridge children an explicit env and cwd Co-Authored-By: Claude Sonnet 5.5 * refactor(platform-apple): read runner settings from the command host The runner reaches the host through its port. The container bundle ids were fixed at import time from the process environment; they are now resolved from the host a caller passes in. Co-Authored-By: Claude Sonnet 5.5 * fix(host): run the daemon clean script's runner lease cleanup inside its own host Co-Authored-By: Claude Sonnet 5.5 * refactor(host): hand leaf modules their environment instead of importing the command host The eager-closure budgets forbid the host-kit command import in the install source download, the app-log file policy and the Maestro HTTP helper, so their callers pass the environment they already hold. Client tests that change PATH give the client a host instead. Co-Authored-By: Claude Sonnet 5.5 * test(host): bind the command host in the install-artifact suites Co-Authored-By: Claude Sonnet 5.5 * fix(host): answer review of the platform host environment change Redaction patterns come from the command host, the maestro child reads its own environment without a working-directory lookup, local ~ install sources expand against the host environment, and the runner launch resolves its host inside the try that closes its log descriptor. Co-Authored-By: Claude Sonnet 5.5 * refactor(react-native): take the host explicitly instead of reading the command host Co-Authored-By: Claude Sonnet 5.5 * refactor(platforms): pass the host explicitly where a platform package reads configuration Android install artifacts read the environment from the Android adb host port, Apple install artifacts take it as an option from the root deployment executor, and the Apple runner names its host port member hostEnvironment. Co-Authored-By: Claude Sonnet 5.5 * refactor(daemon): hand the app-log marker the request host's environment Co-Authored-By: Claude Sonnet 5.5 * fix(capture-kit): report an unavailable PNG worker in the first diagnostics scope that sees it A startup pre-warm runs outside any scope, where the event is dropped; it no longer latches the one-time report. Co-Authored-By: Claude Sonnet 5.5 * test(host): share the test command host binding through test-kit and test the runner's bound host behaviorally test-kit no longer depends on capture-kit: its app-log live-handle alias is gone (callers use the capture-kit constructor) and its keyboard fixture builds its own snapshot state. capture-kit's tests take the helper from test-kit. Co-Authored-By: Claude Sonnet 5.5 * refactor(capture-kit): expose the app-log live handle adapter through its own entry Co-Authored-By: Claude Sonnet 5.5 * refactor(test-kit): build the test app-log handle without importing capture-kit Replaces the capture-kit subpath entry, which grew the declared export surface. Co-Authored-By: Claude Sonnet 5.5 * refactor(capture-kit): take the host explicitly for Swift cache and recording tooling capture-kit no longer reads the command host, so its suites need no bound host and its local test-host copy is gone; the root recording seams supply the host. test-kit keeps depending on capture-kit again, and capture-kit does not depend on test-kit. Co-Authored-By: Claude Sonnet 5.5 * fix(capture-kit): report a worker that dies under a request in that request Co-Authored-By: Claude Sonnet 5.5 * refactor(platform-android): take the install environment from the caller The Android adb host port's environment is bound at module load, so a host whose environment differs from the process's got the process's proxy settings on a network install. Co-Authored-By: Claude Sonnet 5.5 * test(platform-apple): pin the detached snapshot bridge's starting directory Co-Authored-By: Claude Sonnet 5.5 * test(capture-kit): fail when the pre-warm starts no worker to kill Co-Authored-By: Claude Sonnet 5.5 * test(host): keep the provider scenario host's Swift cache directory for scenarios that set it on the process Co-Authored-By: Claude Sonnet 5.5 * fix(capture-kit): key the cached Swift helper paths on the host they were built for The video validator is cached per Swift cache root and the overlay scripts per working directory, so a second host does not reuse the first host's paths. Co-Authored-By: Claude Sonnet 5.5 * test(host): give the Android recording scenarios their host env through the harness Co-Authored-By: Claude Sonnet 5.5 * test(host): scenarios name their own host env; the harness no longer forwards the process's Swift cache Co-Authored-By: Claude Sonnet 5.5 * test(host): drop the iOS recording scenario's process-env block now that capture-kit takes the host Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- .../src/capture-admission/app-log-files.ts | 2 +- .../src/host-environment.fixtures.ts | 11 + .../src/png-worker-client-unavailable.test.ts | 74 ++++++ packages/capture-kit/src/png-worker-client.ts | 45 ++-- .../__tests__/overlay-script-paths.test.ts | 53 +++++ .../src/recording/__tests__/overlay.test.ts | 47 +++- .../recording/contact-sheet-frames.test.ts | 6 +- .../src/recording/contact-sheet-frames.ts | 16 +- .../src/recording/contact-sheet.test.ts | 2 + .../src/recording/contact-sheet.ts | 3 + packages/capture-kit/src/recording/overlay.ts | 47 ++-- .../src/recording/swift-cache.test.ts | 45 +++- .../capture-kit/src/recording/swift-cache.ts | 33 ++- .../capture-kit/src/recording/video.test.ts | 46 +++- packages/capture-kit/src/recording/video.ts | 38 +-- .../src/resources/__tests__/app-log.test.ts | 4 +- packages/daemon/src/resources/app-log.ts | 4 +- .../src/server/http/http-server-proxy.test.ts | 15 +- .../internal/session-observability.ts | 5 +- .../__tests__/run-script-http.test.ts | 117 ++++++---- .../src/daemon-port/run-script-http-child.ts | 1 + .../src/daemon-port/run-script-http.ts | 20 +- .../install-artifact-cancellation.test.ts | 20 +- .../src/install-artifact.test.ts | 11 +- .../platform-android/src/install-artifact.ts | 14 +- .../core/__tests__/install-artifact.test.ts | 14 +- .../__tests__/physical-device-files.test.ts | 3 + .../src/core/__tests__/screenshot.test.ts | 3 + .../src/core/install-artifact.ts | 9 +- .../src/core/physical-device-files.ts | 5 +- .../platform-apple/src/core/runner-host.ts | 2 + .../platform-apple/src/core/screenshot.ts | 5 +- .../src/runner/__tests__/host-setup.ts | 5 + .../__tests__/runner-process-launch.test.ts | 28 +++ .../runner-session-stale-bundles.test.ts | 4 +- packages/platform-apple/src/runner/host.ts | 6 + packages/platform-apple/src/runner/index.ts | 2 +- .../src/runner/runner-adoption.ts | 4 +- .../src/runner/runner-artifact.ts | 10 +- .../src/runner/runner-cache-metadata.ts | 42 ++-- .../src/runner/runner-cache-retention.ts | 8 +- .../platform-apple/src/runner/runner-cache.ts | 5 +- .../src/runner/runner-disposal.ts | 5 +- .../platform-apple/src/runner/runner-lease.ts | 5 +- .../src/runner/runner-process-launch.ts | 6 +- .../src/runner/runner-retention.ts | 4 +- .../src/runner/runner-session.ts | 5 +- .../src/runner/runner-xctestrun.ts | 2 +- .../src/snapshot-source/host.test.ts | 43 +++- .../src/snapshot-source/host.ts | 6 +- .../src/install-source-download.test.ts | 9 + .../src/install-source-download.ts | 5 +- .../install-source-network-transport.test.ts | 1 + .../src/install-source-network-transport.ts | 9 +- packages/provision-kit/src/install-source.ts | 29 ++- .../src/companion/client-companion-tunnel.ts | 7 +- .../companion/client-metro-companion.test.ts | 21 ++ .../src/companion/companion-tunnel.ts | 6 +- packages/react-native/src/index-metro.test.ts | 221 ++++++++++-------- packages/react-native/src/index.ts | 6 +- .../metro/client-metro-auto-companion.test.ts | 147 +++++++----- .../client-metro-startup-cleanup.test.ts | 7 +- .../react-native/src/metro/client-metro.ts | 14 +- .../src/metro/metro-session-hints.ts | 28 ++- scripts/clean-daemon.ts | 7 +- scripts/write-xcuitest-cache-metadata.ts | 8 +- scripts/xcuitest-build-settings.ts | 2 +- .../agent-device-client-metro-host.test.ts | 26 +++ src/__tests__/install-source.test.ts | 135 +++++++---- src/agent-device-client-metro.test.ts | 18 +- src/agent-device-client.ts | 29 ++- .../recording/runtime/contact-sheet.test.ts | 3 + .../recording/runtime/contact-sheet.ts | 2 + ...orm-runtime-android-deployment-executor.ts | 6 +- src/platform-runtime-app-log-output.test.ts | 27 ++- src/platform-runtime-app-log-output.ts | 10 +- ...tform-runtime-apple-deployment-executor.ts | 6 +- ...m-runtime-screen-recording-android-host.ts | 4 +- ...screen-recording-apple-runner-host.test.ts | 2 +- ...time-screen-recording-apple-runner-host.ts | 5 +- ...me-screen-recording-finalizer-host.test.ts | 7 +- ...runtime-screen-recording-finalizer-host.ts | 10 +- src/sdk/metro-public.test.ts | 9 + src/sdk/metro.ts | 13 +- .../integration/live-device-e2e/assertions.ts | 3 +- .../android-recording-fixtures.ts | 33 ++- .../android-recording.test.ts | 127 ++++++---- .../ios-record-trace.test.ts | 10 - .../provider-scenarios/macos-world.ts | 3 + .../record-trace-android-copy.test.ts | 27 ++- .../record-trace-android-liveness.test.ts | 23 +- .../session-close-leak-oracle.test.ts | 11 +- 92 files changed, 1390 insertions(+), 596 deletions(-) create mode 100644 packages/capture-kit/src/host-environment.fixtures.ts create mode 100644 packages/capture-kit/src/png-worker-client-unavailable.test.ts create mode 100644 packages/capture-kit/src/recording/__tests__/overlay-script-paths.test.ts create mode 100644 src/__tests__/agent-device-client-metro-host.test.ts diff --git a/packages/capture-kit/src/capture-admission/app-log-files.ts b/packages/capture-kit/src/capture-admission/app-log-files.ts index 1406b4ccb4..d3be442797 100644 --- a/packages/capture-kit/src/capture-admission/app-log-files.ts +++ b/packages/capture-kit/src/capture-admission/app-log-files.ts @@ -12,7 +12,7 @@ export type AppLogRotationConfig = Readonly<{ }>; /** Shared lower-level file policy for daemon markers and runtime output sinks. */ -export function ensureAppLogPath(outPath: string, env: NodeJS.ProcessEnv = process.env): void { +export function ensureAppLogPath(outPath: string, env: NodeJS.ProcessEnv): void { const directory = path.dirname(outPath); if (!fs.existsSync(directory)) fs.mkdirSync(directory, { recursive: true }); assertAppLogFileIsNotSymbolicLink(outPath); diff --git a/packages/capture-kit/src/host-environment.fixtures.ts b/packages/capture-kit/src/host-environment.fixtures.ts new file mode 100644 index 0000000000..e5240152ba --- /dev/null +++ b/packages/capture-kit/src/host-environment.fixtures.ts @@ -0,0 +1,11 @@ +import { captureHostEnvironment } from '@agent-device/host-kit/process'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; + +/** + * The host a capture-kit test hands to code that takes one explicitly: a snapshot of the process + * as it is when called, so `vi.stubEnv` made earlier in the test reaches it. Test-kit depends on + * capture-kit, so capture-kit keeps this local. + */ +export function createTestHost(): HostEnvironment { + return captureHostEnvironment(process); +} diff --git a/packages/capture-kit/src/png-worker-client-unavailable.test.ts b/packages/capture-kit/src/png-worker-client-unavailable.test.ts new file mode 100644 index 0000000000..565f892f04 --- /dev/null +++ b/packages/capture-kit/src/png-worker-client-unavailable.test.ts @@ -0,0 +1,74 @@ +import { EventEmitter } from 'node:events'; +import { expect, test, vi } from 'vitest'; +import { PNG } from './png.ts'; + +test('an unavailable worker is reported by the first diagnostics scope that reaches the fallback', async () => { + vi.resetModules(); + vi.doMock('./internal-entry.ts', () => ({ resolveInternalEntryModulePath: () => null })); + // Loaded after the reset so the client and the scope share one diagnostics module. + const { countDiagnosticEventsByPhase, withDiagnosticsScope } = + await import('@agent-device/host-kit/diagnostics'); + const { decodePngAsync, prewarmPngWorker } = await import('./png-worker-client.ts'); + const png = PNG.sync.write(new PNG({ width: 1, height: 1 })); + + // The daemon pre-warms outside any request scope, where the report would be dropped. + prewarmPngWorker(); + + const reported = await withDiagnosticsScope({ command: 'screenshot' }, async () => { + await decodePngAsync(png, 'first'); + const afterFirst = countDiagnosticEventsByPhase(['png_worker_unavailable']); + await decodePngAsync(png, 'second'); + return { afterFirst, afterSecond: countDiagnosticEventsByPhase(['png_worker_unavailable']) }; + }); + const laterScope = await withDiagnosticsScope({ command: 'screenshot' }, async () => { + await decodePngAsync(png, 'third'); + return countDiagnosticEventsByPhase(['png_worker_unavailable']); + }); + + expect(reported).toEqual({ afterFirst: 1, afterSecond: 1 }); + expect(laterScope).toBe(0); + vi.doUnmock('./internal-entry.ts'); +}); + +test('a worker that dies under a request is reported in that request, not only the next one', async () => { + vi.resetModules(); + class FakeWorker extends EventEmitter { + static last: FakeWorker | undefined; + constructor() { + super(); + FakeWorker.last = this; + } + postMessage(): void {} + ref(): void {} + unref(): void {} + terminate(): Promise { + return Promise.resolve(0); + } + } + vi.doMock('node:worker_threads', async (importOriginal) => ({ + ...(await importOriginal()), + Worker: FakeWorker, + })); + const { countDiagnosticEventsByPhase, withDiagnosticsScope } = + await import('@agent-device/host-kit/diagnostics'); + const { decodePngAsync, prewarmPngWorker } = await import('./png-worker-client.ts'); + const png = PNG.sync.write(new PNG({ width: 1, height: 1 })); + prewarmPngWorker(); + + let jobStarted!: () => void; + const started = new Promise((resolve) => (jobStarted = resolve)); + const scoped = withDiagnosticsScope({ command: 'screenshot' }, async () => { + const decoded = decodePngAsync(png, 'under-request'); + jobStarted(); + await decoded; + return countDiagnosticEventsByPhase(['png_worker_unavailable']); + }); + await started; + // The worker's own error event carries no diagnostics scope. + const worker = FakeWorker.last; + if (!worker) throw new Error('the pre-warm did not start a worker'); + worker.emit('error', new Error('worker crashed')); + + expect(await scoped).toBe(1); + vi.doUnmock('node:worker_threads'); +}); diff --git a/packages/capture-kit/src/png-worker-client.ts b/packages/capture-kit/src/png-worker-client.ts index 994e1cedf0..07745995a3 100644 --- a/packages/capture-kit/src/png-worker-client.ts +++ b/packages/capture-kit/src/png-worker-client.ts @@ -1,5 +1,5 @@ import { Worker } from 'node:worker_threads'; -import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { emitDiagnostic, getDiagnosticsMeta } from '@agent-device/host-kit/diagnostics'; import { AppError, toAppErrorCode } from '@agent-device/kernel/errors'; import type { Rect } from '@agent-device/kernel/snapshot'; import { resolveInternalEntryModulePath } from './internal-entry.ts'; @@ -39,21 +39,31 @@ type PendingJob = { let worker: Worker | null = null; let workerUnavailable = false; -let warnedWorkerUnavailable = false; +let workerUnavailableReason: string | undefined; +let reportedWorkerUnavailable = false; let nextJobId = 0; const pendingJobs = new Map(); -/** Permanently degrades to the sync path and reports the reason once. */ +/** Permanently degrades to the sync path; the reason is reported by the first scope that sees it. */ function markWorkerUnavailable(reason: string): void { workerUnavailable = true; - if (warnedWorkerUnavailable) return; - warnedWorkerUnavailable = true; - // Worker failures can surface outside a diagnostics scope (e.g. daemon - // startup pre-warm), so pair the scoped diagnostic with a process warning. - emitDiagnostic({ level: 'warn', phase: 'png_worker_unavailable', data: { reason } }); - process.emitWarning( - `PNG worker unavailable, falling back to in-process PNG processing: ${reason}`, - ); + workerUnavailableReason ??= reason; + reportWorkerUnavailable(); +} + +/** + * A pre-warm at daemon startup runs outside any diagnostics scope, where the event would be + * dropped, so the report waits for the first request scope that reaches the degraded path. + */ +function reportWorkerUnavailable(): void { + if (reportedWorkerUnavailable || workerUnavailableReason === undefined) return; + if (Object.keys(getDiagnosticsMeta()).length === 0) return; + reportedWorkerUnavailable = true; + emitDiagnostic({ + level: 'warn', + phase: 'png_worker_unavailable', + data: { reason: workerUnavailableReason }, + }); } function handleWorkerMessage(message: PngWorkerResponse): void { @@ -102,7 +112,10 @@ function updateWorkerRef(): void { } function obtainWorker(): Worker | null { - if (workerUnavailable) return null; + if (workerUnavailable) { + reportWorkerUnavailable(); + return null; + } if (worker) return worker; const modulePath = resolveInternalEntryModulePath(import.meta.url, PNG_WORKER_ENTRYPOINT); if (!modulePath) { @@ -110,7 +123,9 @@ function obtainWorker(): Worker | null { return null; } try { + // The worker decodes and encodes bytes it is sent; it needs none of the host's environment. const created = new Worker(modulePath, { + env: {}, execArgv: modulePath.endsWith('.ts') ? ['--experimental-strip-types'] : [], }); created.on('message', handleWorkerMessage); @@ -176,7 +191,11 @@ async function runPngJob( try { return await runWorkerJob(job); } catch (error) { - if (error instanceof PngWorkerUnavailableError) return await runSync(); + if (error instanceof PngWorkerUnavailableError) { + // A worker can fail while this request waits on it, from an event callback with no scope. + reportWorkerUnavailable(); + return await runSync(); + } throw error; } } diff --git a/packages/capture-kit/src/recording/__tests__/overlay-script-paths.test.ts b/packages/capture-kit/src/recording/__tests__/overlay-script-paths.test.ts new file mode 100644 index 0000000000..5051eff479 --- /dev/null +++ b/packages/capture-kit/src/recording/__tests__/overlay-script-paths.test.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts'; + +vi.mock('@agent-device/host-kit/command', async () => { + const fs = await import('node:fs'); + return { + runCmd: vi.fn(async (_cmd: string, args: string[]) => { + fs.writeFileSync(args[args.indexOf('--output') + 1]!, 'processed'); + return { stdout: '', stderr: '', exitCode: 0 }; + }), + }; +}); +vi.mock('../video.ts', () => ({ waitForPlayableVideo: vi.fn(async () => {}) })); +vi.mock('../swift-cache.ts', () => ({ + buildSwiftToolEnv: () => ({}), + compileSwiftSourceFile: vi.fn(async () => '/bin/true'), + resolveRecordingScriptPath: vi.fn( + (name: string, _projectRoot: string, cwd: string) => `${cwd}/${name}`, + ), +})); + +import { overlayRecordingTouches, startRecordingOverlayBudget } from '../overlay.ts'; +import { compileSwiftSourceFile } from '../swift-cache.ts'; + +test('a helper script path is resolved per host working directory, not once per process', async () => { + const root = mkdtempForTestSync('agent-device-overlay-script-paths-'); + const videoPath = path.join(root, 'recording.mp4'); + const telemetryPath = path.join(root, 'recording.gesture-telemetry.json'); + fs.writeFileSync(telemetryPath, '{"events":[]}'); + const overlay = async (cwd: string) => { + fs.writeFileSync(videoPath, 'original'); + await overlayRecordingTouches({ + host: { env: {}, cwd }, + videoPath, + telemetryPath, + deadline: startRecordingOverlayBudget(), + }); + }; + + await overlay('/cwd/one'); + await overlay('/cwd/two'); + await overlay('/cwd/one'); + + expect(vi.mocked(compileSwiftSourceFile).mock.calls.map(([params]) => params.sourcePath)).toEqual( + [ + '/cwd/one/recording-overlay.swift', + '/cwd/two/recording-overlay.swift', + '/cwd/one/recording-overlay.swift', + ], + ); +}); diff --git a/packages/capture-kit/src/recording/__tests__/overlay.test.ts b/packages/capture-kit/src/recording/__tests__/overlay.test.ts index 9a54eec10d..e42b12d359 100644 --- a/packages/capture-kit/src/recording/__tests__/overlay.test.ts +++ b/packages/capture-kit/src/recording/__tests__/overlay.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { createTestHost } from '../../host-environment.fixtures.ts'; import fs from 'node:fs'; import path from 'node:path'; import { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts'; @@ -63,12 +64,14 @@ test('overlay burns touches through a cached helper and same-directory temp outp fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), }); fs.writeFileSync(videoPath, 'second original'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -109,7 +112,12 @@ test('overlay preserves Swift helper compile hints', async () => { }); await expect( - overlayRecordingTouches({ videoPath, telemetryPath, deadline: startRecordingOverlayBudget() }), + overlayRecordingTouches({ + host: createTestHost(), + videoPath, + telemetryPath, + deadline: startRecordingOverlayBudget(), + }), ).rejects.toMatchObject({ code: 'COMMAND_FAILED', message: 'Failed to add touch overlays to the recording', @@ -126,6 +134,7 @@ test('overlay defaults to the fast medium export preset', async () => { fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -143,6 +152,7 @@ test('overlay forwards the requested high export preset', async () => { fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -161,6 +171,7 @@ test('overlay caps the helper frame rate at the caller fps only when one was ask fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -169,6 +180,7 @@ test('overlay caps the helper frame rate at the caller fps only when one was ask mockRunCmd.mockClear(); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -185,6 +197,7 @@ test('overlay hands the helper what is left of its budget, inside the record req fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -212,7 +225,7 @@ test('overlay hands the helper only what the finalization left of the budget', a const deadline = startRecordingOverlayBudget(); vi.setSystemTime(Date.now() + spentMs); - await overlayRecordingTouches({ videoPath, telemetryPath, deadline }); + await overlayRecordingTouches({ host: createTestHost(), videoPath, telemetryPath, deadline }); const helperCall = mockRunCmd.mock.calls.find(([cmd]) => cmd !== 'xcrun'); expect(helperCall?.[2]?.timeoutMs).toBe(OVERLAY_BUDGET_MS - spentMs); @@ -234,7 +247,12 @@ test('overlay leaves the video as recorded when compiling the helper spent its b }); await expect( - overlayRecordingTouches({ videoPath, telemetryPath, deadline: startRecordingOverlayBudget() }), + overlayRecordingTouches({ + host: createTestHost(), + videoPath, + telemetryPath, + deadline: startRecordingOverlayBudget(), + }), ).rejects.toMatchObject({ code: 'COMMAND_FAILED', message: 'Failed to add touch overlays to the recording', @@ -251,6 +269,7 @@ test('overlay names its budget when the export ran out of it', async () => { fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -262,7 +281,12 @@ test('overlay names its budget when the export ran out of it', async () => { }); await expect( - overlayRecordingTouches({ videoPath, telemetryPath, deadline: startRecordingOverlayBudget() }), + overlayRecordingTouches({ + host: createTestHost(), + videoPath, + telemetryPath, + deadline: startRecordingOverlayBudget(), + }), ).rejects.toMatchObject({ code: 'COMMAND_FAILED', message: `Failed to add touch overlays to the recording: the export did not finish within its ${OVERLAY_BUDGET_MS}ms budget; a lower --fps renders faster`, @@ -277,6 +301,7 @@ test('overlay keeps the plain failure when the export failed with budget to spar fs.writeFileSync(telemetryPath, '{"events":[]}'); await overlayRecordingTouches({ + host: createTestHost(), videoPath, telemetryPath, deadline: startRecordingOverlayBudget(), @@ -286,7 +311,12 @@ test('overlay keeps the plain failure when the export failed with budget to spar }); await expect( - overlayRecordingTouches({ videoPath, telemetryPath, deadline: startRecordingOverlayBudget() }), + overlayRecordingTouches({ + host: createTestHost(), + videoPath, + telemetryPath, + deadline: startRecordingOverlayBudget(), + }), ).rejects.toMatchObject({ message: 'Failed to add touch overlays to the recording', }); @@ -304,12 +334,15 @@ test('overlay keeps the video as recorded when verifying the export spent its bu }); await expect( - overlayRecordingTouches({ videoPath, telemetryPath, deadline }), + overlayRecordingTouches({ host: createTestHost(), videoPath, telemetryPath, deadline }), ).rejects.toMatchObject({ code: 'COMMAND_FAILED', message: `Failed to add touch overlays to the recording: the export did not finish within its ${OVERLAY_BUDGET_MS}ms budget; a lower --fps renders faster`, }); - expect(vi.mocked(waitForPlayableVideo)).toHaveBeenCalledWith(expect.any(String), { deadline }); + expect(vi.mocked(waitForPlayableVideo)).toHaveBeenCalledWith(expect.any(String), { + host: expect.objectContaining({ cwd: process.cwd() }), + deadline, + }); expect(fs.readFileSync(videoPath, 'utf8')).toBe('original'); expect(fs.readdirSync(tmpDir).filter((name) => name.includes('agent-device-'))).toEqual([]); }); diff --git a/packages/capture-kit/src/recording/contact-sheet-frames.test.ts b/packages/capture-kit/src/recording/contact-sheet-frames.test.ts index b8266c16ab..113a00f345 100644 --- a/packages/capture-kit/src/recording/contact-sheet-frames.test.ts +++ b/packages/capture-kit/src/recording/contact-sheet-frames.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createTestHost } from '../host-environment.fixtures.ts'; import path from 'node:path'; import { CONTACT_SHEET_EXTRACTION_REASON, @@ -37,9 +38,9 @@ function reasonOf(action: () => Promise): Promise | unknown { /** Decoding is Apple tooling, so every case here states the host it is pretending to be. */ function extract( - input: Omit[0], 'hostPlatform'>, + input: Omit[0], 'hostPlatform' | 'host'>, ): Promise>> { - return extractRecordingFrames({ ...input, hostPlatform: 'darwin' }); + return extractRecordingFrames({ host: createTestHost(), ...input, hostPlatform: 'darwin' }); } function withTimes(args: readonly string[], times: string): string[] { @@ -63,6 +64,7 @@ describe('extractRecordingFrames', () => { expect( await reasonOf(() => extractRecordingFrames({ + host: createTestHost(), videoPath: VIDEO, scratchDir, maxWidth: CELL_WIDTH, diff --git a/packages/capture-kit/src/recording/contact-sheet-frames.ts b/packages/capture-kit/src/recording/contact-sheet-frames.ts index fd6f92de77..b9e9e61c0c 100644 --- a/packages/capture-kit/src/recording/contact-sheet-frames.ts +++ b/packages/capture-kit/src/recording/contact-sheet-frames.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import path from 'node:path'; import { CONTACT_SHEET_EXTRACTION_REASON, @@ -67,6 +68,7 @@ export type ExtractedRecordingFrames = Readonly<{ */ export async function extractRecordingFrames( input: Readonly<{ + host: HostEnvironment; videoPath: string; scratchDir: string; timesMs: readonly number[]; @@ -107,7 +109,7 @@ export async function extractRecordingFrames( ], { timeoutMs: EXTRACTION_TIMEOUT_MS, - env: buildSwiftToolEnv(), + env: buildSwiftToolEnv(input.host.env), allowFailure: true, ...(input.signal === undefined ? {} : { signal: input.signal }), }, @@ -260,11 +262,17 @@ function lastNonEmptyLine(text: string): string { * Compiles the decoder helper, or fails as an extraction failure: a cold Swift cache that cannot * build the helper is exactly as unable to produce a sheet as a helper that exits nonzero. */ -async function compileDecoder(input: { videoPath: string; signal?: AbortSignal }): Promise { +async function compileDecoder(input: { + host: HostEnvironment; + videoPath: string; + signal?: AbortSignal; +}): Promise { + const { cwd } = input.host; try { return await compileSwiftSourceFile({ - sourcePath: resolveRecordingScriptPath(FRAMES_SCRIPT, findProjectRoot()), - extraSourcePaths: [resolveRecordingScriptPath(SHARED_SUPPORT_SCRIPT, findProjectRoot())], + host: input.host, + sourcePath: resolveRecordingScriptPath(FRAMES_SCRIPT, findProjectRoot(), cwd), + extraSourcePaths: [resolveRecordingScriptPath(SHARED_SUPPORT_SCRIPT, findProjectRoot(), cwd)], cacheName: 'recording-frames', timeoutMs: COMPILATION_TIMEOUT_MS, }); diff --git a/packages/capture-kit/src/recording/contact-sheet.test.ts b/packages/capture-kit/src/recording/contact-sheet.test.ts index 464f65dd3b..49d3e4d7da 100644 --- a/packages/capture-kit/src/recording/contact-sheet.test.ts +++ b/packages/capture-kit/src/recording/contact-sheet.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createTestHost } from '../host-environment.fixtures.ts'; import fs from 'node:fs'; import path from 'node:path'; import { @@ -98,6 +99,7 @@ type BuildInput = Partial< function build(videoPath: string, input: BuildInput = {}) { return buildRecordingContactSheet({ + host: createTestHost(), ...input, videoPath, maxPixels: input.maxPixels ?? 20_000_000, diff --git a/packages/capture-kit/src/recording/contact-sheet.ts b/packages/capture-kit/src/recording/contact-sheet.ts index 131f9859f2..b3f3a0fb88 100644 --- a/packages/capture-kit/src/recording/contact-sheet.ts +++ b/packages/capture-kit/src/recording/contact-sheet.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import os from 'node:os'; import path from 'node:path'; import { @@ -50,6 +51,7 @@ export type { RecordingContactSheetResult } from './contact-sheet-report.ts'; */ export async function buildRecordingContactSheet( input: Readonly<{ + host: HostEnvironment; videoPath: string; outputPath?: string; maxPixels: number; @@ -83,6 +85,7 @@ export async function buildRecordingContactSheet( const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-contact-sheet-')); try { const extracted = await extractRecordingFrames({ + host: input.host, videoPath, scratchDir, timesMs: grid.timesMs, diff --git a/packages/capture-kit/src/recording/overlay.ts b/packages/capture-kit/src/recording/overlay.ts index 7dad0d5f51..6f1d9f46b4 100644 --- a/packages/capture-kit/src/recording/overlay.ts +++ b/packages/capture-kit/src/recording/overlay.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import path from 'node:path'; import { runCmd } from '@agent-device/host-kit/command'; import { Deadline } from '@agent-device/host-kit/retry'; @@ -45,36 +46,48 @@ export function startRecordingOverlayBudget(): Deadline { */ const HELPER_EXIT_GRACE_MS = 5_000; -let overlayScriptPath: string | undefined; -let exportSupportScriptPath: string | undefined; +// The working-directory fallback in the search makes a resolved script path specific to the host's +// directory, so each directory keeps its own. +const overlayScriptPaths = new Map(); +const exportSupportScriptPaths = new Map(); -function getOverlayScriptPath(): string { - overlayScriptPath ??= resolveRecordingScriptPath('recording-overlay.swift', findProjectRoot()); - return overlayScriptPath; +function resolveCachedScriptPath( + cache: Map, + scriptName: string, + host: HostEnvironment, +): string { + let resolved = cache.get(host.cwd); + if (resolved === undefined) { + resolved = resolveRecordingScriptPath(scriptName, findProjectRoot(), host.cwd); + cache.set(host.cwd, resolved); + } + return resolved; +} + +function getOverlayScriptPath(host: HostEnvironment): string { + return resolveCachedScriptPath(overlayScriptPaths, 'recording-overlay.swift', host); } -function getExportSupportScriptPath(): string { - exportSupportScriptPath ??= resolveRecordingScriptPath( - 'RecordingExportSupport.swift', - findProjectRoot(), - ); - return exportSupportScriptPath; +function getExportSupportScriptPath(host: HostEnvironment): string { + return resolveCachedScriptPath(exportSupportScriptPaths, 'RecordingExportSupport.swift', host); } async function exportProcessedVideo(params: { + host: HostEnvironment; videoPath: string; scriptPath: string; scriptArgs: string[]; commandDescription: string; deadline: Deadline; }): Promise { - const { videoPath, scriptPath, scriptArgs, commandDescription, deadline } = params; + const { host, videoPath, scriptPath, scriptArgs, commandDescription, deadline } = params; const outputPath = temporarySiblingVideoPath(videoPath); let exportOverran = false; try { const executablePath = await compileSwiftSourceFile({ + host, sourcePath: scriptPath, - extraSourcePaths: [getExportSupportScriptPath()], + extraSourcePaths: [getExportSupportScriptPath(host)], // `runCmd` reads a timeout of 0 as none. timeoutMs: Math.max(1, deadline.remainingMs()), }); @@ -98,13 +111,13 @@ async function exportProcessedVideo(params: { '--timeout-ms', String(Math.round(exportMs)), ], - { timeoutMs: helperMs, env: buildSwiftToolEnv() }, + { timeoutMs: helperMs, env: buildSwiftToolEnv(host.env) }, ); } catch (error) { exportOverran = deadline.remainingMs() <= HELPER_EXIT_GRACE_MS; throw error; } - await waitForPlayableVideo(outputPath, { deadline }); + await waitForPlayableVideo(outputPath, { host, deadline }); if (deadline.isExpired()) { exportOverran = true; throw new AppError( @@ -148,6 +161,7 @@ function temporarySiblingVideoPath(videoPath: string): string { } export async function overlayRecordingTouches(params: { + host: HostEnvironment; videoPath: string; telemetryPath: string; exportQuality?: RecordingExportQuality; @@ -164,8 +178,9 @@ export async function overlayRecordingTouches(params: { targetLabel = 'recording', } = params; await exportProcessedVideo({ + host: params.host, videoPath, - scriptPath: getOverlayScriptPath(), + scriptPath: getOverlayScriptPath(params.host), scriptArgs: [ '--events', telemetryPath, diff --git a/packages/capture-kit/src/recording/swift-cache.test.ts b/packages/capture-kit/src/recording/swift-cache.test.ts index 8949f5583e..5087846756 100644 --- a/packages/capture-kit/src/recording/swift-cache.test.ts +++ b/packages/capture-kit/src/recording/swift-cache.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { createTestHost } from '../host-environment.fixtures.ts'; import fs from 'node:fs'; import path from 'node:path'; import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; @@ -38,6 +39,7 @@ test('compileSwiftSourceFile compiles to a unique temp executable before publish const sourcePath = writeSourceFile('print("hello")'); const executablePath = await compileSwiftSourceFile({ + host: createTestHost(), cacheName: 'recording-helper', sourcePath, }); @@ -56,10 +58,7 @@ test('concurrent file-backed helper compiles for the same cache key reuse the wi const sourcePath = writeSourceFile(); await expectConcurrentCacheReuse(() => - compileSwiftSourceFile({ - sourcePath, - cacheName: 'recording-overlay', - }), + compileSwiftSourceFile({ host: createTestHost(), sourcePath, cacheName: 'recording-overlay' }), ); }); @@ -72,16 +71,19 @@ test('extra source paths participate in the cache key and reach the compiler', a const [withSupport, withSupportAgain, withChangedSupport] = await Promise.all([ compileSwiftSourceFile({ + host: createTestHost(), sourcePath, extraSourcePaths: [supportPath], cacheName: 'recording-overlay', }), compileSwiftSourceFile({ + host: createTestHost(), sourcePath, extraSourcePaths: [supportPath], cacheName: 'recording-overlay', }), compileSwiftSourceFile({ + host: createTestHost(), sourcePath, extraSourcePaths: [changedSupportPath], cacheName: 'recording-overlay', @@ -110,6 +112,7 @@ test('cache locks with a proven dead owner are recovered before compiling', asyn await expect( compileSwiftSourceFile({ + host: createTestHost(), sourcePath, cacheName: 'recording-overlay', timeoutMs: 100, @@ -127,6 +130,7 @@ test('an ownerless cache lock is retained with verified-recovery guidance regard await expect( compileSwiftSourceFile({ + host: createTestHost(), sourcePath, cacheName: 'recording-overlay', timeoutMs: 1, @@ -156,7 +160,11 @@ test('compileSwiftSourceText resolves a cache name with a long interior dash run // call is expected to reject once it reaches disk I/O; that happens only *after* the // (now fast) sanitize step this test pins, so timing the settle either way still proves // no catastrophic backtracking occurred. - await compileSwiftSourceText({ source: 'print(1)', cacheName: value }).catch(() => {}); + await compileSwiftSourceText({ + host: createTestHost(), + source: 'print(1)', + cacheName: value, + }).catch(() => {}); const elapsedMs = Date.now() - start; expect(elapsedMs).toBeLessThan(1_000); @@ -164,6 +172,7 @@ test('compileSwiftSourceText resolves a cache name with a long interior dash run test('compileSwiftSourceText falls back to swift-helper when the cache name sanitizes to nothing', async () => { const executablePath = await compileSwiftSourceText({ + host: createTestHost(), source: 'print(1)', cacheName: '---', }); @@ -191,9 +200,9 @@ test('a compile that failed is reported over a cache lock that could not be give throw buildFailure; }); - await expect(compileSwiftSourceFile({ sourcePath, cacheName: 'recording-overlay' })).rejects.toBe( - buildFailure, - ); + await expect( + compileSwiftSourceFile({ host: createTestHost(), sourcePath, cacheName: 'recording-overlay' }), + ).rejects.toBe(buildFailure); // The release really could not verify itself: the lock is still standing. expect(fs.existsSync(lockDir)).toBe(true); }); @@ -207,6 +216,7 @@ function writeSourceFile(source = 'print("recording")'): string { async function createBlockedCacheEntry() { const sourcePath = writeSourceFile(); const executablePath = await compileSwiftSourceFile({ + host: createTestHost(), sourcePath, cacheName: 'recording-overlay', }); @@ -279,3 +289,22 @@ test('recording script candidates include packaged dist apple-runner source', () expect(firstExisting).toBe(scriptPath); }); + +test('the cache root and tool environment come from the host it is given, not the process', async () => { + const hostCacheDir = path.join(tmpDir, 'host-cache'); + + const executablePath = await compileSwiftSourceText({ + host: { + env: { AGENT_DEVICE_SWIFT_CACHE_DIR: hostCacheDir, FROM_HOST: 'yes' }, + cwd: tmpDir, + }, + source: 'print("host")', + cacheName: 'host-helper', + }); + + expect(path.dirname(executablePath)).toBe(path.join(hostCacheDir, 'bin')); + expect(mockRunCmd.mock.calls[0]?.[2]?.env).toMatchObject({ + FROM_HOST: 'yes', + HOME: path.join(hostCacheDir, 'home'), + }); +}); diff --git a/packages/capture-kit/src/recording/swift-cache.ts b/packages/capture-kit/src/recording/swift-cache.ts index e3a36e1bb9..9c4ed0aad8 100644 --- a/packages/capture-kit/src/recording/swift-cache.ts +++ b/packages/capture-kit/src/recording/swift-cache.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import { trimEdgeDashes } from '@agent-device/kernel/collections'; import { AppError } from '@agent-device/kernel/errors'; import { runCmd } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { readProcessStartTime } from '@agent-device/host-kit/process'; import { acquireProcessLock, @@ -42,13 +43,14 @@ export function buildRecordingScriptPathCandidates( /** * Resolves one recording helper script: checkout, built package, or working-directory fallback. - * The caller names the project root it trusts, so this module stays free of host metadata lookup. + * The caller names the project root it trusts, so this module stays free of host metadata lookup; + * the working directory defaults to the command host's. */ export function resolveRecordingScriptPath( scriptName: string, projectRoot: string, + cwd: string, moduleDir = path.dirname(fileURLToPath(import.meta.url)), - cwd = process.cwd(), ): string { const scriptCandidates = buildRecordingScriptPathCandidates( scriptName, @@ -69,8 +71,8 @@ export function resolveRecordingScriptPath( }); } -export function buildSwiftToolEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - const root = getSwiftCacheRoot(); +export function buildSwiftToolEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const root = getSwiftCacheRoot(env); const homePath = path.join(root, 'home'); const moduleCachePath = path.join(root, 'module-cache'); fs.mkdirSync(homePath, { recursive: true }); @@ -83,6 +85,7 @@ export function buildSwiftToolEnv(env: NodeJS.ProcessEnv = process.env): NodeJS. } export async function compileSwiftSourceFile(params: { + host: HostEnvironment; sourcePath: string; /** Additional compilation units (shared helpers) compiled into the same executable. */ extraSourcePaths?: string[]; @@ -108,8 +111,13 @@ export async function compileSwiftSourceFile(params: { source, ]), ]); - const executablePath = path.join(getSwiftCacheRoot(), 'bin', `${cacheName}-${key}`); + const executablePath = path.join( + getSwiftCacheRoot(params.host.env), + 'bin', + `${cacheName}-${key}`, + ); await ensureSwiftExecutable({ + host: params.host, sourcePaths, executablePath, timeoutMs: params.timeoutMs, @@ -118,16 +126,19 @@ export async function compileSwiftSourceFile(params: { } export async function compileSwiftSourceText(params: { + host: HostEnvironment; source: string; cacheName: string; timeoutMs?: number; }): Promise { const cacheName = sanitizeCacheName(params.cacheName); const key = hashParts([SWIFT_CACHE_VERSION, process.platform, process.arch, params.source]); - const sourcePath = path.join(getSwiftCacheRoot(), 'sources', `${cacheName}-${key}.swift`); - const executablePath = path.join(getSwiftCacheRoot(), 'bin', `${cacheName}-${key}`); + const cacheRoot = getSwiftCacheRoot(params.host.env); + const sourcePath = path.join(cacheRoot, 'sources', `${cacheName}-${key}.swift`); + const executablePath = path.join(cacheRoot, 'bin', `${cacheName}-${key}`); await ensureSwiftExecutable({ + host: params.host, sourcePaths: [sourcePath], executablePath, sourceText: params.source, @@ -136,8 +147,9 @@ export async function compileSwiftSourceText(params: { return executablePath; } -function getSwiftCacheRoot(): string { - const configured = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR?.trim(); +/** The directory compiled helpers land in for `env`: what a cached helper path is valid for. */ +export function getSwiftCacheRoot(env: NodeJS.ProcessEnv): string { + const configured = env.AGENT_DEVICE_SWIFT_CACHE_DIR?.trim(); if (configured) { return path.resolve(configured); } @@ -145,6 +157,7 @@ function getSwiftCacheRoot(): string { } async function ensureSwiftExecutable(params: { + host: HostEnvironment; sourcePaths: string[]; executablePath: string; sourceText?: string; @@ -180,7 +193,7 @@ async function ensureSwiftExecutable(params: { } await runCmd('xcrun', ['swiftc', ...params.sourcePaths, '-o', tempExecutablePath], { timeoutMs, - env: buildSwiftToolEnv(), + env: buildSwiftToolEnv(params.host.env), }); fs.renameSync(tempExecutablePath, params.executablePath); } finally { diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index c91904f010..00039aae51 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { createTestHost } from '../host-environment.fixtures.ts'; import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import { runCmd } from '@agent-device/host-kit/command'; @@ -6,6 +7,7 @@ import { likelyPlayableWebmContainer } from '../__tests__/test-utils/video-fixtu import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; import { mp4Atom, mp4MovieHeader } from './mp4.fixtures.ts'; import { Deadline } from '@agent-device/host-kit/retry'; +import { compileSwiftSourceText } from './swift-cache.ts'; import { isPlayableVideo, waitForPlayableVideo } from './video.ts'; vi.mock('@agent-device/host-kit/command', () => ({ @@ -13,7 +15,8 @@ vi.mock('@agent-device/host-kit/command', () => ({ })); vi.mock('./swift-cache.ts', () => ({ buildSwiftToolEnv: () => ({}), - compileSwiftSourceText: async () => '/bin/true', + getSwiftCacheRoot: (env: NodeJS.ProcessEnv) => env.AGENT_DEVICE_SWIFT_CACHE_DIR ?? '', + compileSwiftSourceText: vi.fn(async () => '/bin/true'), })); beforeEach(() => { @@ -24,11 +27,15 @@ const directory = mkdtempForTestSync('agent-device-video-webm-'); const playableWebm = likelyPlayableWebmContainer(); test('accepts a real WebM video track and complete media block without AVFoundation', async () => { - await expect(isPlayableVideo(writeFixture('capture.webm', playableWebm))).resolves.toBe(true); + await expect( + isPlayableVideo(writeFixture('capture.webm', playableWebm), createTestHost()), + ).resolves.toBe(true); }); test('does not infer WebM from bytes when the requested container is different', async () => { - await expect(isPlayableVideo(writeFixture('capture.bin', playableWebm))).resolves.toBe(false); + await expect( + isPlayableVideo(writeFixture('capture.bin', playableWebm), createTestHost()), + ).resolves.toBe(false); }); function writeFixture(name: string, bytes: Buffer): string { @@ -43,15 +50,38 @@ const moov = mp4Atom( mp4Atom('mvhd', mp4MovieHeader({ version: 0, timescale: 1_000, duration: 2_000 })), ); +test('each Swift cache root compiles its own validator, and a root reuses the one it has', async () => { + const mp4 = writeFixture('validated.mp4', Buffer.concat([ftyp, moov])); + const hostIn = (cacheDir: string) => ({ + env: { AGENT_DEVICE_SWIFT_CACHE_DIR: cacheDir }, + cwd: directory, + }); + vi.mocked(compileSwiftSourceText).mockClear(); + + await isPlayableVideo(mp4, hostIn('/cache/one')); + await isPlayableVideo(mp4, hostIn('/cache/one')); + await isPlayableVideo(mp4, hostIn('/cache/two')); + + expect( + vi + .mocked(compileSwiftSourceText) + .mock.calls.map(([params]) => params.host.env.AGENT_DEVICE_SWIFT_CACHE_DIR), + ).toEqual(['/cache/one', '/cache/two']); +}); + test('reaches the semantic validator with an MP4 container whose last box runs past the file', async () => { const truncated = Buffer.concat([ftyp, moov]).subarray(0, ftyp.length + moov.length - 4); - await expect(isPlayableVideo(writeFixture('truncated.mp4', truncated))).resolves.toBe(true); + await expect( + isPlayableVideo(writeFixture('truncated.mp4', truncated), createTestHost()), + ).resolves.toBe(true); expect(runCmd).toHaveBeenCalledTimes(1); }); test('refuses an MP4 container that never declares a movie header', async () => { const container = Buffer.concat([ftyp, mp4Atom('mdat', Buffer.alloc(8))]); - await expect(isPlayableVideo(writeFixture('no-moov.mp4', container))).resolves.toBe(false); + await expect( + isPlayableVideo(writeFixture('no-moov.mp4', container), createTestHost()), + ).resolves.toBe(false); expect(runCmd).not.toHaveBeenCalled(); }); @@ -59,6 +89,10 @@ test('waiting for a playable video stops polling once its deadline passes', asyn const filePath = path.join(mkdtempForTestSync('agent-device-video-wait-'), 'never-playable.mp4'); fs.writeFileSync(filePath, ''); const started = Date.now(); - await waitForPlayableVideo(filePath, { pollMs: 60_000, deadline: Deadline.fromTimeoutMs(0) }); + await waitForPlayableVideo(filePath, { + host: createTestHost(), + pollMs: 60_000, + deadline: Deadline.fromTimeoutMs(0), + }); expect(Date.now() - started).toBeLessThan(1_000); }); diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index c6284eb1f6..ece772e724 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -2,7 +2,8 @@ import fs from 'node:fs'; import { AppError } from '@agent-device/kernel/errors'; import { runCmd } from '@agent-device/host-kit/command'; import { type Deadline, sleep } from '@agent-device/host-kit/retry'; -import { buildSwiftToolEnv, compileSwiftSourceText } from './swift-cache.ts'; +import { buildSwiftToolEnv, compileSwiftSourceText, getSwiftCacheRoot } from './swift-cache.ts'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { hasPlayableWebmStructure } from './video-webm.ts'; @@ -34,7 +35,8 @@ semaphore.wait() exit(exitCode) `.trim(); -let videoValidatorExecutablePathPromise: Promise | undefined; +// A compiled helper is valid for the Swift cache it was built into, so each cache root keeps its own. +const videoValidatorExecutablePaths = new Map>(); export async function waitForStableFile( filePath: string, @@ -67,18 +69,18 @@ export async function waitForStableFile( } } -export async function isPlayableVideo(filePath: string): Promise { +export async function isPlayableVideo(filePath: string, host: HostEnvironment): Promise { const container = await readVideoContainerKind(filePath); if (!container) return false; // AVFoundation is the MP4 semantic validator. It does not reliably load WebM on supported // macOS hosts, so WebM completion is established by its EBML document type + Segment marker. if (container === 'webm') return true; try { - const validatorPath = await getVideoValidatorExecutablePath(); + const validatorPath = await getVideoValidatorExecutablePath(host); const result = await runCmd(validatorPath, [filePath], { allowFailure: true, timeoutMs: 10_000, - env: buildSwiftToolEnv(), + env: buildSwiftToolEnv(host.env), }); if (result.exitCode === 0) { return true; @@ -92,16 +94,22 @@ export async function isPlayableVideo(filePath: string): Promise { } } -async function getVideoValidatorExecutablePath(): Promise { - videoValidatorExecutablePathPromise ??= compileSwiftSourceText({ - source: VIDEO_VALIDATION_SCRIPT, - cacheName: 'video-validator', - timeoutMs: 30_000, - }); +async function getVideoValidatorExecutablePath(host: HostEnvironment): Promise { + const cacheRoot = getSwiftCacheRoot(host.env); + let compiled = videoValidatorExecutablePaths.get(cacheRoot); + if (!compiled) { + compiled = compileSwiftSourceText({ + host, + source: VIDEO_VALIDATION_SCRIPT, + cacheName: 'video-validator', + timeoutMs: 30_000, + }); + videoValidatorExecutablePaths.set(cacheRoot, compiled); + } try { - return await videoValidatorExecutablePathPromise; + return await compiled; } catch (error) { - videoValidatorExecutablePathPromise = undefined; + videoValidatorExecutablePaths.delete(cacheRoot); throw error; } } @@ -122,13 +130,13 @@ function isSwiftVideoValidatorError(error: unknown): boolean { /** Polls until the file plays, for `attempts` polls or until `deadline` passes, whichever is first. */ export async function waitForPlayableVideo( filePath: string, - options: { pollMs?: number; attempts?: number; deadline?: Deadline } = {}, + options: { host: HostEnvironment; pollMs?: number; attempts?: number; deadline?: Deadline }, ): Promise { const pollMs = options.pollMs ?? 150; const attempts = options.attempts ?? 12; for (let attempt = 0; attempt < attempts; attempt += 1) { - if (await isPlayableVideo(filePath)) { + if (await isPlayableVideo(filePath, options.host)) { return; } if (options.deadline?.isExpired()) { diff --git a/packages/daemon/src/resources/__tests__/app-log.test.ts b/packages/daemon/src/resources/__tests__/app-log.test.ts index c96feea65a..40630c37c6 100644 --- a/packages/daemon/src/resources/__tests__/app-log.test.ts +++ b/packages/daemon/src/resources/__tests__/app-log.test.ts @@ -12,7 +12,7 @@ test('marker and clear operations keep app-log file ownership in the daemon', () const root = mkdtempForTestSync('agent-device-app-log-files-'); const outPath = path.join(root, 'session', 'app.log'); - appendAppLogMarker(outPath, 'checkpoint'); + appendAppLogMarker(outPath, 'checkpoint', process.env); fs.writeFileSync(`${outPath}.1`, 'rotated'); expect(fs.readFileSync(outPath, 'utf8')).toMatch(/\[agent-device\]\[mark\].* checkpoint/); @@ -44,7 +44,7 @@ test.each(['metadata', 'mark', 'clear'] as const)( assertThrowsAppError( () => { if (operation === 'metadata') getAppLogPathMetadata(outPath); - else if (operation === 'mark') appendAppLogMarker(outPath, 'checkpoint'); + else if (operation === 'mark') appendAppLogMarker(outPath, 'checkpoint', process.env); else clearAppLogFiles(outPath); }, { code: 'COMMAND_FAILED', message: expectedMessage, hint: NOT_REGULAR_FILE_HINT }, diff --git a/packages/daemon/src/resources/app-log.ts b/packages/daemon/src/resources/app-log.ts index 31ef5a3fbf..3887a794b9 100644 --- a/packages/daemon/src/resources/app-log.ts +++ b/packages/daemon/src/resources/app-log.ts @@ -26,8 +26,8 @@ export function getAppLogPathMetadata(outPath: string): { } } -export function appendAppLogMarker(outPath: string, marker: string): void { - ensureAppLogPath(outPath); +export function appendAppLogMarker(outPath: string, marker: string, env: NodeJS.ProcessEnv): void { + ensureAppLogPath(outPath, env); const line = `[agent-device][mark][${new Date().toISOString()}] ${marker.trim() || 'marker'}\n`; const descriptor = openVerifiedFileForAppend(outPath); try { diff --git a/packages/daemon/src/server/http/http-server-proxy.test.ts b/packages/daemon/src/server/http/http-server-proxy.test.ts index b670de57a9..ca93e9c3a4 100644 --- a/packages/daemon/src/server/http/http-server-proxy.test.ts +++ b/packages/daemon/src/server/http/http-server-proxy.test.ts @@ -183,12 +183,15 @@ test('proxy enforces public-only Maestro HTTP policy on a local daemon', async ( const url = request.positionals[0] ?? ''; return { ok: true, - data: await executeRunScriptHttpRequest({ - method: 'GET', - url, - headers: {}, - publicNetworkOnly: request.internal?.publicNetworkOnly === true, - }), + data: await executeRunScriptHttpRequest( + { + method: 'GET', + url, + headers: {}, + publicNetworkOnly: request.internal?.publicNetworkOnly === true, + }, + env, + ), }; }, }); diff --git a/packages/daemon/src/sessions/session-observability/internal/session-observability.ts b/packages/daemon/src/sessions/session-observability/internal/session-observability.ts index d19ce34029..b73244b47a 100644 --- a/packages/daemon/src/sessions/session-observability/internal/session-observability.ts +++ b/packages/daemon/src/sessions/session-observability/internal/session-observability.ts @@ -211,7 +211,7 @@ async function executeLogsRuntimePlan( return await handleLogsStop(params); case 'mark': await params.bindDevice(params.session.device, plan.use); - return handleLogsMark(plan.marker, params.sessionName, params.sessionStore); + return handleLogsMark(plan.marker, params.sessionName, params.sessionStore, params.host.env); case 'clear': await params.bindDevice(params.session.device, plan.use); return handleLogsClear(params); @@ -301,9 +301,10 @@ function handleLogsMark( marker: string, sessionName: string, sessionStore: SessionStore, + env: NodeJS.ProcessEnv, ): DaemonResponse { const logPath = sessionStore.resolveAppLogPath(sessionName); - appendAppLogMarker(logPath, marker); + appendAppLogMarker(logPath, marker, env); return { ok: true, data: { path: logPath, marked: true } }; } diff --git a/packages/maestro/src/daemon-port/__tests__/run-script-http.test.ts b/packages/maestro/src/daemon-port/__tests__/run-script-http.test.ts index 4f121ee7fa..5a610a8aa6 100644 --- a/packages/maestro/src/daemon-port/__tests__/run-script-http.test.ts +++ b/packages/maestro/src/daemon-port/__tests__/run-script-http.test.ts @@ -40,13 +40,16 @@ test('keeps local requests on the existing Fetch path', async () => { ); vi.stubGlobal('fetch', fetchMock); - const result = await executeRunScriptHttpRequest({ - method: 'POST', - url: 'http://127.0.0.1:8080/local', - headers: { authorization: 'secret' }, - body: '{}', - publicNetworkOnly: false, - }); + const result = await executeRunScriptHttpRequest( + { + method: 'POST', + url: 'http://127.0.0.1:8080/local', + headers: { authorization: 'secret' }, + body: '{}', + publicNetworkOnly: false, + }, + process.env, + ); assert.deepEqual(result, { status: 201, @@ -73,13 +76,16 @@ test('preserves Fetch decompression for public requests', async (t) => { const port = await listenOnLoopback(server); approveDownloadSourceUrl.mockResolvedValue({ address: '127.0.0.1', family: 4 }); - const result = await executeRunScriptHttpRequest({ - method: 'POST', - url: `http://public.test:${port}/data`, - headers: {}, - body: '{}', - publicNetworkOnly: true, - }); + const result = await executeRunScriptHttpRequest( + { + method: 'POST', + url: `http://public.test:${port}/data`, + headers: {}, + body: '{}', + publicNetworkOnly: true, + }, + process.env, + ); assert.equal(result.body, 'decoded'); } finally { @@ -109,16 +115,19 @@ test('uses Fetch cross-origin 307 header and body semantics', async (t) => { const port = await listenOnLoopback(server); approveDownloadSourceUrl.mockResolvedValue({ address: '127.0.0.1', family: 4 }); - const result = await executeRunScriptHttpRequest({ - method: 'POST', - url: `http://public.test:${port}/start`, - headers: { - authorization: 'secret', - 'content-type': 'application/json', + const result = await executeRunScriptHttpRequest( + { + method: 'POST', + url: `http://public.test:${port}/start`, + headers: { + authorization: 'secret', + 'content-type': 'application/json', + }, + body: '{}', + publicNetworkOnly: true, }, - body: '{}', - publicNetworkOnly: true, - }); + process.env, + ); assert.equal(result.body, 'ok'); assert.equal(redirectedHeaders?.authorization, undefined); @@ -146,12 +155,15 @@ test('reapproves a redirect before its connection', async (t) => { .mockRejectedValueOnce(new AppError('INVALID_ARGS', 'non-public address')); await assert.rejects( - executeRunScriptHttpRequest({ - method: 'GET', - url: `http://public.test:${port}/start`, - headers: {}, - publicNetworkOnly: true, - }), + executeRunScriptHttpRequest( + { + method: 'GET', + url: `http://public.test:${port}/start`, + headers: {}, + publicNetworkOnly: true, + }, + process.env, + ), /non-public address/, ); assert.equal(requests, 1); @@ -164,12 +176,15 @@ test('validates an IP literal before connecting', async () => { approveDownloadSourceUrl.mockRejectedValue(new AppError('INVALID_ARGS', 'non-public address')); await assert.rejects( - executeRunScriptHttpRequest({ - method: 'GET', - url: 'http://169.254.169.254/latest/meta-data', - headers: {}, - publicNetworkOnly: true, - }), + executeRunScriptHttpRequest( + { + method: 'GET', + url: 'http://169.254.169.254/latest/meta-data', + headers: {}, + publicNetworkOnly: true, + }, + process.env, + ), /non-public address/, ); assert.equal(approveDownloadSourceUrl.mock.calls.length, 1); @@ -203,12 +218,15 @@ test('preserves configured proxy routing while pinning the approved destination' vi.stubEnv('NO_PROXY', ''); approveDownloadSourceUrl.mockResolvedValue({ address: '127.0.0.1', family: 4 }); - const result = await executeRunScriptHttpRequest({ - method: 'GET', - url: `http://public.test:${targetPort}/data`, - headers: {}, - publicNetworkOnly: true, - }); + const result = await executeRunScriptHttpRequest( + { + method: 'GET', + url: `http://public.test:${targetPort}/data`, + headers: {}, + publicNetworkOnly: true, + }, + process.env, + ); assert.equal(result.body, 'proxied'); assert.equal(connectTarget, `127.0.0.1:${targetPort}`); @@ -220,7 +238,7 @@ test('preserves configured proxy routing while pinning the approved destination' }); test('rejects an HTTPS downgrade before approval or dispatch', async () => { - const dispatcher = new PublicFetchDispatcher(true, AbortSignal.timeout(1_000)); + const dispatcher = new PublicFetchDispatcher(true, AbortSignal.timeout(1_000), process.env); const error = await new Promise((resolve) => { dispatcher.dispatch({ origin: 'http://example.test', path: '/redirected', method: 'GET' }, { onError: resolve, @@ -242,12 +260,15 @@ test('caps the decoded public response body', async (t) => { approveDownloadSourceUrl.mockResolvedValue({ address: '127.0.0.1', family: 4 }); await assert.rejects( - executeRunScriptHttpRequest({ - method: 'GET', - url: `http://public.test:${port}/large`, - headers: {}, - publicNetworkOnly: true, - }), + executeRunScriptHttpRequest( + { + method: 'GET', + url: `http://public.test:${port}/large`, + headers: {}, + publicNetworkOnly: true, + }, + process.env, + ), new RegExp(`response exceeded ${MAX_RUN_SCRIPT_HTTP_RESPONSE_BYTES} bytes`), ); } finally { diff --git a/packages/maestro/src/daemon-port/run-script-http-child.ts b/packages/maestro/src/daemon-port/run-script-http-child.ts index 5a91ff25c3..8c20de3831 100644 --- a/packages/maestro/src/daemon-port/run-script-http-child.ts +++ b/packages/maestro/src/daemon-port/run-script-http-child.ts @@ -6,6 +6,7 @@ import { executeRunScriptHttpRequest } from './run-script-http.ts'; export async function runScriptHttpChild(): Promise { const response = await executeRunScriptHttpRequest( JSON.parse(readFileSync(0, 'utf8')) as RunScriptHttpRequest, + process.env, ); process.stdout.write(JSON.stringify(response)); } diff --git a/packages/maestro/src/daemon-port/run-script-http.ts b/packages/maestro/src/daemon-port/run-script-http.ts index ccf9af1807..11d5fb1f8e 100644 --- a/packages/maestro/src/daemon-port/run-script-http.ts +++ b/packages/maestro/src/daemon-port/run-script-http.ts @@ -22,9 +22,10 @@ export type RunScriptHttpResponse = { export async function executeRunScriptHttpRequest( input: RunScriptHttpRequest, + environment: NodeJS.ProcessEnv, ): Promise { if (!input.publicNetworkOnly) return await executeLocalRequest(input); - return await executePublicRequest(input); + return await executePublicRequest(input, environment); } async function executeLocalRequest(input: RunScriptHttpRequest): Promise { @@ -35,10 +36,17 @@ async function executeLocalRequest(input: RunScriptHttpRequest): Promise { +async function executePublicRequest( + input: RunScriptHttpRequest, + environment: NodeJS.ProcessEnv, +): Promise { const initialUrl = parsePublicUrl(input.url); const signal = AbortSignal.timeout(30_000); - const dispatcher = new PublicFetchDispatcher(initialUrl.protocol === 'https:', signal); + const dispatcher = new PublicFetchDispatcher( + initialUrl.protocol === 'https:', + signal, + environment, + ); try { const response = await undiciFetch(initialUrl, { ...fetchInit(input), @@ -69,11 +77,13 @@ export class PublicFetchDispatcher extends Dispatcher { readonly #dispatchers = new Set(); #httpsRequired: boolean; readonly #signal: AbortSignal; + readonly #environment: NodeJS.ProcessEnv; - constructor(httpsRequired: boolean, signal: AbortSignal) { + constructor(httpsRequired: boolean, signal: AbortSignal, environment: NodeJS.ProcessEnv) { super(); this.#httpsRequired = httpsRequired; this.#signal = signal; + this.#environment = environment; } override dispatch( @@ -100,7 +110,7 @@ export class PublicFetchDispatcher extends Dispatcher { } if (url.protocol === 'https:') this.#httpsRequired = true; const approved = await approveDownloadSourceUrl(url, this.#signal); - const proxyUrl = resolveProxyForUrl(url); + const proxyUrl = resolveProxyForUrl(url, this.#environment); const dispatcher = proxyUrl ? proxyDispatcher(proxyUrl, url) : directDispatcher(approved.address, approved.family); diff --git a/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts b/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts index 4241f7e867..508c8aa881 100644 --- a/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts +++ b/packages/platform-android/src/__tests__/install-artifact-cancellation.test.ts @@ -10,6 +10,9 @@ import * as networkTransport from '@agent-device/provision-kit/install-source-ne import { mkdtempForTest } from './test-utils/tmp-dir.ts'; import { prepareAndroidInstallArtifact } from '../install-artifact.ts'; import * as manifest from '../manifest.ts'; +import { bindAndroidAdbHostStub } from '../adb-host.fixtures.ts'; + +bindAndroidAdbHostStub(); test('aborts an in-flight Android artifact identity probe', async () => { const tempRoot = await mkdtempForTest('agent-device-android-artifact-abort-'); @@ -28,7 +31,7 @@ test('aborts an in-flight Android artifact identity probe', async () => { try { const pending = prepareAndroidInstallArtifact( { kind: 'path', path: archivePath }, - { signal: controller.signal }, + { environment: process.env, signal: controller.signal }, ); await vi.waitFor(() => assert.equal(probe.mock.calls.length, 1)); controller.abort(new DOMException('cancelled', 'AbortError')); @@ -62,12 +65,19 @@ test('cleans URL materialization when identity inspection fails', async () => { try { await assert.rejects( () => - prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://example.com/app.apk', - }), + prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'https://example.com/app.apk', + }, + { environment: { https_proxy: 'http://host-proxy' } }, + ), /identity failed/, ); + // The download follows the environment the caller passed, not the adb host's. + assert.deepEqual(requestSpy.mock.calls[0]?.[0].environment, { + https_proxy: 'http://host-proxy', + }); assert.deepEqual(await fs.readdir(tempRoot), []); } finally { manifestSpy.mockRestore(); diff --git a/packages/platform-android/src/install-artifact.test.ts b/packages/platform-android/src/install-artifact.test.ts index 13a1bc283a..11c8a0d197 100644 --- a/packages/platform-android/src/install-artifact.test.ts +++ b/packages/platform-android/src/install-artifact.test.ts @@ -16,10 +16,13 @@ test('installing an already-installed APK resolves its package with no aapt reac // after — the diff resolves nothing — and no adb call may run. The closest negative — the same // install without the artifact identity — lives in deployment/native.test.ts. bindAndroidAdbHostStub(); - const artifact = await prepareAndroidInstallArtifact({ - kind: 'path', - path: ANDROID_MANIFEST_APK_FIXTURE_PATH, - }); + const artifact = await prepareAndroidInstallArtifact( + { + kind: 'path', + path: ANDROID_MANIFEST_APK_FIXTURE_PATH, + }, + { environment: process.env }, + ); try { assert.equal(artifact.packageName, ANDROID_MANIFEST_FIXTURE_PACKAGE); const adbCalls: string[][] = []; diff --git a/packages/platform-android/src/install-artifact.ts b/packages/platform-android/src/install-artifact.ts index 5ec4b3cc19..ac1baf7ff7 100644 --- a/packages/platform-android/src/install-artifact.ts +++ b/packages/platform-android/src/install-artifact.ts @@ -12,20 +12,26 @@ export type PreparedAndroidInstallArtifact = { export async function prepareAndroidInstallArtifact( source: LocalInstallSource, - options?: { signal?: AbortSignal; resolveIdentity?: boolean }, + options: { + signal?: AbortSignal; + resolveIdentity?: boolean; + /** The host environment whose proxy settings apply to a network download. */ + environment: NodeJS.ProcessEnv; + }, ): Promise { const materialized = await materializeInstallablePath({ source, isInstallablePath: (candidatePath, stat) => stat.isFile() && isAndroidInstallablePath(candidatePath), installableLabel: 'Android installable (.apk or .aab)', - signal: options?.signal, + signal: options.signal, + environment: options.environment, }); try { const identity = - options?.resolveIdentity === false + options.resolveIdentity === false ? {} - : await inspectAndroidArtifactIdentity(materialized.installablePath, options?.signal); + : await inspectAndroidArtifactIdentity(materialized.installablePath, options.signal); return { archivePath: materialized.archivePath, installablePath: materialized.installablePath, diff --git a/packages/platform-apple/src/core/__tests__/install-artifact.test.ts b/packages/platform-apple/src/core/__tests__/install-artifact.test.ts index cda3ca4d84..10711d088e 100644 --- a/packages/platform-apple/src/core/__tests__/install-artifact.test.ts +++ b/packages/platform-apple/src/core/__tests__/install-artifact.test.ts @@ -20,7 +20,7 @@ test('prepareIosInstallArtifact resolves a multi-payload IPA by a bundle-name hi await withMultiPayloadIpa([{ name: 'Alpha' }, { name: 'Beta' }], async (ipaPath) => { const prepared = await prepareIosInstallArtifact( { kind: 'path', path: ipaPath }, - { appIdentifierHint: 'beta' }, + { environment: process.env, appIdentifierHint: 'beta' }, ); try { assert.equal(path.basename(prepared.installablePath), 'Beta.app'); @@ -36,7 +36,7 @@ test('prepareIosInstallArtifact resolves a multi-payload IPA by a dotted bundle- await withMultiPayloadIpa([{ name: 'Alpha' }, { name: 'Beta' }], async (ipaPath) => { const prepared = await prepareIosInstallArtifact( { kind: 'path', path: ipaPath }, - { appIdentifierHint: 'COM.Example.Alpha' }, + { environment: process.env, appIdentifierHint: 'COM.Example.Alpha' }, ); try { assert.equal(path.basename(prepared.installablePath), 'Alpha.app'); @@ -57,7 +57,7 @@ test('prepareIosInstallArtifact lists the available bundles when a dotted hint m async () => await prepareIosInstallArtifact( { kind: 'path', path: ipaPath }, - { appIdentifierHint: 'com.example.gamma' }, + { environment: process.env, appIdentifierHint: 'com.example.gamma' }, ), (error: unknown) => error instanceof AppError && @@ -72,7 +72,11 @@ test('prepareIosInstallArtifact lists the available bundles when a dotted hint m test('prepareIosInstallArtifact lists the available bundles when a hint-free multi-payload IPA fails', async () => { await withMultiPayloadIpa([{ name: 'Alpha' }, { name: 'Beta' }], async (ipaPath) => { await assert.rejects( - async () => await prepareIosInstallArtifact({ kind: 'path', path: ipaPath }), + async () => + await prepareIosInstallArtifact( + { kind: 'path', path: ipaPath }, + { environment: process.env }, + ), /Pass an app identifier or bundle name matching one of: Alpha\.app \(com\.example\.alpha\), Beta\.app \(com\.example\.beta\)/, ); }); @@ -89,7 +93,7 @@ test('prepareIosInstallArtifact keeps a non-dotted hint a name-only match', asyn async () => await prepareIosInstallArtifact( { kind: 'path', path: ipaPath }, - { appIdentifierHint: 'beta-app' }, + { environment: process.env, appIdentifierHint: 'beta-app' }, ), /none matched "beta-app"/, ); diff --git a/packages/platform-apple/src/core/__tests__/physical-device-files.test.ts b/packages/platform-apple/src/core/__tests__/physical-device-files.test.ts index b06dabb3f2..51a982b47c 100644 --- a/packages/platform-apple/src/core/__tests__/physical-device-files.test.ts +++ b/packages/platform-apple/src/core/__tests__/physical-device-files.test.ts @@ -1,4 +1,5 @@ import { test } from 'vitest'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { AppError } from '@agent-device/kernel/errors'; import { copyCoreDeviceRunnerFile } from '../physical-device-files.ts'; @@ -11,6 +12,8 @@ import { withFakeAppleTool, type FakeAppleToolScript } from '../../__tests__/fak * candidate bundle ids in order and reports only what the last one said. The shapes below pin the * retry boundary (one success stops the loop) and each source of the reported message. */ +bindTestCommandHost(); + test('copyCoreDeviceRunnerFile copies with the app data container domain', async () => { const calls = await runCopy(() => ''); assert.equal(calls.length, 1); diff --git a/packages/platform-apple/src/core/__tests__/screenshot.test.ts b/packages/platform-apple/src/core/__tests__/screenshot.test.ts index 01960d1c13..1d7c1f7430 100644 --- a/packages/platform-apple/src/core/__tests__/screenshot.test.ts +++ b/packages/platform-apple/src/core/__tests__/screenshot.test.ts @@ -3,8 +3,11 @@ import assert from 'node:assert/strict'; import { promises as fs, readFileSync } from 'node:fs'; import path from 'node:path'; import type { RunnerScreenCaptureMetadata } from '@agent-device/contracts/screen-capture-contract'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import { mkdtempForTest } from '../../__tests__/tmp-dir.ts'; +bindTestCommandHost(); + vi.mock('@agent-device/host-kit/command', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runCmd: vi.fn(actual.runCmd) }; diff --git a/packages/platform-apple/src/core/install-artifact.ts b/packages/platform-apple/src/core/install-artifact.ts index 5fe729906d..1936b857cd 100644 --- a/packages/platform-apple/src/core/install-artifact.ts +++ b/packages/platform-apple/src/core/install-artifact.ts @@ -24,6 +24,8 @@ import { type InstallIosArtifactOptions = { appIdentifierHint?: string; signal?: AbortSignal; + /** The host environment whose proxy settings apply to a network download. */ + environment: NodeJS.ProcessEnv; }; type IosPayloadAppBundle = { @@ -44,7 +46,7 @@ export type PreparedIosInstallArtifact = { export async function prepareIosInstallArtifact( source: LocalInstallSource, - options?: InstallIosArtifactOptions, + options: InstallIosArtifactOptions, ): Promise { return await withInstallArtifactArchiveScope( async () => await prepareIosInstallArtifactInScope(source, options), @@ -53,7 +55,7 @@ export async function prepareIosInstallArtifact( async function prepareIosInstallArtifactInScope( source: LocalInstallSource, - options?: InstallIosArtifactOptions, + options: InstallIosArtifactOptions, ): Promise { const materialized = await materializeInstallablePath({ source, @@ -61,7 +63,8 @@ async function prepareIosInstallArtifactInScope( (stat.isDirectory() && candidatePath.toLowerCase().endsWith('.app')) || (stat.isFile() && candidatePath.toLowerCase().endsWith('.ipa')), installableLabel: 'iOS installable (.app or .ipa)', - signal: options?.signal, + signal: options.signal, + environment: options.environment, }); let resolved: Awaited> | undefined; diff --git a/packages/platform-apple/src/core/physical-device-files.ts b/packages/platform-apple/src/core/physical-device-files.ts index d9ffd0978b..ddb0ce3413 100644 --- a/packages/platform-apple/src/core/physical-device-files.ts +++ b/packages/platform-apple/src/core/physical-device-files.ts @@ -1,8 +1,9 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { Deadline } from '@agent-device/host-kit/retry'; import { IOS_RUNNER_SCREENSHOT_COPY_TIMEOUT_MS } from './config.ts'; -import { IOS_RUNNER_CONTAINER_BUNDLE_IDS } from '../runner/index.ts'; +import { resolveRunnerContainerBundleIds } from '../runner/index.ts'; import { runXcrun } from './tool-provider.ts'; export async function copyCoreDeviceRunnerFile( @@ -13,7 +14,7 @@ export async function copyCoreDeviceRunnerFile( ): Promise { const deadline = Deadline.fromTimeoutMs(timeoutMs); let copyResult = { exitCode: 1, stdout: '', stderr: '' }; - for (const bundleId of IOS_RUNNER_CONTAINER_BUNDLE_IDS) { + for (const bundleId of resolveRunnerContainerBundleIds(commandHostEnvironment().env)) { copyResult = await runXcrun( [ 'devicectl', diff --git a/packages/platform-apple/src/core/runner-host.ts b/packages/platform-apple/src/core/runner-host.ts index 6790d89a7c..c6a5534137 100644 --- a/packages/platform-apple/src/core/runner-host.ts +++ b/packages/platform-apple/src/core/runner-host.ts @@ -3,6 +3,7 @@ import { publishFileSync, acquireProcessLock, withProcessLock } from '@agent-dev import { commandDeveloperDir, + commandHostEnvironment, isCommandTimeoutError, requireExecSuccess, runCmdBackground, @@ -71,6 +72,7 @@ export const appleRunnerHost: AppleRunnerHost = { requireExecSuccess, isCommandTimeoutError, commandDeveloperDir, + hostEnvironment: commandHostEnvironment, shellQuote, emitDiagnostic, withDiagnosticTimer, diff --git a/packages/platform-apple/src/core/screenshot.ts b/packages/platform-apple/src/core/screenshot.ts index 23bc3e3627..717a09b507 100644 --- a/packages/platform-apple/src/core/screenshot.ts +++ b/packages/platform-apple/src/core/screenshot.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { copyHostFile } from '@agent-device/host-kit/host-file'; import { Deadline, retryWithPolicy } from '@agent-device/host-kit/retry'; @@ -23,7 +24,7 @@ import { } from './config.ts'; import { runAppleRunnerCommand } from './runner-client.ts'; import { - IOS_RUNNER_CONTAINER_BUNDLE_IDS, + resolveRunnerContainerBundleIds, type AppleRunnerCommandOptions, } from '../runner/index.ts'; import { prepareSimulatorStatusBarForScreenshot } from './screenshot-status-bar.ts'; @@ -297,7 +298,7 @@ async function copyRunnerScreenshotFromSimulator( lastError = cachedCopy.error; iosSimulatorRunnerContainerCache.delete(device.id); } - for (const bundleId of IOS_RUNNER_CONTAINER_BUNDLE_IDS) { + for (const bundleId of resolveRunnerContainerBundleIds(commandHostEnvironment().env)) { const containerResult = await runSimctlForDevice( device, ['get_app_container', device.id, bundleId, 'data'], diff --git a/packages/platform-apple/src/runner/__tests__/host-setup.ts b/packages/platform-apple/src/runner/__tests__/host-setup.ts index 1cc5ebb13e..d506a521fb 100644 --- a/packages/platform-apple/src/runner/__tests__/host-setup.ts +++ b/packages/platform-apple/src/runner/__tests__/host-setup.ts @@ -1,4 +1,5 @@ import { beforeEach } from 'vitest'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import { appleRunnerTestHost, installAppleRunnerTestHost, @@ -11,6 +12,10 @@ import { // retry/lock/exec semantics and stub individual capabilities per test. installAppleRunnerTestHost(await loadAppleRunnerHost()); +// Runner code reads its settings from the command host; tests run as one whose environment is the +// live process environment, so `vi.stubEnv` reaches it. +bindTestCommandHost(); + beforeEach(() => { appleRunnerTestHost.reset(); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts b/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts index 703610720f..8d7d9b3efb 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-process-launch.test.ts @@ -49,6 +49,34 @@ test("the runner is spawned detached onto its own log file, not onto this proces assert.equal(args[args.indexOf('-derivedDataPath') + 1], '/tmp/runner-derived'); }); +test('the detached runner starts in the bound host environment and directory, not the process', async () => { + const root = mkdtempForTestSync('runner-launch-host-'); + const bin = path.join(root, 'bin'); + const cwd = path.join(root, 'cwd'); + const evidence = path.join(root, 'evidence.txt'); + fs.mkdirSync(bin); + fs.mkdirSync(cwd); + fs.writeFileSync( + path.join(bin, 'xcodebuild'), + '#!/bin/sh\nprintf "%s\\n%s\\n" "$FROM_HOST" "$PWD" > "$EVIDENCE"\n', + { mode: 0o755 }, + ); + appleRunnerTestHost.update({ + runCmdBackground: appleRunnerTestHost.defaults().runCmdBackground, + hostEnvironment: () => ({ env: { PATH: bin, FROM_HOST: 'yes', EVIDENCE: evidence }, cwd }), + }); + + const launched = launchRunnerProcess({ + device: IOS_SIMULATOR, + xctestrunPath: '/tmp/runner.xctestrun', + derivedPath: '/tmp/runner-derived', + logPath: runnerLogPath(), + }); + await launched.wait; + + assert.equal(fs.readFileSync(evidence, 'utf8'), `yes\n${fs.realpathSync(cwd)}\n`); +}); + test('test-without-building resolves a scoped-set simulator in its own set', () => { mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-stale-bundles.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-stale-bundles.test.ts index 42a09daf41..7a9e2780a4 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-stale-bundles.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-stale-bundles.test.ts @@ -95,7 +95,7 @@ vi.mock('../runner-startup-transport.ts', async () => { }); import { abortAllIosRunnerSessions, ensureRunnerSession } from '../runner-session.ts'; -import { IOS_RUNNER_CONTAINER_BUNDLE_IDS } from '../runner-xctestrun.ts'; +import { resolveRunnerContainerBundleIds } from '../runner-xctestrun.ts'; import { AppError } from '@agent-device/kernel/errors'; const TEST_OWNER_START_TIME = 'fixed-test-owner-start-time'; @@ -223,7 +223,7 @@ test('stale bundle uninstalls start concurrently', async () => { // Both container bundle uninstalls must be in flight before either resolves; // against the sequential pre-fix loop this wait times out with one gate held. await vi.waitFor(() => - assert.equal(uninstallGates.length, IOS_RUNNER_CONTAINER_BUNDLE_IDS.length), + assert.equal(uninstallGates.length, resolveRunnerContainerBundleIds(process.env).length), ); for (const resolve of uninstallGates) { resolve({ exitCode: 0, stdout: '', stderr: '' }); diff --git a/packages/platform-apple/src/runner/host.ts b/packages/platform-apple/src/runner/host.ts index 37be783617..ce6af7a57f 100644 --- a/packages/platform-apple/src/runner/host.ts +++ b/packages/platform-apple/src/runner/host.ts @@ -87,6 +87,11 @@ export type AppleRunnerHost = Pick< > & Pick & Pick & { + /** + * The environment and working directory the runner reads its configuration from (derived-data + * overrides, lease directory, bundle ids, signing settings) and starts detached children with. + */ + hostEnvironment: typeof HostCommand.commandHostEnvironment; /** * The `Deadline` constructor is a class static, so the port carries the factory alone, typed * to the read side: a test host substitutes its own clock. @@ -158,6 +163,7 @@ export const runCmdBackground = delegate('runCmdBackground'); export const requireExecSuccess = delegate('requireExecSuccess'); export const isCommandTimeoutError = delegate('isCommandTimeoutError'); export const commandDeveloperDir = delegate('commandDeveloperDir'); +export const hostEnvironment = delegate('hostEnvironment'); export const shellQuote = delegate('shellQuote'); export const emitDiagnostic = delegate('emitDiagnostic'); export const withDiagnosticTimer = delegate('withDiagnosticTimer'); diff --git a/packages/platform-apple/src/runner/index.ts b/packages/platform-apple/src/runner/index.ts index f61f67599e..8639f05c84 100644 --- a/packages/platform-apple/src/runner/index.ts +++ b/packages/platform-apple/src/runner/index.ts @@ -19,4 +19,4 @@ export { type AppleRunnerProvider, } from './runner-provider.ts'; export { buildRunnerSequenceCommand, parseRunnerSequenceResult } from './runner-sequence.ts'; -export { IOS_RUNNER_CONTAINER_BUNDLE_IDS } from './runner-cache-metadata.ts'; +export { resolveRunnerContainerBundleIds } from './runner-cache-metadata.ts'; diff --git a/packages/platform-apple/src/runner/runner-adoption.ts b/packages/platform-apple/src/runner/runner-adoption.ts index 3048c2a6a7..8d7288957e 100644 --- a/packages/platform-apple/src/runner/runner-adoption.ts +++ b/packages/platform-apple/src/runner/runner-adoption.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { emitDiagnostic, isProcessAlive, parseBooleanLiteral } from './host.ts'; +import { hostEnvironment, emitDiagnostic, isProcessAlive, parseBooleanLiteral } from './host.ts'; import type { ExecResult } from '@agent-device/host-kit/command'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { isRequestCanceledError } from '@agent-device/kernel/errors'; @@ -61,7 +61,7 @@ const RUNNER_ADOPTION_EXIT_POLL_INTERVAL_MS = 1_000; // Kill switch for the runner handoff across daemon restarts: disables both // detaching healthy runners on graceful shutdown and adopting them on the next // startup, in every handoff lane (#2681). -export function isIosRunnerDetachEnabled(env: NodeJS.ProcessEnv = process.env): boolean { +export function isIosRunnerDetachEnabled(env: NodeJS.ProcessEnv = hostEnvironment().env): boolean { return parseBooleanLiteral(env.AGENT_DEVICE_IOS_RUNNER_DETACH ?? '') !== false; } diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index b15275d240..b3301914b5 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -15,6 +15,7 @@ import { emitRequestProgress, findProjectRoot, isCommandTimeoutError, + hostEnvironment, } from './host.ts'; import type { ExecBackgroundResult } from '@agent-device/host-kit/command'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -872,16 +873,17 @@ async function buildRunnerXctestrun( // teardown or a last-waiter cancellation reaches this same line again, and admission is what // refuses it. Checking at registration would be a check after the child exists (#3220). assertRunnerStartAdmitsPreparation(device.id, options.startAdmission); - const runnerBundleBuildSettings = resolveRunnerBundleBuildSettings(process.env); + const { env } = hostEnvironment(); + const runnerBundleBuildSettings = resolveRunnerBundleBuildSettings(env); const signingBuildSettings = resolveRunnerSigningBuildSettings( - process.env, + env, device.kind === 'device', device, ); const provisioningArgs = device.kind === 'device' ? ['-allowProvisioningUpdates'] : []; const performanceBuildSettings = resolveRunnerPerformanceBuildSettings(); - const archBuildSettings = resolveRunnerArchBuildSettings(process.env); - const sandboxBuildArgs = resolveRunnerSandboxBuildArgs(); + const archBuildSettings = resolveRunnerArchBuildSettings(env); + const sandboxBuildArgs = resolveRunnerSandboxBuildArgs(env); try { await runCmdStreaming( 'xcodebuild', diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts index 9f5bb6acba..4ffc608910 100644 --- a/packages/platform-apple/src/runner/runner-cache-metadata.ts +++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { isEnvTruthy, findProjectRoot, readVersion } from './host.ts'; +import { hostEnvironment, isEnvTruthy, findProjectRoot, readVersion } from './host.ts'; import { resolveRunnerBuildDestinationFamily, resolveRunnerDerivedBaseName, @@ -88,14 +88,14 @@ function normalizeBundleId(value: string | undefined): string { return value?.trim() ?? ''; } -export function resolveRunnerAppBundleId(env: NodeJS.ProcessEnv = process.env): string { +export function resolveRunnerAppBundleId(env: NodeJS.ProcessEnv = hostEnvironment().env): string { const configured = normalizeBundleId(env.AGENT_DEVICE_IOS_BUNDLE_ID) || normalizeBundleId(env.AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID); return configured || DEFAULT_IOS_RUNNER_APP_BUNDLE_ID; } -function resolveRunnerTestBundleId(env: NodeJS.ProcessEnv = process.env): string { +function resolveRunnerTestBundleId(env: NodeJS.ProcessEnv = hostEnvironment().env): string { const configured = normalizeBundleId(env.AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID); if (configured) { return configured; @@ -103,7 +103,7 @@ function resolveRunnerTestBundleId(env: NodeJS.ProcessEnv = process.env): string return `${resolveRunnerAppBundleId(env)}.uitests`; } -function resolveRunnerContainerBundleIds(env: NodeJS.ProcessEnv = process.env): string[] { +export function resolveRunnerContainerBundleIds(env: NodeJS.ProcessEnv): string[] { const appBundleId = resolveRunnerAppBundleId(env); const testBundleId = resolveRunnerTestBundleId(env); return Array.from( @@ -117,16 +117,13 @@ function resolveRunnerContainerBundleIds(env: NodeJS.ProcessEnv = process.env): ); } -export const IOS_RUNNER_CONTAINER_BUNDLE_IDS: string[] = resolveRunnerContainerBundleIds( - process.env, -); - export function resolveExpectedRunnerCacheMetadata( device: DeviceInfo, projectRoot: string = findProjectRoot(), budget?: RunnerPhaseBudget, ): RunnerXctestrunCacheMetadata { const platformName = resolveRunnerPlatformName(device); + const { env } = hostEnvironment(); return { schemaVersion: RUNNER_CACHE_SCHEMA_VERSION, packageVersion: readVersion(projectRoot), @@ -136,15 +133,15 @@ export function resolveExpectedRunnerCacheMetadata( deviceKind: device.kind, target: device.target ?? 'mobile', buildDestinationFamily: resolveRunnerBuildDestinationFamily(device), - runnerBundleBuildSettings: resolveRunnerBundleBuildSettings(process.env), + runnerBundleBuildSettings: resolveRunnerBundleBuildSettings(env), runnerSigningBuildSettings: resolveRunnerSigningBuildSettings( - process.env, + env, device.kind === 'device', device, ), runnerPerformanceBuildSettings: resolveRunnerPerformanceBuildSettings(), - runnerArchBuildSettings: resolveRunnerArchBuildSettings(process.env), - runnerSandboxBuildArgs: resolveRunnerSandboxBuildArgs(), + runnerArchBuildSettings: resolveRunnerArchBuildSettings(env), + runnerSandboxBuildArgs: resolveRunnerSandboxBuildArgs(env), }; } @@ -152,7 +149,7 @@ export function resolveRunnerDerivedPath( device: DeviceInfo, metadata: RunnerXctestrunCacheMetadata, ): string { - const override = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim(); + const override = hostEnvironment().env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim(); if (override) { return path.resolve(override); } @@ -256,7 +253,7 @@ export function resolveRunnerMaxConcurrentDestinationsFlag(device: DeviceInfo): } export function resolveRunnerSigningBuildSettings( - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = hostEnvironment().env, forDevice = false, device: Pick = { platform: 'apple' }, ): string[] { @@ -285,7 +282,9 @@ export function resolveRunnerSigningBuildSettings( return args; } -export function resolveRunnerBundleBuildSettings(env: NodeJS.ProcessEnv = process.env): string[] { +export function resolveRunnerBundleBuildSettings( + env: NodeJS.ProcessEnv = hostEnvironment().env, +): string[] { const appBundleId = resolveRunnerAppBundleId(env); const testBundleId = resolveRunnerTestBundleId(env); return [ @@ -310,7 +309,9 @@ export function resolveRunnerPerformanceBuildSettings(): string[] { * override changes the bytes on disk and must reach both the `xcodebuild` arguments and the * cache identity from this one resolver. */ -export function resolveRunnerArchBuildSettings(env: NodeJS.ProcessEnv = process.env): string[] { +export function resolveRunnerArchBuildSettings( + env: NodeJS.ProcessEnv = hostEnvironment().env, +): string[] { const archs = env.AGENT_DEVICE_XCUITEST_ARCHS?.trim(); return archs ? [`ARCHS=${archs}`] : []; } @@ -329,11 +330,10 @@ export function resolveRunnerBuildLocationSettings(derived: string): string[] { ]; } -export function resolveRunnerSandboxBuildArgs(): string[] { - return [ - ...RUNNER_SANDBOX_BUILD_ARGS, - `OTHER_SWIFT_FLAGS=${resolveRunnerSwiftFlags(process.env)}`, - ]; +export function resolveRunnerSandboxBuildArgs( + env: NodeJS.ProcessEnv = hostEnvironment().env, +): string[] { + return [...RUNNER_SANDBOX_BUILD_ARGS, `OTHER_SWIFT_FLAGS=${resolveRunnerSwiftFlags(env)}`]; } function resolveRunnerSwiftFlags(env: NodeJS.ProcessEnv): string { diff --git a/packages/platform-apple/src/runner/runner-cache-retention.ts b/packages/platform-apple/src/runner/runner-cache-retention.ts index 5db90afcf0..3d543b73bf 100644 --- a/packages/platform-apple/src/runner/runner-cache-retention.ts +++ b/packages/platform-apple/src/runner/runner-cache-retention.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { emitDiagnostic } from './host.ts'; +import { hostEnvironment, emitDiagnostic } from './host.ts'; import { acquireRunnerXctestrunCacheLock, emitRunnerXctestrunDecision, @@ -16,7 +16,9 @@ const MIN_IDLE_MS = 24 * 60 * 60_000; const CACHE_KEY_DIRECTORY = /^cache-[0-9a-f]{16}$/; /** How many runner cache keys per platform folder survive, the current one included; 0 keeps them all. */ -export function resolveRunnerCacheKeepCount(env: NodeJS.ProcessEnv = process.env): number { +export function resolveRunnerCacheKeepCount( + env: NodeJS.ProcessEnv = hostEnvironment().env, +): number { const raw = env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP?.trim(); if (!raw || !/^\d+$/.test(raw)) return DEFAULT_RUNNER_CACHE_KEEP; return Number(raw); @@ -33,7 +35,7 @@ export function resolveRunnerCacheKeepCount(env: NodeJS.ProcessEnv = process.env */ export async function evictStaleRunnerCaches( currentDerived: string, - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = hostEnvironment().env, nowMs: number = Date.now(), ): Promise { const keep = resolveRunnerCacheKeepCount(env); diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts index fbb60aba68..9a427ab1b3 100644 --- a/packages/platform-apple/src/runner/runner-cache.ts +++ b/packages/platform-apple/src/runner/runner-cache.ts @@ -8,6 +8,7 @@ import { withProcessLock, isEnvTruthy, findProjectRoot, + hostEnvironment, } from './host.ts'; import { RUNNER_CACHE_METADATA_FILE, @@ -284,12 +285,12 @@ function evaluateRunnerCacheMetadata( } function shouldCleanDerived(): boolean { - return isEnvTruthy(process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED); + return isEnvTruthy(hostEnvironment().env.AGENT_DEVICE_IOS_CLEAN_DERIVED); } export function assertSafeDerivedCleanup( derivedPath: string, - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = hostEnvironment().env, ): void { const override = env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim(); if (!override) { diff --git a/packages/platform-apple/src/runner/runner-disposal.ts b/packages/platform-apple/src/runner/runner-disposal.ts index 297bc4735f..0e426883bb 100644 --- a/packages/platform-apple/src/runner/runner-disposal.ts +++ b/packages/platform-apple/src/runner/runner-disposal.ts @@ -1,4 +1,5 @@ import { + hostEnvironment, emitDiagnostic, isProcessAlive, isProcessGroupAlive, @@ -24,7 +25,7 @@ import { } from './runner-lease.ts'; import { forgetRunnerPrepProcess, - IOS_RUNNER_CONTAINER_BUNDLE_IDS, + resolveRunnerContainerBundleIds, runnerPrepProcessChildren, runnerPrepProcessChildrenWithoutLiveOwner, } from './runner-xctestrun.ts'; @@ -289,7 +290,7 @@ async function terminateRunnerSimulatorApps(device: DeviceInfo): Promise { if (device.kind !== 'simulator') return; await Promise.allSettled( - IOS_RUNNER_CONTAINER_BUNDLE_IDS.map(async (bundleId) => { + resolveRunnerContainerBundleIds(hostEnvironment().env).map(async (bundleId) => { try { await runXcrun(buildSimctlArgsForDevice(device, ['terminate', device.id, bundleId]), { allowFailure: true, diff --git a/packages/platform-apple/src/runner/runner-lease.ts b/packages/platform-apple/src/runner/runner-lease.ts index 18f3d77742..f04f6d959f 100644 --- a/packages/platform-apple/src/runner/runner-lease.ts +++ b/packages/platform-apple/src/runner/runner-lease.ts @@ -14,6 +14,7 @@ import { classifyOwnerLiveness, leaseOwnerStateDir, shellQuote, + hostEnvironment, } from './host.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -301,7 +302,7 @@ function logicalLeaseContextMatchesDevice( function readCurrentStateDir(): string | undefined { const configured = leaseOwnerStateDir(); if (configured) return configured; - return process.env.AGENT_DEVICE_STATE_DIR?.trim() || undefined; + return hostEnvironment().env.AGENT_DEVICE_STATE_DIR?.trim() || undefined; } function buildBusyRunnerLeaseHint( @@ -470,7 +471,7 @@ function resolveRunnerLeasePath(deviceId: string): string { } function resolveRunnerLeaseRoot(): string { - const override = process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR?.trim(); + const override = hostEnvironment().env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR?.trim(); if (override) return path.resolve(override); return path.join(os.homedir(), '.agent-device', 'apple-runner', 'leases'); } diff --git a/packages/platform-apple/src/runner/runner-process-launch.ts b/packages/platform-apple/src/runner/runner-process-launch.ts index 80ce456b4e..1091769b4c 100644 --- a/packages/platform-apple/src/runner/runner-process-launch.ts +++ b/packages/platform-apple/src/runner/runner-process-launch.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { runCmdBackground } from './host.ts'; +import { hostEnvironment, runCmdBackground } from './host.ts'; import type { ExecBackgroundResult } from '@agent-device/host-kit/command'; import { resolveRunnerDestination } from './apple-runner-platform.ts'; import { resolveRunnerMaxConcurrentDestinationsFlag } from './runner-cache-metadata.ts'; @@ -60,6 +60,8 @@ export function launchRunnerProcess(input: LaunchRunnerProcessInput): LaunchedRu }); const listenerReady = createRunnerListenerReadySignal(); try { + // The runner outlives this process, so it gets its environment and directory as plain values. + const { env, cwd } = hostEnvironment(); const launched = runCmdBackground( 'xcodebuild', [ @@ -85,6 +87,8 @@ export function launchRunnerProcess(input: LaunchRunnerProcessInput): LaunchedRu { allowFailure: true, detached: true, + env, + cwd, signal: input.signal, stdio: ['ignore', outputFd, outputFd], captureOutput: false, diff --git a/packages/platform-apple/src/runner/runner-retention.ts b/packages/platform-apple/src/runner/runner-retention.ts index 1e818afef6..3e7224926d 100644 --- a/packages/platform-apple/src/runner/runner-retention.ts +++ b/packages/platform-apple/src/runner/runner-retention.ts @@ -1,6 +1,6 @@ import net from 'node:net'; import { isIosFamily } from '@agent-device/kernel/device'; -import { emitDiagnostic } from './host.ts'; +import { hostEnvironment, emitDiagnostic } from './host.ts'; import { isRunnerMainThreadOccupied, type RunnerSession, @@ -117,7 +117,7 @@ function needsListenerWatch(session: RunnerSession): boolean { return session.device.kind === 'simulator' && isIosFamily(session.device); } -function resolveRunnerIdleStopMs(env: NodeJS.ProcessEnv = process.env): number { +function resolveRunnerIdleStopMs(env: NodeJS.ProcessEnv = hostEnvironment().env): number { const raw = env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS?.trim(); if (raw) { const parsed = Number(raw); diff --git a/packages/platform-apple/src/runner/runner-session.ts b/packages/platform-apple/src/runner/runner-session.ts index 16b00c4043..22f3fb257e 100644 --- a/packages/platform-apple/src/runner/runner-session.ts +++ b/packages/platform-apple/src/runner/runner-session.ts @@ -6,6 +6,7 @@ import { emitDiagnostic, buildSimctlArgsForDevice, runXcrun, + hostEnvironment, } from './host.ts'; import { isApplePlatform, type DeviceInfo } from '@agent-device/kernel/device'; import type { RunnerLogicalLeaseContext } from '@agent-device/contracts/runner-lease-context'; @@ -18,7 +19,7 @@ import { ensureXctestrunArtifact, fenceRunnerStartAdmissionsForTeardown, finishRunnerStartAdmission, - IOS_RUNNER_CONTAINER_BUNDLE_IDS, + resolveRunnerContainerBundleIds, openRunnerStartAdmission, prepareXctestrunWithEnv, readmitRunnerStartAdmission, @@ -524,7 +525,7 @@ async function cleanupStaleSimulatorRunnerBundles(device: DeviceInfo): Promise { + resolveRunnerContainerBundleIds(hostEnvironment().env).map(async (bundleId) => { // Best-effort cleanup only; xcodebuild may still be able to install. await uninstallStaleSimulatorRunnerBundle(device, bundleId); }), diff --git a/packages/platform-apple/src/runner/runner-xctestrun.ts b/packages/platform-apple/src/runner/runner-xctestrun.ts index 3ed2714ced..4b19e8b3c9 100644 --- a/packages/platform-apple/src/runner/runner-xctestrun.ts +++ b/packages/platform-apple/src/runner/runner-xctestrun.ts @@ -27,9 +27,9 @@ export { type RunnerXctestrunCacheKind, } from './runner-cache.ts'; export { - IOS_RUNNER_CONTAINER_BUNDLE_IDS, resolveExpectedRunnerCacheMetadata, resolveRunnerAppBundleId, + resolveRunnerContainerBundleIds, resolveRunnerDerivedPath, } from './runner-cache-metadata.ts'; export { diff --git a/packages/platform-apple/src/snapshot-source/host.test.ts b/packages/platform-apple/src/snapshot-source/host.test.ts index 989e0feaef..8461b0e4c2 100644 --- a/packages/platform-apple/src/snapshot-source/host.test.ts +++ b/packages/platform-apple/src/snapshot-source/host.test.ts @@ -1,10 +1,15 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import path from 'node:path'; import { test, vi } from 'vitest'; -import { runCmdBackground } from '@agent-device/host-kit/command'; +import { runCmdBackground, withCommandHost } from '@agent-device/host-kit/command'; +import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; import { simulatorAddressFor } from '../core/simctl.ts'; import { createSnapshotSourceHost, snapshotSourceSocketPath } from './host.ts'; +bindTestCommandHost(); + vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ ...(await importOriginal()), runCmdBackground: vi.fn(), @@ -71,3 +76,39 @@ test.each([ ], ]); }); + +test('the detached bridge starts in the host directory, not the process directory', async () => { + const root = mkdtempForTestSync('snapshot-bridge-cwd-'); + const bin = path.join(root, 'bin'); + const hostCwd = path.join(root, 'host-cwd'); + const evidence = path.join(root, 'evidence.txt'); + fs.mkdirSync(bin); + fs.mkdirSync(hostCwd); + fs.writeFileSync(path.join(bin, 'xcrun'), '#!/bin/sh\nprintf "%s\\n" "$PWD" > "$EVIDENCE"\n', { + mode: 0o755, + }); + const actual = await vi.importActual( + '@agent-device/host-kit/command', + ); + vi.mocked(runCmdBackground).mockReset().mockImplementation(actual.runCmdBackground); + + await withCommandHost( + { host: { env: { PATH: bin, EVIDENCE: evidence }, cwd: hostCwd }, requestEnv: undefined }, + async () => { + createSnapshotSourceHost().start( + simulatorAddressFor({ + platform: 'apple', + id: 'simulator-1', + name: 'iPhone 17', + kind: 'simulator', + target: 'mobile', + }), + '/tmp/bridge', + '/tmp/bridge.sock', + ); + }, + ); + + await vi.waitFor(() => assert.equal(fs.existsSync(evidence), true)); + assert.equal(fs.readFileSync(evidence, 'utf8'), `${fs.realpathSync(hostCwd)}\n`); +}); diff --git a/packages/platform-apple/src/snapshot-source/host.ts b/packages/platform-apple/src/snapshot-source/host.ts index b89fac9ad8..fe006334db 100644 --- a/packages/platform-apple/src/snapshot-source/host.ts +++ b/packages/platform-apple/src/snapshot-source/host.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import net from 'node:net'; import path from 'node:path'; -import { runCmd, runCmdBackground } from '@agent-device/host-kit/command'; +import { commandHostEnvironment, runCmd, runCmdBackground } from '@agent-device/host-kit/command'; import { hostHomeDirectory } from '@agent-device/host-kit/host-file'; import { signalProcessGroupBestEffort } from '@agent-device/host-kit/process'; import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; @@ -44,6 +44,8 @@ function startSnapshotBridge( if (options.signal?.aborted) { throw snapshotSourceError('cancelled', 'abort-signal'); } + // The bridge outlives this call, so it gets its environment and directory as plain values. + const { env, cwd } = commandHostEnvironment(); const started = runCmdBackground( 'xcrun', buildSimctlArgsForAddress(simulator, [ @@ -61,6 +63,8 @@ function startSnapshotBridge( allowFailure: true, captureOutput: false, detached: true, + env, + cwd, }, ); const pid = started.child.pid ?? 0; diff --git a/packages/provision-kit/src/install-source-download.test.ts b/packages/provision-kit/src/install-source-download.test.ts index 119a18684a..c9e46506a8 100644 --- a/packages/provision-kit/src/install-source-download.test.ts +++ b/packages/provision-kit/src/install-source-download.test.ts @@ -36,6 +36,7 @@ test('download redirects revalidate destinations and strip sensitive cross-origi 'user-agent': 'agent-device-test', }, signal: new AbortController().signal, + environment: process.env, }); assert.equal(await fs.readFile(result, 'utf8'), 'apk'); assert.equal(lookup.mock.calls.length, 2); @@ -70,12 +71,14 @@ test('download sends a default user-agent that a caller header replaces', async tempDir: tempRoot, url: 'https://example.com/default.apk', signal: new AbortController().signal, + environment: process.env, }); await downloadInstallSource({ tempDir: tempRoot, url: 'https://example.com/custom.apk', headers: { 'User-Agent': 'caller-agent' }, signal: new AbortController().signal, + environment: process.env, }); const defaultHeaders = requestMock.mock.calls[0]![0].headers; const customHeaders = requestMock.mock.calls[1]![0].headers; @@ -108,6 +111,7 @@ test('download errors do not disclose URL credentials or query values', async () url: 'https://private-user:private-pass@example.com/app?token=private-query', headers: { authorization: 'private-header' }, signal: new AbortController().signal, + environment: process.env, }).catch((error: unknown) => error); assert.match((error as Error).message, /credentials are not allowed/i); const serialized = JSON.stringify(error); @@ -142,6 +146,7 @@ test('download identifies the daemon as the requester and preserves the network tempDir: tempRoot, url: 'https://example.com/app.apk', signal: new AbortController().signal, + environment: process.env, }).catch((error: unknown) => error); assert.ok(error instanceof Error); @@ -178,6 +183,7 @@ test('download omits network error codes that are not identifiers', async () => tempDir: tempRoot, url: 'https://example.com/app.apk', signal: new AbortController().signal, + environment: process.env, }).catch((error: unknown) => error); assert.ok(error instanceof Error); @@ -210,6 +216,7 @@ test('download rejects non-identity content encoding and malformed lengths', asy tempDir: tempRoot, url: 'https://example.com/app', signal: new AbortController().signal, + environment: process.env, }), /content encoding/i, ); @@ -221,6 +228,7 @@ test('download rejects non-identity content encoding and malformed lengths', asy tempDir: tempRoot, url: 'https://example.com/app', signal: new AbortController().signal, + environment: process.env, }), /content-length/i, ); @@ -252,6 +260,7 @@ test('download and the trusted-source check refuse an unparsable source alike', tempDir: '/unused', url: source, signal: new AbortController().signal, + environment: process.env, }), expected, ); diff --git a/packages/provision-kit/src/install-source-download.ts b/packages/provision-kit/src/install-source-download.ts index 9fd53df554..1a035fbc04 100644 --- a/packages/provision-kit/src/install-source-download.ts +++ b/packages/provision-kit/src/install-source-download.ts @@ -33,11 +33,12 @@ export async function downloadInstallSource(params: { url: string; headers?: Record; signal: AbortSignal; + environment: NodeJS.ProcessEnv; }): Promise { let currentUrl = parseSourceUrl(params.url); let headers = withDefaultUserAgent(sanitizeHeaders(params.headers)); for (let redirectCount = 0; ; redirectCount += 1) { - const response = await requestHop(currentUrl, headers, params.signal); + const response = await requestHop(currentUrl, headers, params.signal, params.environment); try { const redirected = readRedirect(response, currentUrl, redirectCount); if (redirected) { @@ -57,6 +58,7 @@ async function requestHop( url: URL, headers: Record, signal: AbortSignal, + environment: NodeJS.ProcessEnv, ): Promise { const approved = await approveDownloadSourceUrl(url, signal); try { @@ -66,6 +68,7 @@ async function requestHop( family: approved.family, headers: { ...headers, 'accept-encoding': 'identity' }, signal, + environment, }); } catch (error) { if (error instanceof AppError) throw error; diff --git a/packages/provision-kit/src/install-source-network-transport.test.ts b/packages/provision-kit/src/install-source-network-transport.test.ts index e3616b7e11..aadd959deb 100644 --- a/packages/provision-kit/src/install-source-network-transport.test.ts +++ b/packages/provision-kit/src/install-source-network-transport.test.ts @@ -57,6 +57,7 @@ test('direct requests connect to the approved address through the real lookup', family: 4, headers: {}, signal: AbortSignal.timeout(5_000), + environment: process.env, }); try { assert.equal(response.statusCode, 200); diff --git a/packages/provision-kit/src/install-source-network-transport.ts b/packages/provision-kit/src/install-source-network-transport.ts index 39491de4b6..b3efb6f7df 100644 --- a/packages/provision-kit/src/install-source-network-transport.ts +++ b/packages/provision-kit/src/install-source-network-transport.ts @@ -14,8 +14,10 @@ export async function requestApprovedUrl(params: { family: 4 | 6; headers: Record; signal: AbortSignal; + /** The host environment whose proxy variables apply to the request. */ + environment: NodeJS.ProcessEnv; }): Promise { - const proxy = resolveProxyForUrl(params.url); + const proxy = resolveProxyForUrl(params.url, params.environment); const dispatcher = proxy ? proxyDispatcher(proxy, params.url) : directDispatcher(params.approvedAddress, params.family); @@ -43,10 +45,7 @@ export async function requestApprovedUrl(params: { } } -export function resolveProxyForUrl( - url: URL, - environment: NodeJS.ProcessEnv = process.env, -): string | undefined { +export function resolveProxyForUrl(url: URL, environment: NodeJS.ProcessEnv): string | undefined { const noProxy = environment.no_proxy ?? environment.NO_PROXY; if (matchesNoProxy(url, noProxy)) return undefined; const httpProxy = environment.http_proxy ?? environment.HTTP_PROXY; diff --git a/packages/provision-kit/src/install-source.ts b/packages/provision-kit/src/install-source.ts index 7435a1466f..136e68e94a 100644 --- a/packages/provision-kit/src/install-source.ts +++ b/packages/provision-kit/src/install-source.ts @@ -27,6 +27,8 @@ export type MaterializeInstallableOptions = { ) => boolean; installableLabel: string; signal?: AbortSignal; + /** The host environment whose proxy settings apply to a network download. */ + environment: NodeJS.ProcessEnv; }; export type MaterializedInstallable = { @@ -50,7 +52,10 @@ export async function materializeInstallablePath( ): Promise { const cleanupTasks: Array<() => Promise> = []; try { - const localSource = await materializeLocalSource(options.source, { signal: options.signal }); + const localSource = await materializeLocalSource(options.source, { + signal: options.signal, + environment: options.environment, + }); cleanupTasks.push(localSource.cleanup); const resolved = await resolveInstallableCandidate(localSource.localPath, { archivePath: undefined, @@ -79,17 +84,17 @@ export async function materializeInstallablePath( } } -function expandSourcePath(inputPath: string): string { - return expandUserHomePath(inputPath); +function expandSourcePath(inputPath: string, env: NodeJS.ProcessEnv): string { + return expandUserHomePath(inputPath, { env }); } async function materializeLocalSource( source: LocalInstallSource, - options?: { signal?: AbortSignal }, + options: { signal?: AbortSignal; environment: NodeJS.ProcessEnv }, ): Promise { if (source.kind === 'path') { return { - localPath: expandSourcePath(source.path), + localPath: expandSourcePath(source.path, options.environment), cleanup: async () => {}, }; } @@ -112,17 +117,23 @@ async function materializeLocalSource( async function downloadToTempFile( tempDir: string, url: string, - headers?: Record, - options?: { signal?: AbortSignal }, + headers: Record | undefined, + options: { signal?: AbortSignal; environment: NodeJS.ProcessEnv }, ): Promise { - const requestSignal = options?.signal; + const requestSignal = options.signal; if (requestSignal?.aborted) { throw createRequestCanceledError(); } const timeoutSignal = AbortSignal.timeout(DEFAULT_SOURCE_DOWNLOAD_TIMEOUT_MS); const signal = requestSignal ? AbortSignal.any([requestSignal, timeoutSignal]) : timeoutSignal; try { - return await downloadInstallSource({ tempDir, url, headers, signal }); + return await downloadInstallSource({ + tempDir, + url, + headers, + signal, + environment: options.environment, + }); } catch (error) { throw classifyDownloadError( error, diff --git a/packages/react-native/src/companion/client-companion-tunnel.ts b/packages/react-native/src/companion/client-companion-tunnel.ts index 8506d79cb5..8486cb010e 100644 --- a/packages/react-native/src/companion/client-companion-tunnel.ts +++ b/packages/react-native/src/companion/client-companion-tunnel.ts @@ -65,7 +65,9 @@ export type EnsureCompanionTunnelOptions = { profileKey?: string; consumerKey?: string; stateDir?: string; - env?: NodeJS.ProcessEnv; + /** The environment and working directory the detached companion process starts with. */ + env: NodeJS.ProcessEnv; + cwd: string; }; export type EnsureCompanionTunnelResult = { @@ -401,7 +403,8 @@ function spawnCompanionProcess( let pid = 0; try { pid = runCmdDetached(process.execPath, [...execArgs, modulePath, options.definition.runArg], { - env: buildCompanionEnv(options, options.env ?? process.env), + cwd: options.cwd, + env: buildCompanionEnv(options, options.env), stdio: ['ignore', logFd, logFd], }); } finally { diff --git a/packages/react-native/src/companion/client-metro-companion.test.ts b/packages/react-native/src/companion/client-metro-companion.test.ts index 2e2a17b988..e7cd226c88 100644 --- a/packages/react-native/src/companion/client-metro-companion.test.ts +++ b/packages/react-native/src/companion/client-metro-companion.test.ts @@ -96,6 +96,8 @@ test('companion ownership is profile-scoped and consumer-counted', async () => { vi.mocked(stopProcessAndConfirmExit).mockResolvedValue(STOPPED); const stagingFirst = await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -106,6 +108,8 @@ test('companion ownership is profile-scoped and consumer-counted', async () => { consumerKey: 'session-a', }); const stagingSecond = await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -116,6 +120,8 @@ test('companion ownership is profile-scoped and consumer-counted', async () => { consumerKey: 'session-b', }); const prod = await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -194,6 +200,8 @@ test('launchUrl changes force a companion respawn for the same profile', async ( vi.mocked(stopProcessAndConfirmExit).mockResolvedValue(STOPPED); const first = await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -204,6 +212,8 @@ test('launchUrl changes force a companion respawn for the same profile', async ( consumerKey: 'session-a', }); const second = await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -239,6 +249,7 @@ test('spawned companion uses neutral env names', async () => { ); await ensureMetroCompanion({ + cwd: process.cwd(), projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -280,6 +291,8 @@ test('state sentinel exists before spawning companion worker', async () => { ); const spawned = await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -436,6 +449,8 @@ test('a companion whose stop was refused keeps its state and is not replaced by vi.mocked(stopProcessAndConfirmExit).mockResolvedValue(RETAINED_ON_TERM); const failure = (await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -479,6 +494,8 @@ test('a half-spawned companion whose stop cannot be confirmed keeps its record', }) as typeof fs.writeFileSync); const failure = (await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -496,6 +513,8 @@ test('a half-spawned companion whose stop cannot be confirmed keeps its record', // second companion beside the one that refused to die. vi.mocked(stopProcessAndConfirmExit).mockClear(); const retry = (await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', @@ -539,6 +558,8 @@ test('legacy state without bridge scope is stopped before respawn', async () => vi.mocked(stopProcessAndConfirmExit).mockResolvedValue(STOPPED); const spawned = await ensureMetroCompanion({ + cwd: process.cwd(), + env: process.env, projectRoot, serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', diff --git a/packages/react-native/src/companion/companion-tunnel.ts b/packages/react-native/src/companion/companion-tunnel.ts index ec189ec57d..92e9193a9a 100644 --- a/packages/react-native/src/companion/companion-tunnel.ts +++ b/packages/react-native/src/companion/companion-tunnel.ts @@ -1,7 +1,11 @@ +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { MissingCompanionEnvError } from './client-companion-tunnel-contract.ts'; import { runCompanionTunnelProcessFromEnv } from './client-companion-tunnel-worker.ts'; -void runCompanionTunnelProcessFromEnv(process.argv.slice(2), process.env).catch((error) => { +void runCompanionTunnelProcessFromEnv( + process.argv.slice(2), + captureHostEnvironment(process).env, +).catch((error) => { if (error instanceof MissingCompanionEnvError) { console.error(error.message); process.exitCode = 1; diff --git a/packages/react-native/src/index-metro.test.ts b/packages/react-native/src/index-metro.test.ts index 724731a967..707ad64a52 100644 --- a/packages/react-native/src/index-metro.test.ts +++ b/packages/react-native/src/index-metro.test.ts @@ -13,6 +13,7 @@ import { writeFakeMetroNpx, findFreeMetroPort, stopTestMetroProcess, + createTestHostEnvironment, } from '@agent-device/test-kit'; const TEST_TOKEN = 'agent-device-proxy-test-token'; @@ -48,18 +49,21 @@ for (const { configFileName, commandName } of [ let pid = 0; try { - const result = await prepareMetroRuntime({ - projectRoot, - publicBaseUrl: `http://127.0.0.1:${metroPort}`, - metroPort, - reuseExisting: false, - installDependenciesIfNeeded: false, - env: { - ...process.env, - AGENT_DEVICE_TEST_NPX_ARGS_FILE: argsFile, - PATH: `${binDir}:${process.env.PATH || ''}`, + const result = await prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: `http://127.0.0.1:${metroPort}`, + metroPort, + reuseExisting: false, + installDependenciesIfNeeded: false, + env: { + ...process.env, + AGENT_DEVICE_TEST_NPX_ARGS_FILE: argsFile, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, }, - }); + createTestHostEnvironment(), + ); pid = result.pid; assert.equal(result.kind, 'repack'); @@ -103,18 +107,21 @@ test('prepareMetroRuntime maps kind=expo to the virtual-metro-entry bundle URL', let pid = 0; try { - const result = await prepareMetroRuntime({ - projectRoot, - kind: 'expo', - publicBaseUrl: `http://127.0.0.1:${metroPort}`, - metroPort, - reuseExisting: false, - installDependenciesIfNeeded: false, - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ''}`, + const result = await prepareMetroRuntime( + { + projectRoot, + kind: 'expo', + publicBaseUrl: `http://127.0.0.1:${metroPort}`, + metroPort, + reuseExisting: false, + installDependenciesIfNeeded: false, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, }, - }); + createTestHostEnvironment(), + ); pid = result.pid; assert.equal(result.kind, 'expo'); @@ -153,18 +160,21 @@ test('prepareMetroRuntime keeps index.bundle for non-expo kinds', async () => { let pid = 0; try { - const result = await prepareMetroRuntime({ - projectRoot, - kind: 'react-native', - publicBaseUrl: `http://127.0.0.1:${metroPort}`, - metroPort, - reuseExisting: false, - installDependenciesIfNeeded: false, - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ''}`, + const result = await prepareMetroRuntime( + { + projectRoot, + kind: 'react-native', + publicBaseUrl: `http://127.0.0.1:${metroPort}`, + metroPort, + reuseExisting: false, + installDependenciesIfNeeded: false, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, }, - }); + createTestHostEnvironment(), + ); pid = result.pid; assert.equal( @@ -204,17 +214,20 @@ test('prepareMetroRuntime detects the package manager from an ancestor lockfile let pid = 0; try { - const result = await prepareMetroRuntime({ - projectRoot, - publicBaseUrl: `http://127.0.0.1:${metroPort}`, - metroPort, - reuseExisting: false, - installDependenciesIfNeeded: true, - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ''}`, + const result = await prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: `http://127.0.0.1:${metroPort}`, + metroPort, + reuseExisting: false, + installDependenciesIfNeeded: true, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, }, - }); + createTestHostEnvironment(), + ); pid = result.pid; assert.equal(result.packageManager, 'yarn'); @@ -246,15 +259,18 @@ test('prepareMetroRuntime install failure hints at --no-install-deps and the det await assert.rejects( () => - prepareMetroRuntime({ - projectRoot, - publicBaseUrl: 'http://127.0.0.1:9', - installDependenciesIfNeeded: true, - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ''}`, + prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: 'http://127.0.0.1:9', + installDependenciesIfNeeded: true, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, }, - }), + createTestHostEnvironment(), + ), (error) => { assert.ok(error instanceof AppError); const hint = error.details?.hint; @@ -288,15 +304,18 @@ test('prepareMetroRuntime detects bun from the text bun.lock lockfile', async () await assert.rejects( () => - prepareMetroRuntime({ - projectRoot, - publicBaseUrl: 'http://127.0.0.1:9', - installDependenciesIfNeeded: true, - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ''}`, + prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: 'http://127.0.0.1:9', + installDependenciesIfNeeded: true, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, }, - }), + createTestHostEnvironment(), + ), (error) => { assert.ok(error instanceof AppError); assert.equal(error.details?.packageManager, 'bun'); @@ -331,15 +350,18 @@ test('prepareMetroRuntime lockfile walk-up stops at the repo root instead of ado await assert.rejects( () => - prepareMetroRuntime({ - projectRoot, - publicBaseUrl: 'http://127.0.0.1:9', - installDependenciesIfNeeded: true, - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ''}`, + prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: 'http://127.0.0.1:9', + installDependenciesIfNeeded: true, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, }, - }), + createTestHostEnvironment(), + ), (error) => { assert.ok(error instanceof AppError); assert.equal(error.details?.packageManager, 'npm'); @@ -353,11 +375,14 @@ test('prepareMetroRuntime lockfile walk-up stops at the repo root instead of ado test('prepareMetroRuntime rejects incomplete proxy configuration', async () => { await assert.rejects( () => - prepareMetroRuntime({ - publicBaseUrl: 'https://sandbox.example.test', - proxyBaseUrl: 'https://proxy.example.test', - env: {}, - }), + prepareMetroRuntime( + { + publicBaseUrl: 'https://sandbox.example.test', + proxyBaseUrl: 'https://proxy.example.test', + env: {}, + }, + createTestHostEnvironment(), + ), (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && @@ -366,10 +391,13 @@ test('prepareMetroRuntime rejects incomplete proxy configuration', async () => { await assert.rejects( () => - prepareMetroRuntime({ - publicBaseUrl: 'https://sandbox.example.test', - env: { AGENT_DEVICE_METRO_BEARER_TOKEN: TEST_TOKEN }, - }), + prepareMetroRuntime( + { + publicBaseUrl: 'https://sandbox.example.test', + env: { AGENT_DEVICE_METRO_BEARER_TOKEN: TEST_TOKEN }, + }, + createTestHostEnvironment(), + ), (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && @@ -378,12 +406,15 @@ test('prepareMetroRuntime rejects incomplete proxy configuration', async () => { await assert.rejects( () => - prepareMetroRuntime({ - publicBaseUrl: 'https://sandbox.example.test', - proxyBaseUrl: 'https://proxy.example.test', - proxyBearerToken: TEST_TOKEN, - env: {}, - }), + prepareMetroRuntime( + { + publicBaseUrl: 'https://sandbox.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: TEST_TOKEN, + env: {}, + }, + createTestHostEnvironment(), + ), (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && @@ -394,11 +425,14 @@ test('prepareMetroRuntime rejects incomplete proxy configuration', async () => { test('prepareMetroRuntime falls back to daemon auth token for proxy auth', async () => { await assert.rejects( () => - prepareMetroRuntime({ - publicBaseUrl: 'https://sandbox.example.test', - proxyBaseUrl: 'https://proxy.example.test', - env: { AGENT_DEVICE_DAEMON_AUTH_TOKEN: TEST_TOKEN }, - }), + prepareMetroRuntime( + { + publicBaseUrl: 'https://sandbox.example.test', + proxyBaseUrl: 'https://proxy.example.test', + env: { AGENT_DEVICE_DAEMON_AUTH_TOKEN: TEST_TOKEN }, + }, + createTestHostEnvironment(), + ), (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && @@ -409,11 +443,14 @@ test('prepareMetroRuntime falls back to daemon auth token for proxy auth', async test('prepareMetroRuntime honors metro bearer token env for proxy auth', async () => { await assert.rejects( () => - prepareMetroRuntime({ - publicBaseUrl: 'https://sandbox.example.test', - proxyBaseUrl: 'https://proxy.example.test', - env: { AGENT_DEVICE_METRO_BEARER_TOKEN: TEST_TOKEN }, - }), + prepareMetroRuntime( + { + publicBaseUrl: 'https://sandbox.example.test', + proxyBaseUrl: 'https://proxy.example.test', + env: { AGENT_DEVICE_METRO_BEARER_TOKEN: TEST_TOKEN }, + }, + createTestHostEnvironment(), + ), (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index c3608a4ed8..747ebba33b 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -1,5 +1,6 @@ import type { MetroSessionOptions, MetroSessionOpenInput } from './metro/metro-session-hints.ts'; import type { SessionRuntimeHints } from '@agent-device/kernel/contracts'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { resolveRuntimeTransportHints } from '@agent-device/host-kit/runtime-transport-hints'; import type { PrepareMetroRuntimeOptions, ReloadMetroOptions } from './metro/client-metro.ts'; import type { PrepareMetroRuntimeResult, ReloadMetroResult } from '@agent-device/contracts/remote'; @@ -34,10 +35,11 @@ export function resolveRuntimeTransport( } export async function prepareMetroRuntime( - input: PrepareMetroRuntimeOptions = {}, + input: PrepareMetroRuntimeOptions, + host: HostEnvironment, ): Promise { const runtime = await import('./metro/client-metro.ts'); - return await runtime.prepareMetroRuntime(input); + return await runtime.prepareMetroRuntime(input, host); } export async function reloadMetro(input: ReloadMetroOptions = {}): Promise { diff --git a/packages/react-native/src/metro/client-metro-auto-companion.test.ts b/packages/react-native/src/metro/client-metro-auto-companion.test.ts index 59b1a101aa..33bbb9aa84 100644 --- a/packages/react-native/src/metro/client-metro-auto-companion.test.ts +++ b/packages/react-native/src/metro/client-metro-auto-companion.test.ts @@ -2,7 +2,7 @@ import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { AppError } from '@agent-device/kernel/errors'; vi.mock('../companion/client-metro-companion.ts', () => ({ @@ -98,15 +98,18 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee vi.stubGlobal('fetch', fetchMock); try { - const result = await prepareMetroRuntime({ - projectRoot, - proxyBaseUrl: 'https://proxy.example.test', - proxyBearerToken: 'shared-token', - bridgeScope: TEST_BRIDGE_SCOPE, - metroPort: 8081, - reuseExisting: true, - installDependenciesIfNeeded: false, - }); + const result = await prepareMetroRuntime( + { + projectRoot, + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + }, + createTestHostEnvironment(), + ); assert.equal(result.started, false); assert.equal(result.reused, true); @@ -131,7 +134,8 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee launchUrl: undefined, profileKey: undefined, consumerKey: undefined, - env: process.env, + env: { ...process.env }, + cwd: process.cwd(), }); assert.equal(fetchMock.mock.calls.length, 3); assert.equal(fetchMock.mock.calls[1]?.[0], 'https://proxy.example.test/api/metro/bridge'); @@ -208,15 +212,18 @@ test('prepareMetroRuntime rejects bridged descriptors without iOS bundle URLs', try { await assert.rejects( () => - prepareMetroRuntime({ - projectRoot, - proxyBaseUrl: 'https://proxy.example.test', - proxyBearerToken: 'shared-token', - bridgeScope: TEST_BRIDGE_SCOPE, - metroPort: 8081, - reuseExisting: true, - installDependenciesIfNeeded: false, - }), + prepareMetroRuntime( + { + projectRoot, + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + }, + createTestHostEnvironment(), + ), /bridge descriptor is missing ios_runtime\.metro_bundle_url/, ); assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0); @@ -268,17 +275,20 @@ test('prepareMetroRuntime preserves the initial bridge error and companion stop try { await assert.rejects( () => - prepareMetroRuntime({ - projectRoot, - publicBaseUrl: 'https://public.example.test', - proxyBaseUrl: 'https://proxy.example.test', - proxyBearerToken: 'shared-token', - bridgeScope: TEST_BRIDGE_SCOPE, - metroPort: 8081, - reuseExisting: true, - installDependenciesIfNeeded: false, - probeTimeoutMs: 10, - }), + prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: 'https://public.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + probeTimeoutMs: 10, + }, + createTestHostEnvironment(), + ), (error: unknown) => { assert(error instanceof Error); assert.match(error.message, /bridgeError=companion startup failed/); @@ -331,17 +341,20 @@ test('prepareMetroRuntime fails fast when initial bridge failure is non-retryabl try { await assert.rejects( () => - prepareMetroRuntime({ - projectRoot, - publicBaseUrl: 'https://public.example.test', - proxyBaseUrl: 'https://proxy.example.test', - proxyBearerToken: 'shared-token', - bridgeScope: TEST_BRIDGE_SCOPE, - metroPort: 8081, - reuseExisting: true, - installDependenciesIfNeeded: false, - probeTimeoutMs: 10, - }), + prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: 'https://public.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + probeTimeoutMs: 10, + }, + createTestHostEnvironment(), + ), /\/api\/metro\/bridge failed \(401\)/, ); assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0); @@ -394,17 +407,20 @@ test('prepareMetroRuntime fails fast on non-retryable bridge errors after compan try { let settled: unknown = 'pending'; - const preparePromise = prepareMetroRuntime({ - projectRoot, - publicBaseUrl: 'https://public.example.test', - proxyBaseUrl: 'https://proxy.example.test', - proxyBearerToken: 'shared-token', - bridgeScope: TEST_BRIDGE_SCOPE, - metroPort: 8081, - reuseExisting: true, - installDependenciesIfNeeded: false, - probeTimeoutMs: 10, - }); + const preparePromise = prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: 'https://public.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + probeTimeoutMs: 10, + }, + createTestHostEnvironment(), + ); void preparePromise.then( () => { settled = 'resolved'; @@ -504,17 +520,20 @@ test('prepareMetroRuntime retries malformed retryable bridge responses after com vi.useFakeTimers(); try { - const preparePromise = prepareMetroRuntime({ - projectRoot, - publicBaseUrl: 'https://public.example.test', - proxyBaseUrl: 'https://proxy.example.test', - proxyBearerToken: 'shared-token', - bridgeScope: TEST_BRIDGE_SCOPE, - metroPort: 8081, - reuseExisting: true, - installDependenciesIfNeeded: false, - probeTimeoutMs: 10, - }); + const preparePromise = prepareMetroRuntime( + { + projectRoot, + publicBaseUrl: 'https://public.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + probeTimeoutMs: 10, + }, + createTestHostEnvironment(), + ); await vi.advanceTimersByTimeAsync(1_000); const result = await preparePromise; diff --git a/packages/react-native/src/metro/client-metro-startup-cleanup.test.ts b/packages/react-native/src/metro/client-metro-startup-cleanup.test.ts index fe1e921666..990ffa42e4 100644 --- a/packages/react-native/src/metro/client-metro-startup-cleanup.test.ts +++ b/packages/react-native/src/metro/client-metro-startup-cleanup.test.ts @@ -2,7 +2,7 @@ import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import type { AppError } from '@agent-device/kernel/errors'; vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ @@ -80,7 +80,10 @@ async function runMetroPrepareThroughTimeout(projectRoot: string): Promise { - const settings = resolveMetroPrepareSettings(input); + const settings = resolveMetroPrepareSettings(input, host); const dependencyInstall = settings.installProjectDeps ? installDependenciesIfNeeded(settings.projectRoot, settings.env) : { installed: false as const }; diff --git a/packages/react-native/src/metro/metro-session-hints.ts b/packages/react-native/src/metro/metro-session-hints.ts index ae3c13c047..58e4cb1095 100644 --- a/packages/react-native/src/metro/metro-session-hints.ts +++ b/packages/react-native/src/metro/metro-session-hints.ts @@ -3,6 +3,7 @@ import type { SessionRuntimeHints } from '@agent-device/kernel/contracts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import fs from 'node:fs'; import path from 'node:path'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { safeSessionName } from '@agent-device/host-kit/session-paths'; import { resolveMetroServerUrl, type MetroReloadTargetInput } from './metro-reload-endpoints.ts'; @@ -14,12 +15,19 @@ export type MetroSessionHints = { export type MetroSessionScope = { stateDir: string; session: string }; -export type MetroSessionOptions = { stateDir?: string; session: string }; +export type MetroSessionOptions = { + stateDir?: string; + session: string; + /** The host whose state directory and working directory the session binding is read from. */ + host: HostEnvironment; +}; function resolveMetroSessionScope(options: MetroSessionOptions): MetroSessionScope { + const { host } = options; return { - stateDir: resolveDaemonPaths(options.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR, { - cwd: process.cwd(), + stateDir: resolveDaemonPaths(options.stateDir ?? host.env.AGENT_DEVICE_STATE_DIR, { + env: host.env, + cwd: host.cwd, }).baseDir, session: options.session, }; @@ -99,16 +107,24 @@ export function resolveSessionMetroServerUrl( (input.metroHost !== undefined && input.metroPort !== undefined); return resolveMetroServerUrl({ ...input, - runtime: (complete ? undefined : readMetroSessionRuntime(scope)) ?? input.runtime, + runtime: (complete ? undefined : readScopedMetroSessionRuntime(scope)) ?? input.runtime, }); } +function readScopedMetroSessionRuntime(scope: MetroSessionScope): SessionRuntimeHints | undefined { + try { + const binding = readMetroSessionHints(scope); + return binding ? { bundleUrl: binding.controlBaseUrl } : undefined; + } catch { + return undefined; + } +} + export function readMetroSessionRuntime( options: MetroSessionOptions, ): SessionRuntimeHints | undefined { try { - const binding = readMetroSessionHints(resolveMetroSessionScope(options)); - return binding ? { bundleUrl: binding.controlBaseUrl } : undefined; + return readScopedMetroSessionRuntime(resolveMetroSessionScope(options)); } catch { return undefined; } diff --git a/scripts/clean-daemon.ts b/scripts/clean-daemon.ts index 7343aafedf..444ab02d9d 100644 --- a/scripts/clean-daemon.ts +++ b/scripts/clean-daemon.ts @@ -2,6 +2,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; +import { withCommandHost } from '@agent-device/host-kit/command'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { readRegisteredDaemonIdentity } from '@agent-device/daemon-contracts/daemon-registration'; import { @@ -39,7 +41,10 @@ if (retirement.status === 'retained') { if (retirement.status === 'retired') { const { cleanupRunnerLeasesForOwner } = await import('@agent-device/platform-apple/runner/operations'); - await cleanupRunnerLeasesForOwner(retirement.termination.identity); + await withCommandHost( + { host: captureHostEnvironment(process), requestEnv: undefined }, + async () => await cleanupRunnerLeasesForOwner(retirement.termination.identity), + ); } if (process.argv.includes(PRUNE_DEV_FLAG)) { diff --git a/scripts/write-xcuitest-cache-metadata.ts b/scripts/write-xcuitest-cache-metadata.ts index f4b8e8c4dd..d3d68c33e8 100644 --- a/scripts/write-xcuitest-cache-metadata.ts +++ b/scripts/write-xcuitest-cache-metadata.ts @@ -1,6 +1,8 @@ #!/usr/bin/env node import path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { withCommandHost } from '@agent-device/host-kit/command'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import type { RunnerXctestrunCacheMetadata } from '@agent-device/platform-apple/runner/operations'; import { findRunnerXctestrun, @@ -70,7 +72,11 @@ async function writeXcuitestCacheMetadata( if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { - await writeXcuitestCacheMetadata(process.argv.slice(2), process.cwd()); + const host = captureHostEnvironment(process); + await withCommandHost( + { host, requestEnv: undefined }, + async () => await writeXcuitestCacheMetadata(process.argv.slice(2), host.cwd), + ); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); diff --git a/scripts/xcuitest-build-settings.ts b/scripts/xcuitest-build-settings.ts index 9ab9f596db..4d6782083a 100644 --- a/scripts/xcuitest-build-settings.ts +++ b/scripts/xcuitest-build-settings.ts @@ -28,7 +28,7 @@ function resolveXcuitestBuildSettings( return [ ...resolveRunnerPerformanceBuildSettings(), ...resolveRunnerArchBuildSettings(env), - ...resolveRunnerSandboxBuildArgs(), + ...resolveRunnerSandboxBuildArgs(env), ...resolveRunnerBundleBuildSettings(env), ...resolveRunnerSigningBuildSettings(env, device.kind === 'device', device), ]; diff --git a/src/__tests__/agent-device-client-metro-host.test.ts b/src/__tests__/agent-device-client-metro-host.test.ts new file mode 100644 index 0000000000..2163e8adae --- /dev/null +++ b/src/__tests__/agent-device-client-metro-host.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; +import { createTransport } from './client-transport-fixture.ts'; + +test('closing a session drops its dev-server binding from the client host state dir', async () => { + const stateDir = mkdtempForTestSync('agent-device-client-metro-host-'); + const hintsPath = path.join(stateDir, 'metro-sessions', 'qa.json'); + fs.mkdirSync(path.dirname(hintsPath), { recursive: true }); + fs.writeFileSync(hintsPath, JSON.stringify({ controlBaseUrl: 'http://127.0.0.1:8081' })); + const setup = createTransport(async () => ({ ok: true, data: { session: 'qa' } })); + const client = createAgentDeviceClient( + { ...setup.config, stateDir: undefined }, + { + transport: setup.transport, + host: { env: { AGENT_DEVICE_STATE_DIR: stateDir }, cwd: stateDir }, + }, + ); + + await client.sessions.close(); + + assert.equal(fs.existsSync(hintsPath), false); +}); diff --git a/src/__tests__/install-source.test.ts b/src/__tests__/install-source.test.ts index 182f160ad5..0dae54f920 100644 --- a/src/__tests__/install-source.test.ts +++ b/src/__tests__/install-source.test.ts @@ -25,6 +25,7 @@ import { } from '@agent-device/platform-apple/tool-provider'; import { prepareIosInstallArtifact } from '@agent-device/platform-apple/install-artifact'; import { AppError } from '@agent-device/kernel/errors'; +import '../platform-runtime-android-adb-host.ts'; import { ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE } from './install-source.coverage.ts'; import { mkdtempForTest } from '@agent-device/test-kit'; import * as networkTransport from '@agent-device/provision-kit/install-source-network-transport'; @@ -140,6 +141,7 @@ test( source: { kind: 'path', path: archivePath }, isInstallablePath: (candidatePath, stat) => stat.isFile() && candidatePath.endsWith('.apk'), installableLabel: 'Android installable (.apk or .aab)', + environment: process.env, }); try { @@ -170,6 +172,7 @@ test('materializeInstallablePath extracts tar.gz archives', async () => { source: { kind: 'path', path: archivePath }, isInstallablePath: (candidatePath, stat) => stat.isFile() && candidatePath.endsWith('.apk'), installableLabel: 'Android installable (.apk or .aab)', + environment: process.env, }); try { @@ -198,10 +201,13 @@ test('prepareIosInstallArtifact extracts a URL archive from any public host', as async () => { await withIosBundleInfo('com.example.signedurl', 'Signed URL', async () => { await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { - const result = await prepareIosInstallArtifact({ - kind: 'url', - url: 'https://artifacts.example.com/signed/build.zip?sig=abc', - }); + const result = await prepareIosInstallArtifact( + { + kind: 'url', + url: 'https://artifacts.example.com/signed/build.zip?sig=abc', + }, + { environment: process.env }, + ); try { assert.equal(path.basename(result.installablePath), 'Demo.app'); @@ -231,10 +237,13 @@ test(ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE.testName, async () => { await withMockedInstallSourceFetch( apkBytes, async () => { - const result = await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://example.com/app.apk', - }); + const result = await prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'https://example.com/app.apk', + }, + { environment: process.env }, + ); try { assert.equal(result.packageName, 'io.example.directurl'); @@ -252,10 +261,13 @@ test(ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE.testName, async () => { test('prepareAndroidInstallArtifact rejects private direct APK URL sources', async () => { await assert.rejects( async () => - await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'http://127.0.0.1/app.apk', - }), + await prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'http://127.0.0.1/app.apk', + }, + { environment: process.env }, + ), /not allowed|private or loopback/i, ); }); @@ -278,10 +290,13 @@ test('prepareAndroidInstallArtifact accepts direct AAB URL sources', async () => await withMockedInstallSourceFetch( aabBytes, async () => { - const result = await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://example.com/app.aab', - }); + const result = await prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'https://example.com/app.aab', + }, + { environment: process.env }, + ); try { assert.equal(result.packageName, 'io.example.directaab'); @@ -314,10 +329,13 @@ test('prepareAndroidInstallArtifact extracts GitHub artifact ZIP containing one }, async () => { await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { - const result = await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/123/zip', - }); + const result = await prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/123/zip', + }, + { environment: process.env }, + ); try { assert.equal(path.basename(result.installablePath), 'app.apk'); @@ -348,10 +366,13 @@ test('prepareAndroidInstallArtifact extracts GitHub artifact ZIP containing one }, async () => { await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { - const result = await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/456/zip', - }); + const result = await prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/456/zip', + }, + { environment: process.env }, + ); try { assert.equal(path.basename(result.installablePath), 'app.aab'); @@ -385,10 +406,13 @@ test('prepareIosInstallArtifact extracts GitHub artifact ZIP containing nested a async () => { await withIosBundleInfo('com.example.githubtar', 'GitHub Tar', async () => { await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { - const result = await prepareIosInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/789/zip', - }); + const result = await prepareIosInstallArtifact( + { + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/789/zip', + }, + { environment: process.env }, + ); try { assert.equal(path.basename(result.installablePath), 'Demo.app'); @@ -426,10 +450,13 @@ test('prepareIosInstallArtifact extracts GitHub artifact ZIP containing one IPA' async () => { await withIosBundleInfo('com.example.githubipa', 'GitHub IPA', async () => { await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { - const result = await prepareIosInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/987/zip', - }); + const result = await prepareIosInstallArtifact( + { + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/987/zip', + }, + { environment: process.env }, + ); try { assert.equal(path.basename(result.installablePath), 'Demo.app'); @@ -462,10 +489,13 @@ test('prepareIosInstallArtifact names the zip a simulator app arrived in as its async () => { await withIosBundleInfo('com.example.githubapp', 'GitHub App', async () => { await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { - const result = await prepareIosInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/989/zip', - }); + const result = await prepareIosInstallArtifact( + { + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/989/zip', + }, + { environment: process.env }, + ); try { assert.equal(path.basename(result.installablePath), 'Demo.app'); @@ -504,10 +534,13 @@ test('prepareIosInstallArtifact cleans URL materialization when IPA payload reso await withIsolatedInstallTempRoot(async (materializeTempRoot) => { await assert.rejects( async () => - await prepareIosInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/988/zip', - }), + await prepareIosInstallArtifact( + { + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/988/zip', + }, + { environment: process.env }, + ), /found 2 .app bundles/, ); assert.deepEqual(await fs.readdir(materializeTempRoot), []); @@ -527,10 +560,13 @@ test('prepareAndroidInstallArtifact rejects URL archives with multiple installab await withMockedInstallSourceFetch(await fs.readFile(archivePath), async () => { await assert.rejects( async () => - await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/654/zip', - }), + await prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/654/zip', + }, + { environment: process.env }, + ), /multiple Android installable/i, ); }); @@ -555,10 +591,13 @@ test('prepareAndroidInstallArtifact extracts a URL archive from any public host' }, async () => { await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { - const result = await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://artifacts.example.com/signed/build.zip?sig=abc', - }); + const result = await prepareAndroidInstallArtifact( + { + kind: 'url', + url: 'https://artifacts.example.com/signed/build.zip?sig=abc', + }, + { environment: process.env }, + ); try { assert.equal(path.basename(result.installablePath), 'app.apk'); diff --git a/src/agent-device-client-metro.test.ts b/src/agent-device-client-metro.test.ts index fc03602fed..d1a6852eb4 100644 --- a/src/agent-device-client-metro.test.ts +++ b/src/agent-device-client-metro.test.ts @@ -203,13 +203,14 @@ test('metro reload targets the dev server bound by metro prepare in the same ses if (req.command === 'close') return { ok: true, data: {} }; throw new Error('metro prepare/reload must stay local and never call the daemon'); }, + // The public MetroPrepareOptions doesn't expose env, so the fake npx must be on the host's PATH. + host: { + env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }, + cwd: projectRoot, + }, }, ); - // The public MetroPrepareOptions doesn't expose env, so the fake npx must be on the real PATH. - const previousPath = process.env.PATH; - process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`; - let pid = 0; try { const prepared = await client.metro.prepare({ @@ -247,7 +248,6 @@ test('metro reload targets the dev server bound by metro prepare in the same ses undefined, ); } finally { - process.env.PATH = previousPath; await stopTestMetroProcess(pid); rmSync(tempRoot, { recursive: true, force: true }); } @@ -279,12 +279,13 @@ test('metro prepare keeps a public Expo address separate from the local reload a transport: async () => { throw new Error('metro prepare/reload must stay local and never call the daemon'); }, + host: { + env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }, + cwd: projectRoot, + }, }, ); - const previousPath = process.env.PATH; - process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`; - let pid = 0; try { const prepared = await client.metro.prepare({ @@ -331,7 +332,6 @@ test('metro prepare keeps a public Expo address separate from the local reload a deviceBaseUrls: [`https://127.0.0.1:${metroPort}${publicBasePath}`], }); } finally { - process.env.PATH = previousPath; await stopTestMetroProcess(pid); rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 07f0635f74..dfdbaddbcc 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -61,14 +61,7 @@ import { import type { CommandResult } from '@agent-device/command-registry/command-result'; import { createDaemonTransport } from './daemon-client/daemon-client.ts'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { - prepareMetroRuntime, - reloadMetro, - clearMetroSessionHintsQuietly, - recordPreparedMetroSession, - recordOpenedMetroSession, - readMetroSessionRuntime, -} from '@agent-device/react-native'; +import * as devServer from '@agent-device/react-native'; import { asOptionalRecord, readPresentedSurfaceFact, @@ -90,6 +83,26 @@ export function createAgentDeviceClient( const transport = deps.transport ?? createDaemonTransport(host); // A typo in a JSON or JS config must not fall through to the default and manage a daemon. readDaemonLifecycle(config.daemonLifecycle); + // The dev-server helpers take the client's host explicitly. + const prepareMetroRuntime = (input: Parameters[0]) => + devServer.prepareMetroRuntime(input, host); + const { reloadMetro } = devServer; + const withHost = (options: Options) => ({ + ...options, + host, + }); + const clearMetroSessionHintsQuietly = (options: { session: string; stateDir?: string }) => + devServer.clearMetroSessionHintsQuietly(withHost(options)); + const recordPreparedMetroSession = ( + options: { session: string; stateDir?: string }, + result: Parameters[1], + ) => devServer.recordPreparedMetroSession(withHost(options), result); + const recordOpenedMetroSession = ( + options: { session: string; stateDir?: string }, + input: Parameters[1], + ) => devServer.recordOpenedMetroSession(withHost(options), input); + const readMetroSessionRuntime = (options: { session: string; stateDir?: string }) => + devServer.readMetroSessionRuntime(withHost(options)); // A non-default responseLevel (digest/full) makes the daemon return a leveled // shape; the per-command client normalizers assume the default shape, so the diff --git a/src/commands/recording/runtime/contact-sheet.test.ts b/src/commands/recording/runtime/contact-sheet.test.ts index 67bfc2b48b..c3bb39dbc7 100644 --- a/src/commands/recording/runtime/contact-sheet.test.ts +++ b/src/commands/recording/runtime/contact-sheet.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import { beforeEach, test, vi } from 'vitest'; import { buildRecordingContactSheet, @@ -15,6 +16,8 @@ import { restrictedCommandPolicy, } from '@agent-device/contracts/command-policy'; +bindTestCommandHost(); + vi.mock('@agent-device/capture-kit/recording-contact-sheet', async (importOriginal) => ({ ...(await importOriginal()), buildRecordingContactSheet: vi.fn(), diff --git a/src/commands/recording/runtime/contact-sheet.ts b/src/commands/recording/runtime/contact-sheet.ts index d11a28433d..826036fc72 100644 --- a/src/commands/recording/runtime/contact-sheet.ts +++ b/src/commands/recording/runtime/contact-sheet.ts @@ -5,6 +5,7 @@ import { recordingContactSheetPath, type RecordingContactSheetResult, } from '@agent-device/capture-kit/recording-contact-sheet'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { AppError } from '@agent-device/kernel/errors'; import type { ArtifactDescriptor, @@ -59,6 +60,7 @@ export const contactSheetCommand: RuntimeCommand< artifactType: 'screen-recording-contact-sheet', }); const sheet = await buildRecordingContactSheet({ + host: commandHostEnvironment(), videoPath: video.path, outputPath: output.path, maxPixels: runtime.policy.maxImagePixels, diff --git a/src/platform-runtime-android-deployment-executor.ts b/src/platform-runtime-android-deployment-executor.ts index 87720ebe90..6e9eb128f8 100644 --- a/src/platform-runtime-android-deployment-executor.ts +++ b/src/platform-runtime-android-deployment-executor.ts @@ -3,6 +3,7 @@ import type { AndroidAppDeploymentExecutor, MaterializeAppSourceInput, } from '@agent-device/contracts/app-deployment-runtime'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; /** Android deployment adapter; native command construction remains package-owned. */ @@ -17,7 +18,10 @@ export function createAndroidAppDeploymentExecutor( }, prepareArtifact: async (input: MaterializeAppSourceInput, options) => { const { prepareAndroidInstallArtifact } = await loadAndroidMechanics(); - return await prepareAndroidInstallArtifact(input.source, options); + return await prepareAndroidInstallArtifact(input.source, { + ...options, + environment: commandHostEnvironment().env, + }); }, resolveAppPackage: async (device, app, options) => { const { resolveAndroidApp } = await loadAndroidMechanics(); diff --git a/src/platform-runtime-app-log-output.test.ts b/src/platform-runtime-app-log-output.test.ts index 6be7e1f424..d700a0a432 100644 --- a/src/platform-runtime-app-log-output.test.ts +++ b/src/platform-runtime-app-log-output.test.ts @@ -1,9 +1,12 @@ import fs from 'node:fs'; import path from 'node:path'; +import { withCommandHost } from '@agent-device/host-kit/command'; import { afterEach, expect, test } from 'vitest'; import { openAppLogOutput, readAppLogOutputTail } from './platform-runtime-app-log-output.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { bindTestCommandHost, mkdtempForTestSync } from '@agent-device/test-kit'; + +bindTestCommandHost(); const roots: string[] = []; @@ -92,3 +95,25 @@ function finalSymlinkFixture(label: string) { fs.symlinkSync(outsidePath, outputPath); return { sessionsDir, outputPath, outsidePath }; } + +test('redacts with the patterns of the command host, not the process', async () => { + const root = mkdtempForTestSync('agent-device-app-log-redact-'); + roots.push(root); + const sessionsDir = path.join(root, 'sessions'); + const outputPath = path.join(sessionsDir, 'one', 'app.log'); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + + await withCommandHost( + { + host: { env: { AGENT_DEVICE_APP_LOG_REDACT_PATTERNS: 'secret-\\d+' }, cwd: root }, + requestEnv: undefined, + }, + async () => { + const sink = await openAppLogOutput(sessionsDir, outputPath); + await sink.write('token secret-42 leaked\n'); + await sink[Symbol.asyncDispose](); + }, + ); + + expect(fs.readFileSync(outputPath, 'utf8')).toBe('token [REDACTED] leaked\n'); +}); diff --git a/src/platform-runtime-app-log-output.ts b/src/platform-runtime-app-log-output.ts index e0e1d3346b..70c4b3fe59 100644 --- a/src/platform-runtime-app-log-output.ts +++ b/src/platform-runtime-app-log-output.ts @@ -1,6 +1,7 @@ import { ensureAppLogPath } from '@agent-device/capture-kit/app-log-files'; import type { AppLogOutputSink } from '@agent-device/contracts/app-log-runtime'; import fs from 'node:fs'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { openVerifiedFileForAppend, openVerifiedFileForRead } from '@agent-device/host-kit/file'; import { requireManagedSessionArtifactPath } from '@agent-device/daemon-contracts/session-artifact-paths'; @@ -9,7 +10,7 @@ export async function openAppLogOutput( pathname: string, ): Promise { pathname = requireManagedOutputPath(sessionsDir, pathname); - ensureAppLogPath(pathname); + ensureAppLogPath(pathname, commandHostEnvironment().env); const descriptor = openVerifiedFileForAppend(pathname); let stream: fs.WriteStream; try { @@ -22,7 +23,8 @@ export async function openAppLogOutput( stream.on('error', (error) => { streamError ??= error; }); - const patterns = redactionPatterns(); + const { env } = commandHostEnvironment(); + const patterns = redactionPatterns(env); let pending = ''; let writes = Promise.resolve(); let disposal: Promise | undefined; @@ -89,8 +91,8 @@ function requireManagedOutputPath(sessionsDir: string, pathname: string): string }); } -function redactionPatterns(): RegExp[] { - return (process.env.AGENT_DEVICE_APP_LOG_REDACT_PATTERNS ?? '') +function redactionPatterns(env: NodeJS.ProcessEnv): RegExp[] { + return (env.AGENT_DEVICE_APP_LOG_REDACT_PATTERNS ?? '') .split(',') .map((part) => part.trim()) .filter(Boolean) diff --git a/src/platform-runtime-apple-deployment-executor.ts b/src/platform-runtime-apple-deployment-executor.ts index 5099809cc1..c32d8a10d2 100644 --- a/src/platform-runtime-apple-deployment-executor.ts +++ b/src/platform-runtime-apple-deployment-executor.ts @@ -1,3 +1,4 @@ +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { AppleAppDeploymentExecutor, MaterializeAppSourceInput, @@ -17,7 +18,10 @@ export function createAppleAppDeploymentExecutor(): AppleAppDeploymentExecutor { prepareArtifact: async (input: MaterializeAppSourceInput, options) => { const { prepareIosInstallArtifact } = await import('@agent-device/platform-apple/install-artifact'); - return await prepareIosInstallArtifact(input.source, options); + return await prepareIosInstallArtifact(input.source, { + ...options, + environment: commandHostEnvironment().env, + }); }, resolveAppBundleId: async (device, app, options) => { const { resolveIosApp } = await import('@agent-device/platform-apple/app-resolution'); diff --git a/src/platform-runtime-screen-recording-android-host.ts b/src/platform-runtime-screen-recording-android-host.ts index 7da8f53699..e6ae77c57f 100644 --- a/src/platform-runtime-screen-recording-android-host.ts +++ b/src/platform-runtime-screen-recording-android-host.ts @@ -6,6 +6,7 @@ import type { } from '@agent-device/contracts/screen-recording-runtime-host'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { type ShellWord, shellFragment, shellQuote } from '@agent-device/kernel/device-shell'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { isPlayableVideo } from '@agent-device/capture-kit/recording-video'; import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; @@ -107,7 +108,8 @@ export async function createAndroidScreenRecordingTransport( }); return { ...result, - playable: result.exitCode === 0 && (await isPlayableVideo(outputPath)), + playable: + result.exitCode === 0 && (await isPlayableVideo(outputPath, commandHostEnvironment())), }; }, remove: async (remotePath, signal) => diff --git a/src/platform-runtime-screen-recording-apple-runner-host.test.ts b/src/platform-runtime-screen-recording-apple-runner-host.test.ts index 10c38fdb00..e475499a4b 100644 --- a/src/platform-runtime-screen-recording-apple-runner-host.test.ts +++ b/src/platform-runtime-screen-recording-apple-runner-host.test.ts @@ -11,7 +11,7 @@ vi.mock('@agent-device/platform-apple/runner/operations', () => ({ runAppleRunnerCommand: runnerClient.run, })); vi.mock('@agent-device/platform-apple/runner', () => ({ - IOS_RUNNER_CONTAINER_BUNDLE_IDS: ['com.callstack.agentdevice.runner'], + resolveRunnerContainerBundleIds: () => ['com.callstack.agentdevice.runner'], })); const simulator = { diff --git a/src/platform-runtime-screen-recording-apple-runner-host.ts b/src/platform-runtime-screen-recording-apple-runner-host.ts index 9f238f6b88..069909c012 100644 --- a/src/platform-runtime-screen-recording-apple-runner-host.ts +++ b/src/platform-runtime-screen-recording-apple-runner-host.ts @@ -106,6 +106,7 @@ export async function captureAppleClockAnchor( } export async function isAppleRunnerBundleId(bundleId: string): Promise { - const { IOS_RUNNER_CONTAINER_BUNDLE_IDS } = await import('@agent-device/platform-apple/runner'); - return IOS_RUNNER_CONTAINER_BUNDLE_IDS.includes(bundleId); + const { resolveRunnerContainerBundleIds } = await import('@agent-device/platform-apple/runner'); + const { commandHostEnvironment } = await import('@agent-device/host-kit/command'); + return resolveRunnerContainerBundleIds(commandHostEnvironment().env).includes(bundleId); } diff --git a/src/platform-runtime-screen-recording-finalizer-host.test.ts b/src/platform-runtime-screen-recording-finalizer-host.test.ts index 040749d6a6..47f2656123 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.test.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.test.ts @@ -1,4 +1,5 @@ import { beforeEach, expect, test, vi } from 'vitest'; +import { bindTestCommandHost } from '@agent-device/test-kit'; import { OVERLAY_BUDGET_MS, type overlayRecordingTouches, @@ -13,6 +14,8 @@ const video = vi.hoisted(() => ({ })); const telemetry = vi.hoisted(() => vi.fn(() => '/tmp/capture.telemetry.json')); const overlay = vi.hoisted(() => vi.fn(async () => {})); +bindTestCommandHost(); + vi.mock('@agent-device/capture-kit/recording-video', () => ({ waitForStableFile: video.stable, waitForPlayableVideo: video.playable, @@ -48,7 +51,9 @@ test('requires stable playable media before publishing finalization telemetry', }); expect(video.stable).toHaveBeenCalledWith('/tmp/capture.mp4'); - expect(video.playable).toHaveBeenCalledWith('/tmp/capture.mp4'); + expect(video.playable).toHaveBeenCalledWith('/tmp/capture.mp4', { + host: expect.objectContaining({ cwd: process.cwd() }), + }); expect(result).toEqual({ telemetryPath: '/tmp/capture.telemetry.json' }); }); diff --git a/src/platform-runtime-screen-recording-finalizer-host.ts b/src/platform-runtime-screen-recording-finalizer-host.ts index d183e13a99..287fe6e7a6 100644 --- a/src/platform-runtime-screen-recording-finalizer-host.ts +++ b/src/platform-runtime-screen-recording-finalizer-host.ts @@ -1,6 +1,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { RECORDING_OUTPUT_UNPLAYABLE_REASON } from '@agent-device/contracts/screen-recording-runtime'; import type { ScreenRecordingRuntimeHost } from '@agent-device/contracts/screen-recording-runtime-host'; +import { commandHostEnvironment } from '@agent-device/host-kit/command'; import type { Deadline } from '@agent-device/host-kit/retry'; import { getRecordingOverlaySupportWarning, @@ -28,8 +29,9 @@ async function finalizeScreenRecording( ) { const overlayDeadline = startRecordingOverlayBudget(); await waitForStableFile(input.outputPath); - await waitForPlayableVideo(input.outputPath); - if (!(await isPlayableVideo(input.outputPath))) throw unplayableRecording(input.outputPath); + const host = commandHostEnvironment(); + await waitForPlayableVideo(input.outputPath, { host }); + if (!(await isPlayableVideo(input.outputPath, host))) throw unplayableRecording(input.outputPath); const telemetryPath = persistRecordingTelemetry({ recording: { outPath: input.outputPath, gestureEvents: [...input.gestureEvents] }, }); @@ -45,7 +47,9 @@ async function overlayTouches( const warning = getRecordingOverlaySupportWarning(); if (warning) return { telemetryPath, overlayWarning: warning }; try { + const host = commandHostEnvironment(); await overlayRecordingTouches({ + host, videoPath: input.outputPath, telemetryPath, exportQuality: input.exportQuality, @@ -53,7 +57,7 @@ async function overlayTouches( targetLabel: input.targetLabel, deadline, }); - if (!(await isPlayableVideo(input.outputPath))) { + if (!(await isPlayableVideo(input.outputPath, host))) { throw new Error('recording post-processing produced an unplayable video'); } return { telemetryPath }; diff --git a/src/sdk/metro-public.test.ts b/src/sdk/metro-public.test.ts index bb55581e4f..27a90f8764 100644 --- a/src/sdk/metro-public.test.ts +++ b/src/sdk/metro-public.test.ts @@ -68,3 +68,12 @@ test('public metro entrypoint does not expose runtime hint builders', async () = status: 200, headers: { 'content-type': 'application/json' }, }) satisfies MetroTunnelResponseMessage; + +test('public prepareMetroRuntime runs without a daemon or CLI host around it', async () => { + const { prepareMetroRuntime } = await import('./metro.ts'); + + await assert.rejects( + prepareMetroRuntime({}), + (error: unknown) => (error as { code?: string }).code === 'INVALID_ARGS', + ); +}); diff --git a/src/sdk/metro.ts b/src/sdk/metro.ts index ac475fd369..24806147b0 100644 --- a/src/sdk/metro.ts +++ b/src/sdk/metro.ts @@ -1,11 +1,20 @@ +import * as devServer from '@agent-device/react-native'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; + export { normalizeBaseUrl, buildBundleUrl, resolveRuntimeTransport, - stopMetroTunnel, - prepareMetroRuntime, reloadMetro, + stopMetroTunnel, type MetroBridgeDescriptor, type MetroTunnelRequestMessage, type MetroTunnelResponseMessage, } from '@agent-device/react-native'; + +/** A JS host calls this with no daemon or CLI around it, so it reads the process's current environment. */ +export async function prepareMetroRuntime( + input: Parameters[0] = {}, +): ReturnType { + return await devServer.prepareMetroRuntime(input, captureHostEnvironment(process)); +} diff --git a/test/integration/live-device-e2e/assertions.ts b/test/integration/live-device-e2e/assertions.ts index 86cc006967..fcba5ddba5 100644 --- a/test/integration/live-device-e2e/assertions.ts +++ b/test/integration/live-device-e2e/assertions.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import { assertPngFile } from '../provider-scenarios/assertions.ts'; import { isPlayableVideo } from '@agent-device/capture-kit/recording-video'; +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import type { CliJsonResult } from '../cli-json.ts'; import type { LiveDeviceContext } from './runtime.ts'; @@ -88,7 +89,7 @@ export function assertNonEmptyFile(filePath: string, name: string): void { export async function assertMp4File(filePath: string): Promise { assertNonEmptyFile(filePath, 'recording'); assert.equal( - await isPlayableVideo(filePath), + await isPlayableVideo(filePath, captureHostEnvironment(process)), true, `recording is not a finalized playable video: ${filePath}`, ); diff --git a/test/integration/provider-scenarios/android-recording-fixtures.ts b/test/integration/provider-scenarios/android-recording-fixtures.ts index 0b251b894f..dd598be6bc 100644 --- a/test/integration/provider-scenarios/android-recording-fixtures.ts +++ b/test/integration/provider-scenarios/android-recording-fixtures.ts @@ -6,11 +6,7 @@ import { deviceIdentity } from '@agent-device/kernel/device'; import { screenRecordingResourceStore } from '@agent-device/capture-kit/screen-recording-resource-store'; import type { AndroidRecordingManifestFixture } from './android-recording-manifest-fixtures.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; -import { - createProviderScenarioHarness, - restoreEnv, - withProviderScenarioTempDir, -} from './harness.ts'; +import { createProviderScenarioHarness, withProviderScenarioTempDir } from './harness.ts'; export type ProviderScenarioDaemon = Awaited>; export type ProviderScenarioRpcResult = Awaited>; @@ -25,28 +21,29 @@ export async function stopAndroidRecording( }); } +/** + * The daemon's host for an Android recording scenario: no real tool on its PATH, so the Swift + * helpers cannot compile, and a Swift cache of its own. + */ +function androidRecordingHostEnv(tmpDir: string): Readonly> { + return { PATH: tmpDir, AGENT_DEVICE_SWIFT_CACHE_DIR: path.join(tmpDir, 'swift-cache') }; +} + export async function createAndroidRecordingScenarioHarness( deps: Parameters[0], + tmpDir: string, ): Promise { - return await createProviderScenarioHarness({ ...deps }); + return await createProviderScenarioHarness({ + ...deps, + env: { ...androidRecordingHostEnv(tmpDir), ...deps.env }, + }); } export async function withAndroidRecordingScenario( prefix: string, run: (tmpDir: string) => Promise, ): Promise { - return await withProviderScenarioTempDir(prefix, async (tmpDir) => { - const previousPath = process.env.PATH; - const previousSwiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; - process.env.PATH = tmpDir; - process.env.AGENT_DEVICE_SWIFT_CACHE_DIR = path.join(tmpDir, 'swift-cache'); - try { - return await run(tmpDir); - } finally { - restoreEnv('PATH', previousPath); - restoreEnv('AGENT_DEVICE_SWIFT_CACHE_DIR', previousSwiftCacheDir); - } - }); + return await withProviderScenarioTempDir(prefix, run); } export function seedAndroidRecordingResource( diff --git a/test/integration/provider-scenarios/android-recording.test.ts b/test/integration/provider-scenarios/android-recording.test.ts index 9060ca21d6..26c7139669 100644 --- a/test/integration/provider-scenarios/android-recording.test.ts +++ b/test/integration/provider-scenarios/android-recording.test.ts @@ -25,10 +25,13 @@ test(ANDROID_RECORDING_CONTRACT_EVIDENCE.testName, async () => { const calls: string[][] = []; const pulls: PullCall[] = []; const outputPath = path.join(tmpDir, 'sessionless.mp4'); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => createAndroidRecordingProvider({ calls, pulls }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => createAndroidRecordingProvider({ calls, pulls }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); try { const started = await daemon.callCommand('record', ['start', outputPath], { platform: 'android', @@ -71,11 +74,14 @@ test('Provider-backed integration Android record stop reattaches a matching dura remotePath, sessionName: 'default', }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => - createAndroidRecordingProvider({ calls, pulls, manifests: [manifest] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ calls, pulls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); seedAndroidRecordingResource(daemon, manifest); try { const stopped = await stopAndroidRecording(daemon, outputPath); @@ -115,11 +121,14 @@ test('Provider-backed integration Android record stop returns fenced completed n recordOnlySession: false, }, }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => - createAndroidRecordingProvider({ calls, manifests: [manifest], deadPids: ['4321'] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ calls, manifests: [manifest], deadPids: ['4321'] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); seedAndroidRecordingResource(daemon, manifest); try { const stopped = await stopAndroidRecording(daemon, outputPath); @@ -165,15 +174,18 @@ test('Provider-backed integration Android record start retires completed evidenc }, }); fs.writeFileSync(previousPath, 'saved recording'); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => - createAndroidRecordingProvider({ - calls, - manifests: [manifest], - reusedPids: [{ pid: '4004', startTime: '5432' }], - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ + calls, + manifests: [manifest], + reusedPids: [{ pid: '4004', startTime: '5432' }], + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); try { const started = await daemon.callCommand('record', ['start', outputPath], { platform: 'android', @@ -217,11 +229,14 @@ test('Provider-backed integration Android record start retires evidence stranded deviceId: 'emulator-5588', chunks: [{ index: 1, remotePath, remotePid: '4004', remoteStartTime: '3766' }], }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => - createAndroidRecordingProvider({ calls, manifests: [manifest], deadPids: ['4004'] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ calls, manifests: [manifest], deadPids: ['4004'] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); try { const started = await daemon.callCommand('record', ['start', outputPath], { platform: 'android', @@ -271,15 +286,18 @@ test('Provider-backed integration Android record start retains completed evidenc recordOnlySession: false, }, }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => - createAndroidRecordingProvider({ - calls, - manifests: [manifest], - reusedPids: [{ pid: '4004', startTime: '9911', role: 'replacement-recorder' }], - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ + calls, + manifests: [manifest], + reusedPids: [{ pid: '4004', startTime: '9911', role: 'replacement-recorder' }], + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); try { assertRpcError( await daemon.callCommand('record', ['start', outputPath], { @@ -338,10 +356,14 @@ test('Provider-backed integration Android record start refuses completed evidenc recordOnlySession: false, }, }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => createAndroidRecordingProvider({ calls, manifests: [manifest] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ calls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); try { assertRpcError( await daemon.callCommand('record', ['start', outputPath], { @@ -386,10 +408,14 @@ test('Provider-backed integration Android corrupt descriptor is retained without remotePath: '/sdcard/agent-device-recording-323456789.mp4', sessionName: 'default', }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => createAndroidRecordingProvider({ calls, manifests: [manifest] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ calls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); seedAndroidRecordingResource(daemon, manifest, { descriptor: { malformed: true } }); try { const stopped = await stopAndroidRecording(daemon); @@ -423,10 +449,13 @@ test('Provider-backed integration Android cleanup-only recovery terminalizes bef pendingRemotePath: '/sdcard/agent-device-recording-423456789.mp4', }); const provider = createAndroidRecordingProvider({ calls, manifests: [manifest] }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => provider, - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); seedAndroidRecordingResource(daemon, manifest); try { assertRpcError( diff --git a/test/integration/provider-scenarios/ios-record-trace.test.ts b/test/integration/provider-scenarios/ios-record-trace.test.ts index 9622d71ec5..60f2f2f2ac 100644 --- a/test/integration/provider-scenarios/ios-record-trace.test.ts +++ b/test/integration/provider-scenarios/ios-record-trace.test.ts @@ -17,7 +17,6 @@ import { PROVIDER_SCENARIO_IOS_DEVICE, PROVIDER_SCENARIO_IOS_SIMULATOR } from '. import { createProviderIosSimulatorRecordingProcess, createProviderScenarioHarness, - restoreEnv, withProviderScenarioTempDir, } from './harness.ts'; import { @@ -112,13 +111,6 @@ test('iOS simulator recording reports host contention and recovers through the f appleSimulatorScreenRecordingTransport: () => recordingTransport, deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], }); - // capture-kit's recording finalizer still reads PATH and the Swift cache from the process - // (#3777 moves capture-kit to the host), so the scenario sets both there as well. - const previousPath = process.env.PATH; - const previousSwiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; - process.env.PATH = tmpDir; - process.env.AGENT_DEVICE_SWIFT_CACHE_DIR = path.join(tmpDir, 'swift-cache'); - try { const open = await daemon.callCommand('open', ['com.apple.Preferences'], { platform: 'ios', @@ -199,8 +191,6 @@ test('iOS simulator recording reports host contention and recovers through the f ); } finally { await daemon.close(); - restoreEnv('PATH', previousPath); - restoreEnv('AGENT_DEVICE_SWIFT_CACHE_DIR', previousSwiftCacheDir); } }, ); diff --git a/test/integration/provider-scenarios/macos-world.ts b/test/integration/provider-scenarios/macos-world.ts index 32d4fd9603..00c0bbde40 100644 --- a/test/integration/provider-scenarios/macos-world.ts +++ b/test/integration/provider-scenarios/macos-world.ts @@ -48,7 +48,10 @@ export async function createMacOsDesktopWorld( }, }, }); + // Recording finalization runs the Swift validator; the process's warm cache keeps it from compiling. + const swiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; const daemon = await createProviderScenarioHarness({ + ...(swiftCacheDir ? { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: swiftCacheDir } } : {}), appleRunnerProvider: options.appleRunnerProvider ? () => options.appleRunnerProvider : undefined, diff --git a/test/integration/provider-scenarios/record-trace-android-copy.test.ts b/test/integration/provider-scenarios/record-trace-android-copy.test.ts index 1c15529b09..766e09eb86 100644 --- a/test/integration/provider-scenarios/record-trace-android-copy.test.ts +++ b/test/integration/provider-scenarios/record-trace-android-copy.test.ts @@ -27,18 +27,21 @@ test('Provider-backed integration Android record stop retries the pull until in- const streaming = mp4WithMoovSlot('free'); const finalized = mp4WithMoovSlot('moov'); let pulls = 0; - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => - createAndroidRecordingProvider({ - calls: [], - manifests: [manifest], - onPull: (_remotePath, localPath, count) => { - pulls = count; - fs.writeFileSync(localPath, count < 3 ? streaming : finalized); - }, - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => + createAndroidRecordingProvider({ + calls: [], + manifests: [manifest], + onPull: (_remotePath, localPath, count) => { + pulls = count; + fs.writeFileSync(localPath, count < 3 ? streaming : finalized); + }, + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); seedAndroidRecordingResource(daemon, manifest); try { const stopped = await stopAndroidRecording(daemon, outputPath); diff --git a/test/integration/provider-scenarios/record-trace-android-liveness.test.ts b/test/integration/provider-scenarios/record-trace-android-liveness.test.ts index aff50c3b50..91c969a3a1 100644 --- a/test/integration/provider-scenarios/record-trace-android-liveness.test.ts +++ b/test/integration/provider-scenarios/record-trace-android-liveness.test.ts @@ -24,17 +24,20 @@ test('Provider-backed integration Android record stop finishes an artifact after remotePath, sessionName: 'default', }); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => { - const provider = createAndroidRecordingProvider({ - calls, - manifests: [manifest], - deadPids: ['4321'], - }); - return provider; + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => { + const provider = createAndroidRecordingProvider({ + calls, + manifests: [manifest], + deadPids: ['4321'], + }); + return provider; + }, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], }, - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + tmpDir, + ); seedAndroidRecordingResource(daemon, manifest); try { const stopped = await stopAndroidRecording(daemon, outputPath); diff --git a/test/integration/provider-scenarios/session-close-leak-oracle.test.ts b/test/integration/provider-scenarios/session-close-leak-oracle.test.ts index ea10091401..54f1c6def2 100644 --- a/test/integration/provider-scenarios/session-close-leak-oracle.test.ts +++ b/test/integration/provider-scenarios/session-close-leak-oracle.test.ts @@ -26,10 +26,13 @@ test( async (tmpDir) => { const calls: string[][] = []; const outputPath = path.join(tmpDir, 'close-leak.mp4'); - const daemon = await createAndroidRecordingScenarioHarness({ - androidAdbProvider: () => createAndroidRecordingProvider({ calls }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); + const daemon = await createAndroidRecordingScenarioHarness( + { + androidAdbProvider: () => createAndroidRecordingProvider({ calls }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }, + tmpDir, + ); try { const started = await daemon.callCommand('record', ['start', outputPath], { platform: 'android', From fab82cc12347583385f3fedc28db75942bac8f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 20:39:18 +0200 Subject: [PATCH 30/33] test: pass the host to the iOS e2e live runner's request Co-Authored-By: Claude Sonnet 5.5 --- test/integration/ios-simulator-e2e/live-runner.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/integration/ios-simulator-e2e/live-runner.ts b/test/integration/ios-simulator-e2e/live-runner.ts index 0ae003e864..73874e01c1 100644 --- a/test/integration/ios-simulator-e2e/live-runner.ts +++ b/test/integration/ios-simulator-e2e/live-runner.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import type { TestContext } from 'node:test'; @@ -69,6 +70,7 @@ const sendToDaemonTransport: AgentDeviceDaemonTransport = async (request, contex paths, 90_000, { + host: createTestHostEnvironment(), signal: context?.signal, timeoutRecovery: 'none', }, From 4f6dd3e722b9e7fe3a10b015f714b33e9e326625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 18 Oct 2026 00:55:34 +0200 Subject: [PATCH 31/33] refactor(react-native): drop the redundant outer catch around the scoped Metro session read; changelog the PNG worker change Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + packages/react-native/src/metro/metro-session-hints.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e734d4446..cbd1400901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- The PNG worker no longer calls `process.emitWarning` when it is unavailable, and it starts with an empty environment (`env: {}`). The unavailable reason is reported once as the `png_worker_unavailable` diagnostic, by the first request that reaches the in-process fallback, instead of also printing a process warning at daemon startup. - 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 `. diff --git a/packages/react-native/src/metro/metro-session-hints.ts b/packages/react-native/src/metro/metro-session-hints.ts index 58e4cb1095..d9260cdbca 100644 --- a/packages/react-native/src/metro/metro-session-hints.ts +++ b/packages/react-native/src/metro/metro-session-hints.ts @@ -123,11 +123,13 @@ function readScopedMetroSessionRuntime(scope: MetroSessionScope): SessionRuntime export function readMetroSessionRuntime( options: MetroSessionOptions, ): SessionRuntimeHints | undefined { + let scope: MetroSessionScope; try { - return readScopedMetroSessionRuntime(resolveMetroSessionScope(options)); + scope = resolveMetroSessionScope(options); } catch { return undefined; } + return readScopedMetroSessionRuntime(scope); } function runtimeServerUrl(runtime: SessionRuntimeHints | undefined): string | undefined { From 595fa4822c17417e33c0ba775362307a57fa439a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 18 Oct 2026 04:56:06 +0200 Subject: [PATCH 32/33] refactor(capture-kit): report an unavailable PNG worker in every scope that takes the fallback; the daemon pre-warms it inside a daemon diagnostics scope Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- .../src/png-worker-client-unavailable.test.ts | 27 +++++------ packages/capture-kit/src/png-worker-client.ts | 32 +++++-------- .../http/daemon-runtime-png-worker.test.ts | 47 +++++++++++++++++++ .../daemon/src/server/http/daemon-runtime.ts | 5 +- 5 files changed, 75 insertions(+), 38 deletions(-) create mode 100644 packages/daemon/src/server/http/daemon-runtime-png-worker.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cbd1400901..98eabe91e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- The PNG worker no longer calls `process.emitWarning` when it is unavailable, and it starts with an empty environment (`env: {}`). The unavailable reason is reported once as the `png_worker_unavailable` diagnostic, by the first request that reaches the in-process fallback, instead of also printing a process warning at daemon startup. +- The PNG worker no longer calls `process.emitWarning` when it is unavailable, and it starts with an empty environment (`env: {}`). The daemon pre-warms it inside a daemon diagnostics scope, so a worker that cannot start leaves a `png_worker_unavailable` event in `daemon.log`; every request that takes the in-process fallback reports the same event in its own diagnostics. - 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 `. diff --git a/packages/capture-kit/src/png-worker-client-unavailable.test.ts b/packages/capture-kit/src/png-worker-client-unavailable.test.ts index 565f892f04..2d6bc721b9 100644 --- a/packages/capture-kit/src/png-worker-client-unavailable.test.ts +++ b/packages/capture-kit/src/png-worker-client-unavailable.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'; import { expect, test, vi } from 'vitest'; import { PNG } from './png.ts'; -test('an unavailable worker is reported by the first diagnostics scope that reaches the fallback', async () => { +test('every scope that reaches the fallback reports the unavailable worker', async () => { vi.resetModules(); vi.doMock('./internal-entry.ts', () => ({ resolveInternalEntryModulePath: () => null })); // Loaded after the reset so the client and the scope share one diagnostics module. @@ -11,22 +11,21 @@ test('an unavailable worker is reported by the first diagnostics scope that reac const { decodePngAsync, prewarmPngWorker } = await import('./png-worker-client.ts'); const png = PNG.sync.write(new PNG({ width: 1, height: 1 })); - // The daemon pre-warms outside any request scope, where the report would be dropped. - prewarmPngWorker(); - - const reported = await withDiagnosticsScope({ command: 'screenshot' }, async () => { - await decodePngAsync(png, 'first'); - const afterFirst = countDiagnosticEventsByPhase(['png_worker_unavailable']); - await decodePngAsync(png, 'second'); - return { afterFirst, afterSecond: countDiagnosticEventsByPhase(['png_worker_unavailable']) }; - }); - const laterScope = await withDiagnosticsScope({ command: 'screenshot' }, async () => { - await decodePngAsync(png, 'third'); + const prewarmed = await withDiagnosticsScope({ command: 'daemon' }, async () => { + prewarmPngWorker(); return countDiagnosticEventsByPhase(['png_worker_unavailable']); }); + const scopes = []; + for (const label of ['first', 'second']) { + scopes.push( + await withDiagnosticsScope({ command: 'screenshot' }, async () => { + await decodePngAsync(png, label); + return countDiagnosticEventsByPhase(['png_worker_unavailable']); + }), + ); + } - expect(reported).toEqual({ afterFirst: 1, afterSecond: 1 }); - expect(laterScope).toBe(0); + expect({ prewarmed, scopes }).toEqual({ prewarmed: 1, scopes: [1, 1] }); vi.doUnmock('./internal-entry.ts'); }); diff --git a/packages/capture-kit/src/png-worker-client.ts b/packages/capture-kit/src/png-worker-client.ts index 07745995a3..df4ad09879 100644 --- a/packages/capture-kit/src/png-worker-client.ts +++ b/packages/capture-kit/src/png-worker-client.ts @@ -1,5 +1,5 @@ import { Worker } from 'node:worker_threads'; -import { emitDiagnostic, getDiagnosticsMeta } from '@agent-device/host-kit/diagnostics'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { AppError, toAppErrorCode } from '@agent-device/kernel/errors'; import type { Rect } from '@agent-device/kernel/snapshot'; import { resolveInternalEntryModulePath } from './internal-entry.ts'; @@ -40,29 +40,20 @@ type PendingJob = { let worker: Worker | null = null; let workerUnavailable = false; let workerUnavailableReason: string | undefined; -let reportedWorkerUnavailable = false; let nextJobId = 0; const pendingJobs = new Map(); -/** Permanently degrades to the sync path; the reason is reported by the first scope that sees it. */ +/** Permanently degrades to the sync path; a scope that reaches the fallback reports the reason. */ function markWorkerUnavailable(reason: string): void { workerUnavailable = true; workerUnavailableReason ??= reason; - reportWorkerUnavailable(); } -/** - * A pre-warm at daemon startup runs outside any diagnostics scope, where the event would be - * dropped, so the report waits for the first request scope that reaches the degraded path. - */ -function reportWorkerUnavailable(): void { - if (reportedWorkerUnavailable || workerUnavailableReason === undefined) return; - if (Object.keys(getDiagnosticsMeta()).length === 0) return; - reportedWorkerUnavailable = true; +function reportWorkerUnavailable(fallbackReason: string): void { emitDiagnostic({ level: 'warn', phase: 'png_worker_unavailable', - data: { reason: workerUnavailableReason }, + data: { reason: workerUnavailableReason ?? fallbackReason }, }); } @@ -112,10 +103,7 @@ function updateWorkerRef(): void { } function obtainWorker(): Worker | null { - if (workerUnavailable) { - reportWorkerUnavailable(); - return null; - } + if (workerUnavailable) return null; if (worker) return worker; const modulePath = resolveInternalEntryModulePath(import.meta.url, PNG_WORKER_ENTRYPOINT); if (!modulePath) { @@ -192,17 +180,19 @@ async function runPngJob( return await runWorkerJob(job); } catch (error) { if (error instanceof PngWorkerUnavailableError) { - // A worker can fail while this request waits on it, from an event callback with no scope. - reportWorkerUnavailable(); + reportWorkerUnavailable(error.message); return await runSync(); } throw error; } } -/** Daemon startup hook: spawns the worker before the first screenshot job. */ +/** + * Daemon startup hook: spawns the worker before the first screenshot job. Run it inside a + * diagnostics scope so a worker that cannot start leaves its reason in the daemon log. + */ export function prewarmPngWorker(): void { - obtainWorker(); + if (obtainWorker() === null) reportWorkerUnavailable('PNG worker is unavailable'); } /** Stops the worker thread (used by tests and shutdown); later calls respawn it. */ diff --git a/packages/daemon/src/server/http/daemon-runtime-png-worker.test.ts b/packages/daemon/src/server/http/daemon-runtime-png-worker.test.ts new file mode 100644 index 0000000000..1f6b6b954b --- /dev/null +++ b/packages/daemon/src/server/http/daemon-runtime-png-worker.test.ts @@ -0,0 +1,47 @@ +import fs from 'node:fs'; +import { expect, test, vi } from 'vitest'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; +import { daemonRuntimeDepsFixture } from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; +import { startDaemonRuntime } from './daemon-runtime.ts'; + +vi.mock('node:worker_threads', async (importOriginal) => ({ + ...(await importOriginal()), + Worker: class { + constructor() { + throw new Error('worker threads are not available'); + } + }, +})); + +test('a daemon whose PNG worker cannot start records the reason in daemon.log', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-png-worker-'); + let runtime: Awaited> = null; + try { + runtime = await startDaemonRuntime({ + host: createTestHostEnvironment({ + env: { + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), + exit: () => {}, + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + composeDeps: daemonRuntimeDepsFixture({}), + }); + expect(runtime).not.toBeNull(); + + const events = fs + .readFileSync(resolveDaemonPaths(stateDir).logPath, 'utf8') + .split('\n') + .filter((line) => line.includes('png_worker_unavailable')); + expect(events).toHaveLength(1); + expect(events[0]).toContain('failed to spawn worker: worker threads are not available'); + } finally { + await runtime?.shutdown(); + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); diff --git a/packages/daemon/src/server/http/daemon-runtime.ts b/packages/daemon/src/server/http/daemon-runtime.ts index 6438c7a16a..688d33b16d 100644 --- a/packages/daemon/src/server/http/daemon-runtime.ts +++ b/packages/daemon/src/server/http/daemon-runtime.ts @@ -893,8 +893,9 @@ async function startDaemonRuntimeOnHost( // Spawn the PNG worker ahead of the first screenshot request so its // cold-start cost is not paid on a user-visible call. Best effort: when it - // cannot start, PNG processing falls back to the in-process sync path. - prewarmPngWorker(); + // cannot start, PNG processing falls back to the in-process sync path and the daemon log + // records why. + await withDaemonDiagnosticsScope({ logPath }, prewarmPngWorker); const shutdownOnHost = async (shutdownOptions: { exitCode?: number; cause?: unknown } = {}) => { idleReap.cancel(); From d1f3001848ed7cd03ceed264651c9cfd38132c0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 18 Oct 2026 08:54:13 +0200 Subject: [PATCH 33/33] refactor(capture-kit): report each PNG worker transition to unavailable once, through a daemon-registered sink with a typed reason Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- .../src/png-worker-client-unavailable.test.ts | 50 ++++-------- packages/capture-kit/src/png-worker-client.ts | 62 +++++++-------- .../http/daemon-runtime-png-worker.test.ts | 77 +++++++++++++++---- .../daemon/src/server/http/daemon-runtime.ts | 11 ++- 5 files changed, 118 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98eabe91e0..c0b3ecaa40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- The PNG worker no longer calls `process.emitWarning` when it is unavailable, and it starts with an empty environment (`env: {}`). The daemon pre-warms it inside a daemon diagnostics scope, so a worker that cannot start leaves a `png_worker_unavailable` event in `daemon.log`; every request that takes the in-process fallback reports the same event in its own diagnostics. +- The PNG worker no longer calls `process.emitWarning` when it is unavailable, and it starts with an empty environment (`env: {}`). The daemon registers a sink when it pre-warms the worker, so every transition to unavailable (spawn failure, missing entry, load error, exit) leaves exactly one `png_worker_unavailable` event, with a typed `reason` (`entry-not-found`, `spawn-failed`, `worker-error`, `worker-exited`) and its `detail`, in `daemon.log`, with or without `--debug`. Requests then continue on the in-process fallback without repeating the event. - 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 `. diff --git a/packages/capture-kit/src/png-worker-client-unavailable.test.ts b/packages/capture-kit/src/png-worker-client-unavailable.test.ts index 2d6bc721b9..0ba3a38d08 100644 --- a/packages/capture-kit/src/png-worker-client-unavailable.test.ts +++ b/packages/capture-kit/src/png-worker-client-unavailable.test.ts @@ -2,34 +2,25 @@ import { EventEmitter } from 'node:events'; import { expect, test, vi } from 'vitest'; import { PNG } from './png.ts'; -test('every scope that reaches the fallback reports the unavailable worker', async () => { +test('a worker whose entry is missing is reported once and later registrations hear it', async () => { vi.resetModules(); vi.doMock('./internal-entry.ts', () => ({ resolveInternalEntryModulePath: () => null })); - // Loaded after the reset so the client and the scope share one diagnostics module. - const { countDiagnosticEventsByPhase, withDiagnosticsScope } = - await import('@agent-device/host-kit/diagnostics'); const { decodePngAsync, prewarmPngWorker } = await import('./png-worker-client.ts'); const png = PNG.sync.write(new PNG({ width: 1, height: 1 })); + const first = vi.fn(); + const second = vi.fn(); - const prewarmed = await withDiagnosticsScope({ command: 'daemon' }, async () => { - prewarmPngWorker(); - return countDiagnosticEventsByPhase(['png_worker_unavailable']); - }); - const scopes = []; - for (const label of ['first', 'second']) { - scopes.push( - await withDiagnosticsScope({ command: 'screenshot' }, async () => { - await decodePngAsync(png, label); - return countDiagnosticEventsByPhase(['png_worker_unavailable']); - }), - ); - } + prewarmPngWorker(first); + const decoded = await decodePngAsync(png, 'fallback'); + prewarmPngWorker(second); - expect({ prewarmed, scopes }).toEqual({ prewarmed: 1, scopes: [1, 1] }); + expect(decoded.width).toBe(1); + expect(first.mock.calls).toEqual([[{ kind: 'entry-not-found' }]]); + expect(second.mock.calls).toEqual([[{ kind: 'entry-not-found' }]]); vi.doUnmock('./internal-entry.ts'); }); -test('a worker that dies under a request is reported in that request, not only the next one', async () => { +test('a worker that dies after it started is reported once, and its pending job still completes', async () => { vi.resetModules(); class FakeWorker extends EventEmitter { static last: FakeWorker | undefined; @@ -48,26 +39,19 @@ test('a worker that dies under a request is reported in that request, not only t ...(await importOriginal()), Worker: FakeWorker, })); - const { countDiagnosticEventsByPhase, withDiagnosticsScope } = - await import('@agent-device/host-kit/diagnostics'); const { decodePngAsync, prewarmPngWorker } = await import('./png-worker-client.ts'); const png = PNG.sync.write(new PNG({ width: 1, height: 1 })); - prewarmPngWorker(); + const unavailable = vi.fn(); + prewarmPngWorker(unavailable); + expect(unavailable).not.toHaveBeenCalled(); - let jobStarted!: () => void; - const started = new Promise((resolve) => (jobStarted = resolve)); - const scoped = withDiagnosticsScope({ command: 'screenshot' }, async () => { - const decoded = decodePngAsync(png, 'under-request'); - jobStarted(); - await decoded; - return countDiagnosticEventsByPhase(['png_worker_unavailable']); - }); - await started; - // The worker's own error event carries no diagnostics scope. + const decoded = decodePngAsync(png, 'under-request'); const worker = FakeWorker.last; if (!worker) throw new Error('the pre-warm did not start a worker'); worker.emit('error', new Error('worker crashed')); + worker.emit('exit', 1); - expect(await scoped).toBe(1); + expect((await decoded).width).toBe(1); + expect(unavailable.mock.calls).toEqual([[{ kind: 'worker-error', detail: 'worker crashed' }]]); vi.doUnmock('node:worker_threads'); }); diff --git a/packages/capture-kit/src/png-worker-client.ts b/packages/capture-kit/src/png-worker-client.ts index df4ad09879..bffd2e7d48 100644 --- a/packages/capture-kit/src/png-worker-client.ts +++ b/packages/capture-kit/src/png-worker-client.ts @@ -1,5 +1,4 @@ import { Worker } from 'node:worker_threads'; -import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { AppError, toAppErrorCode } from '@agent-device/kernel/errors'; import type { Rect } from '@agent-device/kernel/snapshot'; import { resolveInternalEntryModulePath } from './internal-entry.ts'; @@ -29,6 +28,12 @@ import { const PNG_WORKER_ENTRYPOINT = 'png-worker'; +/** Why the worker left service for good; `detail` carries the underlying error text. */ +export type PngWorkerUnavailableReason = { + kind: 'entry-not-found' | 'spawn-failed' | 'worker-error' | 'worker-exited'; + detail?: string; +}; + /** Worker-infrastructure failure: the generic runner falls back to the sync path. */ class PngWorkerUnavailableError extends Error {} @@ -38,23 +43,16 @@ type PendingJob = { }; let worker: Worker | null = null; -let workerUnavailable = false; -let workerUnavailableReason: string | undefined; +let unavailableReason: PngWorkerUnavailableReason | undefined; +let reportUnavailable: ((reason: PngWorkerUnavailableReason) => void) | undefined; let nextJobId = 0; const pendingJobs = new Map(); -/** Permanently degrades to the sync path; a scope that reaches the fallback reports the reason. */ -function markWorkerUnavailable(reason: string): void { - workerUnavailable = true; - workerUnavailableReason ??= reason; -} - -function reportWorkerUnavailable(fallbackReason: string): void { - emitDiagnostic({ - level: 'warn', - phase: 'png_worker_unavailable', - data: { reason: workerUnavailableReason ?? fallbackReason }, - }); +/** Permanently degrades to the sync path; the daemon's sink hears each transition once. */ +function markWorkerUnavailable(reason: PngWorkerUnavailableReason): void { + if (unavailableReason) return; + unavailableReason = reason; + reportUnavailable?.(reason); } function handleWorkerMessage(message: PngWorkerResponse): void { @@ -75,14 +73,14 @@ function handleWorkerMessage(message: PngWorkerResponse): void { } } -function handleWorkerFailure(failed: Worker, error: Error): void { +function handleWorkerFailure(failed: Worker, reason: PngWorkerUnavailableReason): void { if (worker !== failed) return; // Keep the failure handling conservative: after any worker-level error the // daemon permanently falls back to the in-process synchronous path. - markWorkerUnavailable(error.message); + markWorkerUnavailable(reason); worker = null; void failed.terminate().catch(() => {}); - rejectPendingJobs(new PngWorkerUnavailableError(`PNG worker failed: ${error.message}`)); + rejectPendingJobs(new PngWorkerUnavailableError(`PNG worker failed: ${reason.kind}`)); } function rejectPendingJobs(error: Error): void { @@ -103,11 +101,11 @@ function updateWorkerRef(): void { } function obtainWorker(): Worker | null { - if (workerUnavailable) return null; + if (unavailableReason) return null; if (worker) return worker; const modulePath = resolveInternalEntryModulePath(import.meta.url, PNG_WORKER_ENTRYPOINT); if (!modulePath) { - markWorkerUnavailable('worker entry module not found next to the current module'); + markWorkerUnavailable({ kind: 'entry-not-found' }); return null; } try { @@ -118,18 +116,19 @@ function obtainWorker(): Worker | null { }); created.on('message', handleWorkerMessage); created.on('error', (error) => { - handleWorkerFailure(created, error); + handleWorkerFailure(created, { kind: 'worker-error', detail: error.message }); }); created.on('exit', (code) => { - handleWorkerFailure(created, new Error(`PNG worker exited with code ${code}`)); + handleWorkerFailure(created, { kind: 'worker-exited', detail: `exit code ${code}` }); }); created.unref(); worker = created; return created; } catch (error) { - markWorkerUnavailable( - `failed to spawn worker: ${error instanceof Error ? error.message : String(error)}`, - ); + markWorkerUnavailable({ + kind: 'spawn-failed', + detail: error instanceof Error ? error.message : String(error), + }); return null; } } @@ -180,7 +179,6 @@ async function runPngJob( return await runWorkerJob(job); } catch (error) { if (error instanceof PngWorkerUnavailableError) { - reportWorkerUnavailable(error.message); return await runSync(); } throw error; @@ -188,11 +186,15 @@ async function runPngJob( } /** - * Daemon startup hook: spawns the worker before the first screenshot job. Run it inside a - * diagnostics scope so a worker that cannot start leaves its reason in the daemon log. + * Daemon startup hook: spawns the worker before the first screenshot job and registers the daemon's + * sink, which hears every later transition to unavailable once, inside or outside a request. */ -export function prewarmPngWorker(): void { - if (obtainWorker() === null) reportWorkerUnavailable('PNG worker is unavailable'); +export function prewarmPngWorker( + onUnavailable: (reason: PngWorkerUnavailableReason) => void, +): void { + reportUnavailable = onUnavailable; + if (unavailableReason) onUnavailable(unavailableReason); + else obtainWorker(); } /** Stops the worker thread (used by tests and shutdown); later calls respawn it. */ diff --git a/packages/daemon/src/server/http/daemon-runtime-png-worker.test.ts b/packages/daemon/src/server/http/daemon-runtime-png-worker.test.ts index 1f6b6b954b..d481c30442 100644 --- a/packages/daemon/src/server/http/daemon-runtime-png-worker.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-png-worker.test.ts @@ -1,22 +1,41 @@ +import type { EventEmitter } from 'node:events'; import fs from 'node:fs'; import { expect, test, vi } from 'vitest'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; import { daemonRuntimeDepsFixture } from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; -import { startDaemonRuntime } from './daemon-runtime.ts'; -vi.mock('node:worker_threads', async (importOriginal) => ({ - ...(await importOriginal()), - Worker: class { - constructor() { - throw new Error('worker threads are not available'); - } - }, +const workers = vi.hoisted(() => ({ + spawn: 'start' as 'start' | 'throw', + last: undefined as unknown, })); +vi.mock('node:worker_threads', async (importOriginal) => { + const { EventEmitter: Emitter } = await import('node:events'); + return { + ...(await importOriginal()), + Worker: class extends Emitter { + constructor() { + super(); + if (workers.spawn === 'throw') throw new Error('worker threads are not available'); + workers.last = this; + } + postMessage(): void {} + ref(): void {} + unref(): void {} + terminate(): Promise { + return Promise.resolve(0); + } + }, + }; +}); -test('a daemon whose PNG worker cannot start records the reason in daemon.log', async () => { +async function pngWorkerEventsAfter(spawn: 'start' | 'throw', act: () => void): Promise { + workers.spawn = spawn; + // The worker client keeps its state per module instance, so each daemon gets a fresh one. + vi.resetModules(); + const { startDaemonRuntime } = await import('./daemon-runtime.ts'); const stateDir = mkdtempForTestSync('agent-device-daemon-png-worker-'); - let runtime: Awaited> = null; + let runtime: Awaited> | null = null; try { runtime = await startDaemonRuntime({ host: createTestHostEnvironment({ @@ -33,15 +52,39 @@ test('a daemon whose PNG worker cannot start records the reason in daemon.log', composeDeps: daemonRuntimeDepsFixture({}), }); expect(runtime).not.toBeNull(); - - const events = fs - .readFileSync(resolveDaemonPaths(stateDir).logPath, 'utf8') - .split('\n') - .filter((line) => line.includes('png_worker_unavailable')); - expect(events).toHaveLength(1); - expect(events[0]).toContain('failed to spawn worker: worker threads are not available'); + act(); + const { logPath } = resolveDaemonPaths(stateDir); + const readEvents = () => + fs + .readFileSync(logPath, 'utf8') + .split('\n') + .filter((line) => line.includes('png_worker_unavailable')); + await vi.waitFor(() => expect(readEvents().length).toBeGreaterThan(0)); + // A second report would arrive on the same turn as the first; let it. + await new Promise((resolve) => setTimeout(resolve, 50)); + return readEvents(); } finally { await runtime?.shutdown(); fs.rmSync(stateDir, { recursive: true, force: true }); } +} + +test('a daemon whose PNG worker cannot start records the reason in daemon.log once', async () => { + const events = await pngWorkerEventsAfter('throw', () => {}); + + expect(events).toHaveLength(1); + expect(events[0]).toContain('spawn-failed'); + expect(events[0]).toContain('worker threads are not available'); +}); + +test('a worker that fails after it started is recorded in daemon.log once, outside any request', async () => { + const events = await pngWorkerEventsAfter('start', () => { + const worker = workers.last as EventEmitter; + worker.emit('error', new Error('worker crashed')); + worker.emit('exit', 1); + }); + + expect(events).toHaveLength(1); + expect(events[0]).toContain('worker-error'); + expect(events[0]).toContain('worker crashed'); }); diff --git a/packages/daemon/src/server/http/daemon-runtime.ts b/packages/daemon/src/server/http/daemon-runtime.ts index 688d33b16d..cc4b0f0ea4 100644 --- a/packages/daemon/src/server/http/daemon-runtime.ts +++ b/packages/daemon/src/server/http/daemon-runtime.ts @@ -893,9 +893,14 @@ async function startDaemonRuntimeOnHost( // Spawn the PNG worker ahead of the first screenshot request so its // cold-start cost is not paid on a user-visible call. Best effort: when it - // cannot start, PNG processing falls back to the in-process sync path and the daemon log - // records why. - await withDaemonDiagnosticsScope({ logPath }, prewarmPngWorker); + // cannot start or later stops, PNG processing falls back to the in-process sync path and the + // daemon log records why, once, outside any request. + prewarmPngWorker((reason) => { + void emitDaemonDiagnostic(logPath, 'png_worker_unavailable', { + reason: reason.kind, + detail: reason.detail, + }).catch(() => {}); + }); const shutdownOnHost = async (shutdownOptions: { exitCode?: number; cause?: unknown } = {}) => { idleReap.cancel();