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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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 });