From 2fd6c3fca24e840e53fb0391113887e3af1a809e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 11:16:36 +0200 Subject: [PATCH 1/9] refactor(host): thread the client host through remote artifact preparation and materialization Co-Authored-By: Claude Sonnet 5.5 --- .../daemon-artifacts-save-script.test.ts | 24 +++++- .../daemon-artifacts-test-command.test.ts | 5 ++ .../upload-client-cancellation.test.ts | 3 + .../src/__tests__/upload-client.test.ts | 22 +++++- .../src/__tests__/upload-progress.test.ts | 2 +- packages/remote/src/daemon-artifacts.ts | 73 +++++++++++++------ packages/remote/src/remote-config-core.ts | 24 +++--- packages/remote/src/upload-client-artifact.ts | 12 ++- packages/remote/src/upload-client.ts | 5 +- packages/remote/src/upload-progress.ts | 7 +- src/cli/commands/connection.ts | 2 +- src/cli/connection/cloud-profile.ts | 2 +- .../connection/connect-provider-adapters.ts | 10 +-- src/cli/connection/generated-config.ts | 4 +- src/cli/connection/limrun-profile.ts | 5 +- src/cli/connection/proxy-profile.ts | 4 +- .../boundary-fault-acceptance.test.ts | 7 +- .../boundary-fault-transport.test.ts | 7 ++ .../daemon-client-abort-timeout.test.ts | 7 +- .../daemon-client-timeout-route.test.ts | 15 +++- .../__tests__/daemon-client-transport.test.ts | 3 + src/daemon-client/daemon-client-lease-beat.ts | 2 +- src/daemon-client/daemon-client-quiesce.ts | 2 +- src/daemon-client/daemon-client-rpc.test.ts | 2 + src/daemon-client/daemon-client-rpc.ts | 11 ++- src/daemon-client/daemon-client-transport.ts | 8 +- src/daemon-client/daemon-client.ts | 3 + src/sdk/remote-config.test.ts | 24 ++++++ src/sdk/remote-config.ts | 14 +++- .../integration/provider-scenarios/harness.ts | 1 + .../remote-daemon-client.test.ts | 4 + .../remote-proxy-artifact-tenant.test.ts | 3 +- ...ote-test-artifacts-materialization.test.ts | 2 + test/wire-compat/closure-policy.ts | 2 + test/wire-compat/ledger.json | 24 +++--- 35 files changed, 263 insertions(+), 82 deletions(-) create mode 100644 src/sdk/remote-config.test.ts diff --git a/packages/remote/src/__tests__/daemon-artifacts-save-script.test.ts b/packages/remote/src/__tests__/daemon-artifacts-save-script.test.ts index 137217d2ba..5cb0b64a60 100644 --- a/packages/remote/src/__tests__/daemon-artifacts-save-script.test.ts +++ b/packages/remote/src/__tests__/daemon-artifacts-save-script.test.ts @@ -7,6 +7,7 @@ * remote request that would produce a file the caller can never see is refused here instead of * appearing to succeed. */ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; @@ -28,7 +29,13 @@ function replayRequest(saveScript?: boolean | string) { test('a remote daemon refuses --save-script, naming the caller/daemon split', async () => { await assert.rejects( - async () => await prepareRemoteRequestArtifacts(replayRequest(true), REMOTE, NO_CANCELLATION), + async () => + await prepareRemoteRequestArtifacts( + replayRequest(true), + REMOTE, + NO_CANCELLATION, + createTestHostEnvironment(), + ), (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS' && @@ -43,20 +50,31 @@ test('a remote daemon refuses an explicit --save-script output path too', async replayRequest('./flows/login.healed.ad'), REMOTE, NO_CANCELLATION, + createTestHostEnvironment(), ), (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS', ); }); test('a local daemon leaves --save-script untouched', async () => { - const prepared = await prepareRemoteRequestArtifacts(replayRequest(true), LOCAL, NO_CANCELLATION); + const prepared = await prepareRemoteRequestArtifacts( + replayRequest(true), + LOCAL, + NO_CANCELLATION, + createTestHostEnvironment(), + ); assert.equal(prepared.flags?.saveScript, true); assert.deepEqual(prepared.positionals, ['./flows/login.ad']); }); test('a remote replay without --save-script passes through unchanged', async () => { - const prepared = await prepareRemoteRequestArtifacts(replayRequest(), REMOTE, NO_CANCELLATION); + const prepared = await prepareRemoteRequestArtifacts( + replayRequest(), + REMOTE, + NO_CANCELLATION, + createTestHostEnvironment(), + ); assert.deepEqual(prepared.positionals, ['./flows/login.ad']); assert.equal(prepared.uploadedArtifactId, undefined); diff --git a/packages/remote/src/__tests__/daemon-artifacts-test-command.test.ts b/packages/remote/src/__tests__/daemon-artifacts-test-command.test.ts index 898ecc3543..a672274891 100644 --- a/packages/remote/src/__tests__/daemon-artifacts-test-command.test.ts +++ b/packages/remote/src/__tests__/daemon-artifacts-test-command.test.ts @@ -5,6 +5,7 @@ * command: the client resolves the real local artifacts root itself and redirects the daemon to * a temp path it owns, exactly as it already does for `screenshot`/`record start`. */ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import path from 'node:path'; import { test } from 'vitest'; @@ -29,6 +30,7 @@ test('a remote daemon redirects an explicit --artifacts-dir to a temp path it ow testRequest('remote-device-artifacts/ad-test'), REMOTE, NO_CANCELLATION, + createTestHostEnvironment(), ); const redirected = (prepared.flags as Record | undefined)?.artifactsDir; @@ -45,6 +47,7 @@ test('a remote daemon redirects the default artifacts directory too', async () = testRequest(undefined), REMOTE, NO_CANCELLATION, + createTestHostEnvironment(), ); const redirected = (prepared.flags as Record | undefined)?.artifactsDir; @@ -61,6 +64,7 @@ test('a remote daemon leaves an already-absolute --artifacts-dir as the download testRequest('/ci/artifacts/ad-test'), REMOTE, NO_CANCELLATION, + createTestHostEnvironment(), ); const redirected = (prepared.flags as Record | undefined)?.artifactsDir; @@ -73,6 +77,7 @@ test('a local daemon leaves --artifacts-dir untouched', async () => { testRequest('remote-device-artifacts/ad-test'), LOCAL, NO_CANCELLATION, + createTestHostEnvironment(), ); assert.equal( diff --git a/packages/remote/src/__tests__/upload-client-cancellation.test.ts b/packages/remote/src/__tests__/upload-client-cancellation.test.ts index 4c961d8b64..a2949e1ffb 100644 --- a/packages/remote/src/__tests__/upload-client-cancellation.test.ts +++ b/packages/remote/src/__tests__/upload-client-cancellation.test.ts @@ -62,6 +62,7 @@ test('an aborted signal ends a legacy upload mid-stream instead of finishing the await assert.rejects( async () => await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -92,6 +93,7 @@ test('an aborted signal before preflight refuses to ask the daemon for a ticket' await assert.rejects( async () => await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -129,6 +131,7 @@ test('an upload with no signal behaves exactly as before', async () => { try { const uploadId = await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, diff --git a/packages/remote/src/__tests__/upload-client.test.ts b/packages/remote/src/__tests__/upload-client.test.ts index 4d782b0739..bc2a0be605 100644 --- a/packages/remote/src/__tests__/upload-client.test.ts +++ b/packages/remote/src/__tests__/upload-client.test.ts @@ -61,6 +61,7 @@ test('uploadArtifact returns preflight uploadId without uploading bytes on cache try { const uploadId = await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -104,6 +105,7 @@ test('uploadArtifact uploads with hash headers after preflight cache miss', asyn try { const uploadId = await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -143,6 +145,7 @@ test('uploadArtifact falls back to upload when preflight is unsupported', async try { const uploadId = await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -191,6 +194,7 @@ test('uploadArtifact falls back to upload when preflight fails', async () => { try { const uploadId = await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -244,6 +248,7 @@ test('uploadArtifact follows up to five upload redirects', async () => { try { const uploadId = await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -296,6 +301,7 @@ test('uploadArtifact fails cleanly on the sixth upload redirect', async () => { await assert.rejects( async () => await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -359,6 +365,7 @@ test('uploadArtifact falls back to legacy upload when direct upload exceeds redi try { const uploadId = await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -441,6 +448,7 @@ test('uploadArtifact uses direct upload ticket and finalize flow', async () => { try { const uploadId = await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -529,6 +537,7 @@ test.each([ try { const uploadId = await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -611,6 +620,7 @@ test('uploadArtifact resumes a direct upload from the server-reported offset', a try { const uploadId = await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -712,6 +722,7 @@ test('uploadArtifact re-preflights and resumes after an interrupted direct uploa try { const uploadId = await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -784,6 +795,7 @@ test('uploadArtifact preflights and legacy-uploads compressed app bundle directo try { const uploadId = await uploadArtifact({ + env: process.env, localPath: appPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -830,6 +842,7 @@ test('uploadArtifact disables macOS AppleDouble entries when archiving app bundl }, async () => await uploadArtifact({ + env: process.env, localPath: appPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -883,6 +896,7 @@ test('uploadArtifact uploads APK, AAB, and IPA files without wrapping them', asy try { await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -917,7 +931,13 @@ test.each([ const localPath = createTempFile('app.apk', `${flow}-payload`); for (const leaseId of ['lease-1', undefined]) { leases = new Map(); - await uploadArtifact({ localPath, baseUrl: server.baseUrl, token: TEST_TOKEN, leaseId }); + await uploadArtifact({ + env: process.env, + localPath, + baseUrl: server.baseUrl, + token: TEST_TOKEN, + leaseId, + }); const expected = routes.map((route) => [route, route === unleased ? undefined : leaseId]); assert.deepEqual([...leases], expected); } diff --git a/packages/remote/src/__tests__/upload-progress.test.ts b/packages/remote/src/__tests__/upload-progress.test.ts index 91423c4342..adb6e7c7c5 100644 --- a/packages/remote/src/__tests__/upload-progress.test.ts +++ b/packages/remote/src/__tests__/upload-progress.test.ts @@ -15,7 +15,7 @@ function renderEvents(events: UploadProgressEvent[]): string { const isTTYDescriptor = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY'); Object.defineProperty(process.stderr, 'isTTY', { configurable: true, value: true }); try { - const sink = createStderrUploadProgressReporter(); + const sink = createStderrUploadProgressReporter(process.env); assert.equal(sink !== undefined, true); for (const event of events) sink!(event); } finally { diff --git a/packages/remote/src/daemon-artifacts.ts b/packages/remote/src/daemon-artifacts.ts index f0537ec3ed..5c80b18365 100644 --- a/packages/remote/src/daemon-artifacts.ts +++ b/packages/remote/src/daemon-artifacts.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; import type { DaemonArtifact, DaemonResponse } from '@agent-device/kernel/contracts'; import fs from 'node:fs'; @@ -38,13 +39,14 @@ export async function prepareRemoteRequestArtifacts( req: Omit, info: DaemonArtifactEndpoint, signal: AbortSignal, + host: HostEnvironment, ): Promise { const positionals = [...(req.positionals ?? [])]; let flags = req.flags ? { ...req.flags } : undefined; let installSource = req.meta?.installSource; const clientArtifactPaths: Record = {}; let uploadedArtifactId: string | undefined; - const uploadProgress = createStderrUploadProgressReporter(); + const uploadProgress = createStderrUploadProgressReporter(host.env); if (!isRemoteDaemon(info)) { return createPreparedRemoteRequest({ @@ -57,8 +59,14 @@ export async function prepareRemoteRequestArtifacts( } assertRemoteDaemonSupportsSaveScript(req); - flags = applyRemoteArtifactCommand(req, positionals, flags, clientArtifactPaths); - const remoteInstallSource = await prepareRemoteInstallSource(req, info, uploadProgress, signal); + flags = applyRemoteArtifactCommand(req, positionals, flags, clientArtifactPaths, host); + const remoteInstallSource = await prepareRemoteInstallSource( + req, + info, + uploadProgress, + signal, + host, + ); if (remoteInstallSource) { installSource = remoteInstallSource.installSource; uploadedArtifactId = remoteInstallSource.uploadedArtifactId ?? uploadedArtifactId; @@ -80,6 +88,7 @@ export async function prepareRemoteRequestArtifacts( positionals, uploadProgress, signal, + host, ); uploadedArtifactId = installPackageResult ?? uploadedArtifactId; return baseResult(); @@ -112,6 +121,7 @@ async function prepareRemoteInstallPackage( positionals: string[], onProgress: UploadProgressSink | undefined, signal: AbortSignal, + host: HostEnvironment, ): Promise { const pathIndex = positionals.length === 1 ? 0 : 1; const rawPath = positionals[pathIndex]; @@ -121,10 +131,11 @@ async function prepareRemoteInstallPackage( return undefined; } - const localPath = resolveLocalInstallPath(rawPath, req.meta?.cwd); + const localPath = resolveLocalInstallPath(rawPath, req.meta?.cwd, host); if (!localPath) return undefined; return await uploadArtifact({ + env: host.env, localPath, baseUrl: info.baseUrl!, token: info.token, @@ -140,8 +151,9 @@ function applyRemoteArtifactCommand( positionals: string[], flags: DaemonWireRequest['flags'] | undefined, clientArtifactPaths: Record, + host: HostEnvironment, ): DaemonWireRequest['flags'] | undefined { - const remoteArtifact = prepareRemoteArtifactCommand(req, positionals); + const remoteArtifact = prepareRemoteArtifactCommand(req, positionals, host); if (!remoteArtifact) return flags; if (remoteArtifact.positionalIndex !== undefined && remoteArtifact.positionalPath !== undefined) { positionals[remoteArtifact.positionalIndex] = remoteArtifact.positionalPath; @@ -164,10 +176,12 @@ function applyRemoteArtifactFlag( return { ...(flags ?? {}), [flagKey]: flagPath }; } -function resolveLocalInstallPath(rawPath: string, cwd: string | undefined): string | undefined { - const localPath = path.isAbsolute(rawPath) - ? rawPath - : path.resolve(cwd ?? process.cwd(), rawPath); +function resolveLocalInstallPath( + rawPath: string, + cwd: string | undefined, + host: HostEnvironment, +): string | undefined { + const localPath = path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd ?? host.cwd, rawPath); return fs.existsSync(localPath) ? localPath : undefined; } @@ -190,6 +204,7 @@ async function prepareRemoteInstallSource( info: DaemonArtifactEndpoint, onProgress: UploadProgressSink | undefined, signal: AbortSignal, + host: HostEnvironment, ): Promise<{ installSource: NonNullable['installSource']; uploadedArtifactId?: string; @@ -214,7 +229,7 @@ async function prepareRemoteInstallSource( const localPath = path.isAbsolute(rawPath) ? rawPath - : path.resolve(req.meta?.cwd ?? process.cwd(), rawPath); + : path.resolve(req.meta?.cwd ?? host.cwd, rawPath); if (!fs.existsSync(localPath)) { return { installSource: { @@ -225,6 +240,7 @@ async function prepareRemoteInstallSource( } const uploadedArtifactId = await uploadArtifact({ + env: host.env, localPath, baseUrl: info.baseUrl!, token: info.token, @@ -245,6 +261,7 @@ async function prepareRemoteInstallSource( function prepareRemoteArtifactCommand( req: Omit, positionals: string[], + host: HostEnvironment, ): { field: string; localPath: string; @@ -254,7 +271,7 @@ function prepareRemoteArtifactCommand( flagPath?: string; } | null { if (req.command === 'screenshot') { - const localPath = resolveClientArtifactOutputPath(req, 'path', '.png'); + const localPath = resolveClientArtifactOutputPath(req, 'path', '.png', host); if (positionals[0]) { return { field: 'path', @@ -276,12 +293,12 @@ function prepareRemoteArtifactCommand( // this destination and records to a temp path on its own host. return { field: 'outPath', - localPath: resolveAbsoluteClientPath(`recording-${Date.now()}`, req.meta?.cwd), + localPath: resolveAbsoluteClientPath(`recording-${Date.now()}`, req.meta?.cwd, host), }; } const fallbackExtension = recordingFallbackExtension(req); const localPath = normalizeRecordingClientArtifactPath( - resolveClientArtifactOutputPath(req, 'outPath', fallbackExtension, 1), + resolveClientArtifactOutputPath(req, 'outPath', fallbackExtension, host, 1), req, ); return { @@ -301,7 +318,7 @@ function prepareRemoteArtifactCommand( // the same command: the daemon must never resolve a path against the caller's `cwd`. return { field: 'artifactsDir', - localPath: resolveClientArtifactOutputRoot(req), + localPath: resolveClientArtifactOutputRoot(req, host), flagKey: 'artifactsDir', flagPath: buildRemoteTempArtifactDirPath('test-artifacts'), }; @@ -309,10 +326,13 @@ function prepareRemoteArtifactCommand( return null; } -function resolveClientArtifactOutputRoot(req: Omit): string { +function resolveClientArtifactOutputRoot( + req: Omit, + host: HostEnvironment, +): string { const requested = req.flags?.artifactsDir; const rawPath = hasNonEmptyString(requested) ? requested : DEFAULT_TEST_ARTIFACTS_ROOT; - return resolveAbsoluteClientPath(rawPath, req.meta?.cwd); + return resolveAbsoluteClientPath(rawPath, req.meta?.cwd, host); } function recordingFallbackExtension(req: Omit): string { @@ -335,6 +355,7 @@ function resolveClientArtifactOutputPath( req: Omit, field: 'path' | 'outPath', fallbackExtension: string, + host: HostEnvironment, positionalIndex: number = 0, ): string { const requested = req.positionals?.[positionalIndex] ?? req.flags?.out; @@ -342,12 +363,17 @@ function resolveClientArtifactOutputPath( return resolveAbsoluteClientPath( hasNonEmptyString(requested) ? requested : fallbackName, req.meta?.cwd, + host, ); } /** Shared tail of every "what local path did the caller mean" resolution in this file. */ -function resolveAbsoluteClientPath(rawPath: string, cwd: string | undefined): string { - return path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd ?? process.cwd(), rawPath); +function resolveAbsoluteClientPath( + rawPath: string, + cwd: string | undefined, + host: HostEnvironment, +): string { + return path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd ?? host.cwd, rawPath); } function hasNonEmptyString(value: unknown): value is string { @@ -358,6 +384,7 @@ export async function materializeRemoteArtifacts( info: DaemonArtifactEndpoint, req: DaemonWireRequest, response: Extract, + host: HostEnvironment, ): Promise { const artifacts = Array.isArray(response.data?.artifacts) ? response.data.artifacts : []; if (artifacts.length === 0 || !info.baseUrl) return response; @@ -368,7 +395,7 @@ export async function materializeRemoteArtifacts( nextArtifacts.push(artifact); continue; } - const downloadPath = resolveMaterializedArtifactPath(artifact, req); + const downloadPath = resolveMaterializedArtifactPath(artifact, req, host); const materializedPath = await downloadRemoteArtifact({ baseUrl: info.baseUrl, token: info.token, @@ -394,7 +421,11 @@ export async function materializeRemoteArtifacts( return { ok: true, data: nextData }; } -function resolveMaterializedArtifactPath(artifact: DaemonArtifact, req: DaemonWireRequest): string { +function resolveMaterializedArtifactPath( + artifact: DaemonArtifact, + req: DaemonWireRequest, + host: HostEnvironment, +): string { if (artifact.localPath && artifact.localPath.trim().length > 0) { return artifact.localPath; } @@ -403,7 +434,7 @@ function resolveMaterializedArtifactPath(artifact: DaemonArtifact, req: DaemonWi return requestedPath; } const fallbackName = artifact.fileName?.trim() || `${artifact.field}-${Date.now()}`; - return path.resolve(req.meta?.cwd ?? process.cwd(), fallbackName); + return path.resolve(req.meta?.cwd ?? host.cwd, fallbackName); } type DownloadRemoteArtifactParams = { diff --git a/packages/remote/src/remote-config-core.ts b/packages/remote/src/remote-config-core.ts index 063e8527eb..f5913ebb6c 100644 --- a/packages/remote/src/remote-config-core.ts +++ b/packages/remote/src/remote-config-core.ts @@ -5,12 +5,18 @@ import { getRemoteConfigEnvNames, getRemoteConfigFieldSpec, type RemoteConfigProfile, - type RemoteConfigProfileOptions, type ResolvedRemoteConfigProfile, } from './remote-config-schema.ts'; import { AppError } from '@agent-device/kernel/errors'; import { resolveUserPath } from '@agent-device/host-kit/file'; -import { parseSourceValue } from '@agent-device/kernel/source-value'; +import { parseSourceValue, type EnvMap } from '@agent-device/kernel/source-value'; + +/** The remote-config location and the host environment it resolves against. */ +export type HostRemoteConfigProfileOptions = { + configPath: string; + cwd: string; + env: EnvMap; +}; // Deliberately narrower than `resolveRemoteConfigProfile`: this reads only // what the config *file itself* declares, with no ambient environment @@ -19,9 +25,9 @@ import { parseSourceValue } from '@agent-device/kernel/source-value'; // somewhere") must use this, not the env-merged resolver, or provenance is // lost — an env var is global and cannot say which endpoint it belongs to. export function readRemoteConfigFile( - options: RemoteConfigProfileOptions, + options: HostRemoteConfigProfileOptions, ): ResolvedRemoteConfigProfile { - const env = options.env ?? process.env; + const { env } = options; const resolvedPath = resolveRemoteConfigPath(options); if (!fs.existsSync(resolvedPath)) { throw new AppError('INVALID_ARGS', `Remote config file not found: ${resolvedPath}`); @@ -78,9 +84,7 @@ export function readRemoteConfigFile( return { resolvedPath, profile }; } -function readRemoteConfigEnvDefaults( - env: Record = process.env, -): RemoteConfigProfile { +function readRemoteConfigEnvDefaults(env: EnvMap): RemoteConfigProfile { const profile: RemoteConfigProfile = {}; for (const spec of REMOTE_CONFIG_PROFILE_FIELD_SPECS) { const envMatch = getRemoteConfigEnvNames(spec.key) @@ -113,13 +117,13 @@ function mergeRemoteConfigProfile( return merged; } -export function resolveRemoteConfigPath(options: RemoteConfigProfileOptions): string { - const env = options.env ?? process.env; +export function resolveRemoteConfigPath(options: HostRemoteConfigProfileOptions): string { + const { env } = options; return resolveUserPath(options.configPath, { cwd: options.cwd, env }); } export function resolveRemoteConfigProfile( - options: RemoteConfigProfileOptions, + options: HostRemoteConfigProfileOptions, ): ResolvedRemoteConfigProfile { const loaded = readRemoteConfigFile(options); return { diff --git a/packages/remote/src/upload-client-artifact.ts b/packages/remote/src/upload-client-artifact.ts index 2cd6296cbd..d4de4a4f9c 100644 --- a/packages/remote/src/upload-client-artifact.ts +++ b/packages/remote/src/upload-client-artifact.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; @@ -24,6 +25,7 @@ export type PreparedUploadArtifact = { export async function prepareUploadArtifact( localPath: string, requestedPlatform: string | undefined, + env: Readonly, ): Promise { const stat = fs.statSync(localPath); const fileName = path.basename(localPath); @@ -33,7 +35,7 @@ export async function prepareUploadArtifact( const cleanupPaths: string[] = []; try { const payloadPath = isDirectory - ? await createGzipTarArchive(localPath, cleanupPaths) + ? await createGzipTarArchive(localPath, cleanupPaths, env) : localPath; const payloadStat = fs.statSync(payloadPath); @@ -53,7 +55,11 @@ export async function prepareUploadArtifact( } } -async function createGzipTarArchive(localPath: string, cleanupPaths: string[]): Promise { +async function createGzipTarArchive( + localPath: string, + cleanupPaths: string[], + env: Readonly, +): Promise { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `agent-device-upload-${randomUUID()}-`)); cleanupPaths.push(tempDir); const archivePath = path.join(tempDir, `${path.basename(localPath)}.tar.gz`); @@ -62,7 +68,7 @@ async function createGzipTarArchive(localPath: string, cleanupPaths: string[]): ['czf', archivePath, '-C', path.dirname(localPath), path.basename(localPath)], { env: { - ...process.env, + ...env, COPYFILE_DISABLE: '1', }, }, diff --git a/packages/remote/src/upload-client.ts b/packages/remote/src/upload-client.ts index d503c4aa07..bc6526c704 100644 --- a/packages/remote/src/upload-client.ts +++ b/packages/remote/src/upload-client.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import { randomUUID } from 'node:crypto'; import { AppError } from '@agent-device/kernel/errors'; import { @@ -16,6 +17,8 @@ type UploadArtifactOptions = { baseUrl: string; token: string; platform?: string; + /** The environment a packaging command (`tar`) starts from. */ + env: Readonly; /** The lease this upload is for; every request to the daemon names it in a header. */ leaseId?: string; onProgress?: UploadProgressSink; @@ -54,7 +57,7 @@ type UploadPreflightResult = }; export async function uploadArtifact(options: UploadArtifactOptions): Promise { - const prepared = await prepareUploadArtifact(options.localPath, options.platform); + const prepared = await prepareUploadArtifact(options.localPath, options.platform, options.env); const normalizedBase = options.baseUrl.endsWith('/') ? options.baseUrl : `${options.baseUrl}/`; const uploadAttemptId = randomUUID(); diff --git a/packages/remote/src/upload-progress.ts b/packages/remote/src/upload-progress.ts index bc5560a9d1..8f537d5263 100644 --- a/packages/remote/src/upload-progress.ts +++ b/packages/remote/src/upload-progress.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import { Transform } from 'node:stream'; const DEFAULT_UPLOAD_PROGRESS_STEP_BYTES = 8 * 1024 * 1024; @@ -58,8 +59,10 @@ export function createUploadProgressTransform(options: { }); } -export function createStderrUploadProgressReporter(): UploadProgressSink | undefined { - if (process.stderr.isTTY !== true || process.env.CI) return undefined; +export function createStderrUploadProgressReporter( + env: Readonly, +): UploadProgressSink | undefined { + if (process.stderr.isTTY !== true || env.CI) return undefined; return (event) => { process.stderr.write(`${formatUploadProgressEvent(event)}\n`); }; diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 7a5083d445..9741225747 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -86,7 +86,7 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, connection: connectionMetadata, daemon: context.daemon, }); - const verification = await verifyResolvedConnectProvider(resolved); + const verification = await verifyResolvedConnectProvider(resolved, commandHostEnvironment().env); const state = buildConnectedState({ flags: connectFlags, scope, diff --git a/src/cli/connection/cloud-profile.ts b/src/cli/connection/cloud-profile.ts index 5af7d3fad5..5ea12be939 100644 --- a/src/cli/connection/cloud-profile.ts +++ b/src/cli/connection/cloud-profile.ts @@ -21,7 +21,7 @@ export async function resolveCloudConnectProfile(options: { flags: CliFlags; stateDir: string; cwd: string; - env?: EnvMap; + env: EnvMap; // di-seam-approved: injects the fetch global, which has no module boundary vi.mock can // intercept. Exercised at the CLI layer by cloud-connect-profile.test.ts via // vi.stubGlobal('fetch', ...), a layer where this parameter is not reachable. diff --git a/src/cli/connection/connect-provider-adapters.ts b/src/cli/connection/connect-provider-adapters.ts index 0b078c9cda..8ca1de5d20 100644 --- a/src/cli/connection/connect-provider-adapters.ts +++ b/src/cli/connection/connect-provider-adapters.ts @@ -102,11 +102,10 @@ export async function resolveConnectProviderProfile(options: { provider?: ConnectProvider; flags: CliFlags; stateDir: string; - cwd?: string; - env?: EnvMap; + cwd: string; + env: EnvMap; }): Promise { - const cwd = options.cwd ?? process.cwd(); - const env = options.env ?? process.env; + const { cwd, env } = options; if (options.flags.remoteConfig) { const resolved = resolveRemoteConfigProfile({ configPath: options.flags.remoteConfig, @@ -187,6 +186,7 @@ async function resolveProviderConnectionProfile( export async function verifyResolvedConnectProvider( resolved: ResolvedConnectProfile, + env: EnvMap, ): Promise { if (!resolved.provider) { return { @@ -198,7 +198,7 @@ export async function verifyResolvedConnectProvider( } const context = { flags: resolved.flags, - env: process.env, + env, }; const adapter = builtinConnectAdapter(resolved.provider); return adapter diff --git a/src/cli/connection/generated-config.ts b/src/cli/connection/generated-config.ts index 48168ecb71..e82ad7544e 100644 --- a/src/cli/connection/generated-config.ts +++ b/src/cli/connection/generated-config.ts @@ -37,7 +37,7 @@ export function writeGeneratedRemoteConfig(options: { function resolveGeneratedRemoteConfigProfile(options: { configPath: string; cwd: string; - env?: EnvMap; + env: EnvMap; provider: string; }): ResolvedRemoteConfigProfile { try { @@ -60,7 +60,7 @@ function resolveGeneratedRemoteConfigProfile(options: { export function persistAndResolveGeneratedProfile(options: { stateDir: string; cwd: string; - env?: EnvMap; + env: EnvMap; provider: string; profile: RemoteConfigProfile; flags: CliFlags; diff --git a/src/cli/connection/limrun-profile.ts b/src/cli/connection/limrun-profile.ts index 552fba7b14..2586e6b383 100644 --- a/src/cli/connection/limrun-profile.ts +++ b/src/cli/connection/limrun-profile.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import type { RemoteConfigProfile } from '@agent-device/remote/remote-config-schema'; import { AppError } from '@agent-device/kernel/errors'; @@ -20,9 +19,9 @@ export function resolveLimrunConnectProfile(options: { flags: CliFlags; stateDir: string; cwd: string; - env?: EnvMap; + env: EnvMap; }): { flags: CliFlags; remoteConfigPath: string } { - const env = options.env ?? commandHostEnvironment().env; + const { env } = options; const profile = buildLimrunRemoteProfile({ flags: options.flags, cwd: options.cwd, env }); const credentials = readLimrunCredentials(env); const platform = limrunPlatformForLeaseBackend(profile.leaseBackend) ?? 'android'; diff --git a/src/cli/connection/proxy-profile.ts b/src/cli/connection/proxy-profile.ts index 768bbfe7a6..fc3f90b5c6 100644 --- a/src/cli/connection/proxy-profile.ts +++ b/src/cli/connection/proxy-profile.ts @@ -11,9 +11,9 @@ export function resolveProxyConnectProfile(options: { flags: CliFlags; stateDir: string; cwd: string; - env?: EnvMap; + env: EnvMap; }): { flags: CliFlags; remoteConfigPath: string } { - const daemonBaseUrl = options.flags.daemonBaseUrl ?? options.env?.AGENT_DEVICE_DAEMON_BASE_URL; + const daemonBaseUrl = options.flags.daemonBaseUrl ?? options.env.AGENT_DEVICE_DAEMON_BASE_URL; if (!daemonBaseUrl) { throw new AppError( 'INVALID_ARGS', diff --git a/src/daemon-client/__tests__/boundary-fault-acceptance.test.ts b/src/daemon-client/__tests__/boundary-fault-acceptance.test.ts index 7b0e8b5ef5..2fff6d745b 100644 --- a/src/daemon-client/__tests__/boundary-fault-acceptance.test.ts +++ b/src/daemon-client/__tests__/boundary-fault-acceptance.test.ts @@ -4,7 +4,11 @@ import http from 'node:http'; import path from 'node:path'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { assertRejectsAppError, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + assertRejectsAppError, + mkdtempForTestSync, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { sendRequest } from '../daemon-client-transport.ts'; @@ -44,6 +48,7 @@ for (const row of Object.values(PROCESS_DEATH_ROWS)) { 'http', statePaths, 1_000, + { host: createTestHostEnvironment() }, ); } catch (error) { observed = error; diff --git a/src/daemon-client/__tests__/boundary-fault-transport.test.ts b/src/daemon-client/__tests__/boundary-fault-transport.test.ts index 3025d1fba0..0c59f48ffe 100644 --- a/src/daemon-client/__tests__/boundary-fault-transport.test.ts +++ b/src/daemon-client/__tests__/boundary-fault-transport.test.ts @@ -11,6 +11,7 @@ import { listenOnLoopback, type LoopbackServer, mkdtempForTestSync, + createTestHostEnvironment, } from '@agent-device/test-kit'; import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { sendRequest } from '../daemon-client-transport.ts'; @@ -60,6 +61,7 @@ for (const [commandClass, row] of Object.entries(COMMAND_ROWS)) { 'http', daemonPaths(), 60, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -95,6 +97,7 @@ for (const [commandClass, row] of Object.entries(COMMAND_ROWS)) { 'http', daemonPaths(), 1_000, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -136,6 +139,7 @@ test('HTTP RPC errors preserve typed identity through the client transport', asy 'http', daemonPaths(), 1_000, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -165,6 +169,7 @@ test('socket partial response fails at end-of-stream instead of waiting for the 'socket', daemonPaths(), 1_000, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -194,6 +199,7 @@ test('socket empty response fails at end-of-stream with request context', async 'socket', daemonPaths(), 1_000, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -222,6 +228,7 @@ test('socket malformed response stays typed at the daemon client seam', async () 'socket', daemonPaths(), 1_000, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); diff --git a/src/daemon-client/__tests__/daemon-client-abort-timeout.test.ts b/src/daemon-client/__tests__/daemon-client-abort-timeout.test.ts index 02d6997787..dcae9a7162 100644 --- a/src/daemon-client/__tests__/daemon-client-abort-timeout.test.ts +++ b/src/daemon-client/__tests__/daemon-client-abort-timeout.test.ts @@ -25,6 +25,7 @@ import { type SkippableTestContext, skipWhenLoopbackUnavailable, trackLoopbackSockets, + createTestHostEnvironment, } from '@agent-device/test-kit'; const { handleRequestTimeoutCalls } = vi.hoisted(() => ({ @@ -78,7 +79,9 @@ async function runTimerDiscipline( transport, STATE_PATHS, TIMEOUT_MS, - aborted ? { signal: controller.signal } : {}, + aborted + ? { host: createTestHostEnvironment(), signal: controller.signal } + : { host: createTestHostEnvironment() }, ); if (aborted) { const armedTimeoutIndex = setTimeoutSpy.mock.calls.findIndex( @@ -162,7 +165,7 @@ test('an abort during a restart health probe never retires the daemon or redispa 'auto', STATE_PATHS, 1_000, - { signal: controller.signal }, + { host: createTestHostEnvironment(), signal: controller.signal }, ); const rejection = assert.rejects(request, (error: unknown) => isRequestCanceledError(error)); await healthStarted; diff --git a/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts b/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts index a266293a47..1bd7e64a9f 100644 --- a/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts +++ b/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts @@ -48,6 +48,7 @@ import { type LoopbackServer, mkdtempForTestSync, skipWhenLoopbackUnavailable, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { @@ -362,6 +363,7 @@ for (const row of ROUTE_ROWS) { row.transport === 'remote' ? 'http' : row.transport, statePaths, TIMEOUT_MS, + { host: createTestHostEnvironment() }, ), row.hintPattern, ); @@ -406,6 +408,7 @@ test('request-timeout route: a reset never deletes a registration a replacement 'socket', statePaths, TIMEOUT_MS, + { host: createTestHostEnvironment() }, ), /The daemon could not be safely retired/, ); @@ -439,7 +442,14 @@ test('a refused timeout fallback preserves the timeout without an unhandled reje }); try { await assert.rejects( - sendRequest(info, buildRequest(RESET_POLICY_COMMAND, undefined), 'socket', paths, TIMEOUT_MS), + sendRequest( + info, + buildRequest(RESET_POLICY_COMMAND, undefined), + 'socket', + paths, + TIMEOUT_MS, + { host: createTestHostEnvironment() }, + ), (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.details?.reason, 'daemon_transport_timeout'); @@ -470,7 +480,7 @@ test('request-timeout route: an attach client leaves a daemon that answers no pr 'socket', paths, TIMEOUT_MS, - { timeoutRecovery: 'none' }, + { host: createTestHostEnvironment(), timeoutRecovery: 'none' }, ), /did not answer the liveness probe.*does not retire a daemon it did not start.*agent-device daemon stop/s, ); @@ -510,6 +520,7 @@ test('request-timeout route: the reset acts on the resolved state paths, not the 'http', resolvedPaths, TIMEOUT_MS, + { host: createTestHostEnvironment() }, ), /The daemon did not answer the liveness probe and was reset after the timeout/, ); diff --git a/src/daemon-client/__tests__/daemon-client-transport.test.ts b/src/daemon-client/__tests__/daemon-client-transport.test.ts index 54e4b5e6c5..a1319a5676 100644 --- a/src/daemon-client/__tests__/daemon-client-transport.test.ts +++ b/src/daemon-client/__tests__/daemon-client-transport.test.ts @@ -9,6 +9,7 @@ import { listenOnLoopback, mkdtempForTestSync, skipWhenLoopbackUnavailable, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { test, vi } from 'vitest'; import * as hostTransport from '@agent-device/host-kit/transport'; @@ -40,6 +41,7 @@ function sendWithStaleInstance(port: number, timeoutMs: number) { 'auto', resolveDaemonPaths('/tmp/agent-device-instance-retry-test'), timeoutMs, + { host: createTestHostEnvironment() }, ); } @@ -497,6 +499,7 @@ test.for(['timeout', 'reset'] as const)( 'http', paths, mode === 'timeout' ? 40 : 1_000, + { host: createTestHostEnvironment() }, ), ), (error: unknown) => { diff --git a/src/daemon-client/daemon-client-lease-beat.ts b/src/daemon-client/daemon-client-lease-beat.ts index 35ea71afbb..3ef65e2cd5 100644 --- a/src/daemon-client/daemon-client-lease-beat.ts +++ b/src/daemon-client/daemon-client-lease-beat.ts @@ -325,6 +325,6 @@ export function buildUploadLeaseHeartbeat( // The beat's own budget governs; the command's heartbeat policy only ever caps it, and an // unbounded policy leaves the budget standing on its own. policyTimeoutMs === undefined ? budgetMs : Math.min(policyTimeoutMs, budgetMs), - { signal }, + { host: settings.host, signal }, ); } diff --git a/src/daemon-client/daemon-client-quiesce.ts b/src/daemon-client/daemon-client-quiesce.ts index 7f35e3eb33..6a65992624 100644 --- a/src/daemon-client/daemon-client-quiesce.ts +++ b/src/daemon-client/daemon-client-quiesce.ts @@ -49,7 +49,7 @@ export async function requestDaemonQuiesce( 'auto', settings.paths, QUIESCE_TIMEOUT_MS, - { timeoutRecovery: 'none' }, + { host: settings.host, timeoutRecovery: 'none' }, ); } catch (error) { if (isDaemonTransportUnavailableError(error) || isConnectionFailure(error)) return 'gone'; diff --git a/src/daemon-client/daemon-client-rpc.test.ts b/src/daemon-client/daemon-client-rpc.test.ts index 7989e9daf8..2a8ec92267 100644 --- a/src/daemon-client/daemon-client-rpc.test.ts +++ b/src/daemon-client/daemon-client-rpc.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; @@ -161,6 +162,7 @@ test('HTTP RPC errors sanitize an untrusted cause before rehydration', () => { }, }), { + host: createTestHostEnvironment(), info: { token: 'daemon-token', pid: 1 }, req: { command: 'apps_install_source', diff --git a/src/daemon-client/daemon-client-rpc.ts b/src/daemon-client/daemon-client-rpc.ts index faa004b4cd..afe33b6201 100644 --- a/src/daemon-client/daemon-client-rpc.ts +++ b/src/daemon-client/daemon-client-rpc.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { @@ -24,11 +25,12 @@ export function handleDaemonHttpResponseBody( info: DaemonInfo; req: DaemonWireRequest; stateDir: string; + host: HostEnvironment; resolve: (response: DaemonResponse | PromiseLike) => void; reject: (error: unknown) => void; }, ): void { - const { info, req, stateDir, resolve, reject } = options; + const { info, req, stateDir, host, resolve, reject } = options; try { const parsed = parseDaemonHttpResponseBody(body); if (parsed.error) { @@ -43,7 +45,7 @@ export function handleDaemonHttpResponseBody( ); return; } - void resolveDaemonHttpResult(info, req, parsed.result, resolve, reject); + void resolveDaemonHttpResult(info, req, parsed.result, host, resolve, reject); } catch (err) { reject( new AppError( @@ -154,12 +156,15 @@ async function resolveDaemonHttpResult( info: DaemonInfo, req: DaemonWireRequest, result: DaemonResponse, + host: HostEnvironment, resolve: (response: DaemonResponse | PromiseLike) => void, reject: (error: unknown) => void, ): Promise { try { resolve( - info.baseUrl && result.ok ? await materializeRemoteArtifacts(info, req, result) : result, + info.baseUrl && result.ok + ? await materializeRemoteArtifacts(info, req, result, host) + : result, ); } catch (error) { reject(error); diff --git a/src/daemon-client/daemon-client-transport.ts b/src/daemon-client/daemon-client-transport.ts index a9a879287f..490647a700 100644 --- a/src/daemon-client/daemon-client-transport.ts +++ b/src/daemon-client/daemon-client-transport.ts @@ -25,10 +25,13 @@ import { import { buildHttpRpcPayload, handleDaemonHttpResponseBody } from './daemon-client-rpc.ts'; import { handleRequestTimeout } from './daemon-client-timeout.ts'; import { isRemoteDaemon, type DaemonInfo } from './daemon-client-metadata.ts'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { readVersion } from '@agent-device/host-kit/version'; type ResolvedDaemonTransport = 'socket' | 'http'; type SendRequestOptions = { + /** The host whose working directory and environment this request's client-side work reads. */ + host: HostEnvironment; onProgress?: RequestProgressSink; /** * The caller's per-request cancellation (#3178). Aborted at or before a send attempt, nothing @@ -378,7 +381,7 @@ export async function sendRequest( preference: DaemonTransportPreference, statePaths: DaemonPaths, timeoutMs: number | undefined, - options: SendRequestOptions = {}, + options: SendRequestOptions, ): Promise { try { return await sendRequestWithFallback(info, req, preference, statePaths, timeoutMs, options); @@ -402,7 +405,7 @@ async function sendRequestWithFallback( preference: DaemonTransportPreference, statePaths: DaemonPaths, timeoutMs: number | undefined, - options: SendRequestOptions = {}, + options: SendRequestOptions, ): Promise { const transport = chooseTransport(info, preference); const deadline = typeof timeoutMs === 'number' ? performance.now() + timeoutMs : undefined; @@ -777,6 +780,7 @@ async function sendHttpRequest( info, req, stateDir: statePaths.baseDir, + host: options.host, resolve: resolveOnce, reject: rejectOnce, }); diff --git a/src/daemon-client/daemon-client.ts b/src/daemon-client/daemon-client.ts index 49257ab3b0..22d4a5b7a7 100644 --- a/src/daemon-client/daemon-client.ts +++ b/src/daemon-client/daemon-client.ts @@ -146,6 +146,7 @@ async function sendToDaemonOnHost( settings.paths, requestTimeoutMs, { + host, onProgress: options.onProgress, signal: options.signal, timeoutRecovery: @@ -373,6 +374,7 @@ async function protectArtifactUploadWithLeaseBeats( request, info, callerSignal ?? new AbortController().signal, + settings.host, ); } const { buildUploadLeaseHeartbeat, runProtectedLeaseWork } = @@ -386,6 +388,7 @@ async function protectArtifactUploadWithLeaseBeats( request, info, callerSignal ? AbortSignal.any([leaseSignal, callerSignal]) : leaseSignal, + settings.host, ), callerSignal, }); diff --git a/src/sdk/remote-config.test.ts b/src/sdk/remote-config.test.ts new file mode 100644 index 0000000000..0d10d35e3c --- /dev/null +++ b/src/sdk/remote-config.test.ts @@ -0,0 +1,24 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { resolveRemoteConfigProfile } from './remote-config.ts'; + +test('the released remote-config entry reads the process environment when none is given', () => { + const root = mkdtempForTestSync('agent-device-sdk-remote-config-'); + const previous = process.env.AGENT_DEVICE_METRO_PREPARE_PORT; + try { + const configPath = path.join(root, 'demo.remote.json'); + fs.writeFileSync(configPath, JSON.stringify({ platform: 'ios' }), 'utf8'); + process.env.AGENT_DEVICE_METRO_PREPARE_PORT = '9191'; + + const resolved = resolveRemoteConfigProfile({ configPath, cwd: root }); + + assert.equal(resolved.profile.metroPreparePort, 9191); + } finally { + if (previous === undefined) delete process.env.AGENT_DEVICE_METRO_PREPARE_PORT; + else process.env.AGENT_DEVICE_METRO_PREPARE_PORT = previous; + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/sdk/remote-config.ts b/src/sdk/remote-config.ts index 315e44a2a2..29097df6c6 100644 --- a/src/sdk/remote-config.ts +++ b/src/sdk/remote-config.ts @@ -1,2 +1,14 @@ +import { resolveRemoteConfigProfile as resolveRemoteConfigProfileOnHost } from '@agent-device/remote/remote-config'; +import type { + RemoteConfigProfileOptions, + ResolvedRemoteConfigProfile, +} from '@agent-device/remote/remote-config-schema'; + export type { RemoteConfigProfile } from '@agent-device/remote/remote-config-schema'; -export { resolveRemoteConfigProfile } from '@agent-device/remote/remote-config'; + +/** The released entry: without `env`, the profile resolves against the process's environment. */ +export function resolveRemoteConfigProfile( + options: RemoteConfigProfileOptions, +): ResolvedRemoteConfigProfile { + return resolveRemoteConfigProfileOnHost({ ...options, env: options.env ?? process.env }); +} diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 7d2505f5a3..ce2786cc11 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -240,6 +240,7 @@ export async function createProviderScenarioHarness( }); return await new Promise((resolve, reject) => { handleDaemonHttpResponseBody(body, { + host: createTestHostEnvironment(), info: { transport: 'http', httpPort, diff --git a/test/integration/provider-scenarios/remote-daemon-client.test.ts b/test/integration/provider-scenarios/remote-daemon-client.test.ts index 77b7ccd1b6..c6153e8f74 100644 --- a/test/integration/provider-scenarios/remote-daemon-client.test.ts +++ b/test/integration/provider-scenarios/remote-daemon-client.test.ts @@ -12,6 +12,7 @@ import { closeLoopbackServer, listenOnLoopback, skipWhenLoopbackUnavailable, + createTestHostEnvironment, } from '@agent-device/test-kit'; const NO_CANCELLATION = new AbortController().signal; @@ -681,6 +682,7 @@ test('remote web recording defaults client and daemon artifact paths to WebM', a }, { baseUrl: 'http://127.0.0.1:1', token: 'remote-token' }, NO_CANCELLATION, + createTestHostEnvironment(), ); assert.equal(prepared.positionals[0], 'start'); @@ -702,6 +704,7 @@ test('remote web recording appends WebM extension to extensionless client paths' }, { baseUrl: 'http://127.0.0.1:1', token: 'remote-token' }, NO_CANCELLATION, + createTestHostEnvironment(), ); assert.equal(prepared.positionals[0], 'start'); @@ -720,6 +723,7 @@ test('remote recording without platform or requested path lets daemon name the c }, { baseUrl: 'http://127.0.0.1:1', token: 'remote-token' }, NO_CANCELLATION, + createTestHostEnvironment(), ); assert.deepEqual(prepared.positionals, ['start']); diff --git a/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts b/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts index 6cdb73743d..afcd3dc2ac 100644 --- a/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts +++ b/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts @@ -9,7 +9,7 @@ import { downloadRemoteArtifact, materializeRemoteArtifacts, } from '@agent-device/remote/daemon-artifacts'; -import { skipWhenLoopbackUnavailable } from '@agent-device/test-kit'; +import { skipWhenLoopbackUnavailable, createTestHostEnvironment } from '@agent-device/test-kit'; import { withProviderScenarioResource, withProviderScenarioTempDir } from './harness.ts'; import { createProviderScenarioProxy } from './harness-proxy.ts'; import { FAKE_PROVIDER, createFakeProviderWorld } from './provider-device-runtime.fixtures.ts'; @@ -151,6 +151,7 @@ test('Provider-backed integration proxy materializes tenant-owned test artifacts { baseUrl: proxy.baseUrl, token: TENANT_TOKEN }, request, response, + createTestHostEnvironment(), ); assert.equal(materialized.ok, true); if (!materialized.ok) return; diff --git a/test/integration/provider-scenarios/remote-test-artifacts-materialization.test.ts b/test/integration/provider-scenarios/remote-test-artifacts-materialization.test.ts index a54d6eeed0..fef49e562f 100644 --- a/test/integration/provider-scenarios/remote-test-artifacts-materialization.test.ts +++ b/test/integration/provider-scenarios/remote-test-artifacts-materialization.test.ts @@ -14,6 +14,7 @@ import { listenOnLoopback, mkdtempForTestSync, skipWhenLoopbackUnavailable, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { createProviderScenarioHarness, @@ -106,6 +107,7 @@ test('the composed daemon and client path materializes the directory produced by { baseUrl: proxy.baseUrl, token: PROXY_TOKEN }, request, response, + createTestHostEnvironment(), ); assert.equal(materialized.ok, true); if (!materialized.ok) return; diff --git a/test/wire-compat/closure-policy.ts b/test/wire-compat/closure-policy.ts index c818236106..16e892b2b9 100644 --- a/test/wire-compat/closure-policy.ts +++ b/test/wire-compat/closure-policy.ts @@ -35,6 +35,8 @@ const WIRE_EXTERNAL_MODULES: Readonly> = { * cannot change what a skewed peer parses. */ export const WIRE_CLOSURE_WAIVERS: Readonly> = { + 'packages/kernel/src/source-value.ts#HostEnvironment': + "The client process's own environment and working directory, read to resolve local paths. It never crosses the wire; only the paths it helps resolve do, and those are digested through the declarations that use them.", 'packages/kernel/src/errors.ts#AppError': 'The local error CLASS. Only its projection onto the wire is contractual, and that projection is DaemonError, which is listed.', 'packages/kernel/src/errors.ts#AppErrorCode': diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 57c6410b8d..152888f953 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -59,12 +59,12 @@ "src/daemon-client/daemon-client-rpc.ts#appErrorFromDaemonError": "sha256:ac4761006c71d93ccba9f8e37cbd4cf48283dbb22737c163dc9c1a878e154eea", "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload": "sha256:bbcf00e71d3975caba4add0e85537396b5831796f1af52cc8c62a4688882ebd7", "src/daemon-client/daemon-client-rpc.ts#buildLeaseRpcParams": "sha256:365490a0fe1833d7ce8b0d3f5425462608a85119b12c1a2c4f16bb83b5d28e06", - "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody": "sha256:8d5849a4e573d6bb8fbd4ee0259755b76116a65f4abe41705cf84bfab5834ce4", + "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody": "sha256:7f2913988208ce42c2f03b4c3124df25e3f887a4f987fbe448d7a6298f815c23", "src/daemon-client/daemon-client-rpc.ts#isLeaseRpcCommand": "sha256:955dc1b73462d40593e5728dbcbaeec1307944b08262a22627e84cc5dc507e8f", "src/daemon-client/daemon-client-rpc.ts#leaseRpcMethodForCommand": "sha256:3613555c6002c23ab5bec4b98bc1b4d6947014b52fa100176177f955cf762881", "src/daemon-client/daemon-client-rpc.ts#parseDaemonHttpResponseBody": "sha256:0cbe39dbd09e321ec00854a1626a7d89b6344e59c6faabe2fe02504268b2d886", "src/daemon-client/daemon-client-rpc.ts#rejectDaemonHttpRpcError": "sha256:a5cf6fd4a9abea34ec051f03d6e45e5aa248f480371fdf22f93de06622a57b47", - "src/daemon-client/daemon-client-rpc.ts#resolveDaemonHttpResult": "sha256:9432fffc59c11ff7ccf8c9ce7076559e5c043264045737f24cc4b8e67d76c3d7", + "src/daemon-client/daemon-client-rpc.ts#resolveDaemonHttpResult": "sha256:64dff5e16136cbc69f4692c64ea0ea55465155909c4da112ea1356da4fecf5e4", "src/daemon-client/daemon-client-rpc.ts#toDaemonHttpRpcError": "sha256:888246763c48670e7da893054d025744654f8715c3b4906312617a2b5028316b", "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealth": "sha256:3bac36fa97090b273afe1128103e1bf476d05441fd9de41317f1b78775b88304", "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealthLink": "sha256:7702598468b4c82b4ffa93064cd6bdfec938c1c527f7e6007c0584f3884a6eea", @@ -151,8 +151,8 @@ "packages/remote/src/daemon-artifacts.ts#buildDaemonArtifactUrl": "sha256:06dcde057809b507f70f7bb5cdef9bde6515be84b67e45865cf19e70b8636208", "packages/remote/src/daemon-artifacts.ts#downloadRemoteArtifact": "sha256:f3ab09ce387577073ecc5bcd2528963e45f91b2c1e4cc57847c3e2732ccbb300", "packages/remote/src/daemon-artifacts.ts#isRemoteDaemon": "sha256:0f6a714c5f60ce4e8548d811b516d8b6241eca9f7e5414733732da395b8a00e7", - "packages/remote/src/daemon-artifacts.ts#materializeRemoteArtifacts": "sha256:c3ea70cc9a218acfaa30f6c4ba55b80224c96aba10e4e9c7a17828fc851de77e", - "packages/remote/src/daemon-artifacts.ts#resolveMaterializedArtifactPath": "sha256:cad37beddeb03ba70bc92118a5e45f552edfc00feec789758e390d1ea4c7d879", + "packages/remote/src/daemon-artifacts.ts#materializeRemoteArtifacts": "sha256:fffe8ce578e8bcdea175d6a200bea364ffce0956c2cba65bea48d72c0aaea375", + "packages/remote/src/daemon-artifacts.ts#resolveMaterializedArtifactPath": "sha256:0cb422301edef0545615b0f3acb3d80280213921cab2eb529715260ff0fa3647", "packages/remote/src/remote-request-diagnostics.ts#RemoteDaemonErrorPayload": "sha256:8fbcbcdf4bae7b66a08e465a72593794f36364759f18f1a814cd39dfdb76f309", "packages/remote/src/remote-request-diagnostics.ts#RemoteDiagnosticsEndpoint": "sha256:c1d5fe2fe90c425502cf8b7c87d6606374d6af65b4e9faf942a416f683930bdc", "packages/remote/src/remote-request-diagnostics.ts#buildRemoteRequestDiagnosticsUrl": "sha256:30a1a9bf0df24d3564a616699916a3bb4360c2b4d21c4ad0562fb0c1e738d0a9", @@ -216,8 +216,8 @@ }, { "declaration": "packages/remote/src/daemon-artifacts.ts#materializeRemoteArtifacts", - "digest": "sha256:c3ea70cc9a218acfaa30f6c4ba55b80224c96aba10e4e9c7a17828fc851de77e", - "rationale": "#2246 passes isDirectory only for the new test-artifacts type and uses the exact path the client materializer published. Existing screenshot and recording artifacts still report their requested file path; only the new directory response gains its suite-invocation segment. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved." + "digest": "sha256:fffe8ce578e8bcdea175d6a200bea364ffce0956c2cba65bea48d72c0aaea375", + "rationale": "#2246 passes isDirectory only for the new test-artifacts type and uses the exact path the client materializer published. Existing screenshot and recording artifacts still report their requested file path; only the new directory response gains its suite-invocation segment. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved. The function now takes the client host as a parameter and resolves a relative local path against its working directory instead of the process's; the client binds the same directory, so nothing a peer parses moves." }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload", @@ -241,8 +241,8 @@ }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody", - "digest": "sha256:8d5849a4e573d6bb8fbd4ee0259755b76116a65f4abe41705cf84bfab5834ce4", - "rationale": "#1801 client-side only: the RPC error branch now runs through `rejectDaemonHttpRpcError`, which reads a superset of the fields it read before and, for a remote daemon, replaces the daemon-host `logPath` with a caller-local copy. Every field a released daemon sends is still accepted; nothing new is required of the daemon. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved." + "digest": "sha256:7f2913988208ce42c2f03b4c3124df25e3f887a4f987fbe448d7a6298f815c23", + "rationale": "#1801 client-side only: the RPC error branch now runs through `rejectDaemonHttpRpcError`, which reads a superset of the fields it read before and, for a remote daemon, replaces the daemon-host `logPath` with a caller-local copy. Every field a released daemon sends is still accepted; nothing new is required of the daemon. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved. The function now takes the client host as a parameter and resolves a relative local path against its working directory instead of the process's; the client binds the same directory, so nothing a peer parses moves." }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#toDaemonHttpRpcError", @@ -471,13 +471,13 @@ }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#resolveDaemonHttpResult", - "digest": "sha256:9432fffc59c11ff7ccf8c9ce7076559e5c043264045737f24cc4b8e67d76c3d7", - "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did." + "digest": "sha256:64dff5e16136cbc69f4692c64ea0ea55465155909c4da112ea1356da4fecf5e4", + "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did. The function now takes the client host as a parameter and resolves a relative local path against its working directory instead of the process's; the client binds the same directory, so nothing a peer parses moves." }, { "declaration": "packages/remote/src/daemon-artifacts.ts#resolveMaterializedArtifactPath", - "digest": "sha256:cad37beddeb03ba70bc92118a5e45f552edfc00feec789758e390d1ea4c7d879", - "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did." + "digest": "sha256:0cb422301edef0545615b0f3acb3d80280213921cab2eb529715260ff0fa3647", + "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did. The function now takes the client host as a parameter and resolves a relative local path against its working directory instead of the process's; the client binds the same directory, so nothing a peer parses moves." } ] } From 65a1acf7cf640c8a255bd70e32770c395265e096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 11:18:45 +0200 Subject: [PATCH 2/9] refactor(host): pass the composition's env to the platform runtime, diagnostics, toolchain and deployment hosts Co-Authored-By: Claude Sonnet 5.5 --- .../platform-services-fixture.ts | 4 +- src/daemon-composition.ts | 8 ++-- ...atform-runtime-android-adb-binding.test.ts | 4 +- ...untime-android-deployment-executor.test.ts | 4 +- ...orm-runtime-android-deployment-executor.ts | 7 ++- src/platform-runtime-claim-recovery.ts | 17 ++++--- ...-runtime-gateway-gesture-admission.test.ts | 1 + ...platform-runtime-gateway-recording.test.ts | 1 + src/platform-runtime-host-diagnostics.test.ts | 6 ++- src/platform-runtime-host-diagnostics.ts | 5 +- src/platform-runtime-host.test.ts | 2 +- src/platform-runtime-host.ts | 5 +- src/platform-runtime-operation-host.test.ts | 1 + src/platform-runtime-operation-host.ts | 6 ++- src/platform-runtime-perf-host.test.ts | 1 + src/platform-runtime-toolchain-host.test.ts | 2 +- src/platform-runtime-toolchain-host.ts | 5 +- src/platform-runtime.test.ts | 11 +++-- src/platform-runtime.ts | 47 ++++++++++--------- .../integration/provider-scenarios/harness.ts | 1 + .../provider-scenarios/linux-world.ts | 2 +- .../smoke-tvos-platform-coverage.test.ts | 1 + 22 files changed, 85 insertions(+), 56 deletions(-) diff --git a/src/__tests__/daemon-composition/platform-services-fixture.ts b/src/__tests__/daemon-composition/platform-services-fixture.ts index f88ebf7653..7a3a4fc51d 100644 --- a/src/__tests__/daemon-composition/platform-services-fixture.ts +++ b/src/__tests__/daemon-composition/platform-services-fixture.ts @@ -1,6 +1,6 @@ import { type ClaimRecoveryGatewayFactory } from '@agent-device/daemon-contracts/device-claim-recovery'; import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts'; -import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts'; +import { createClaimRecoveryGatewayFor } from '../../platform-runtime-claim-recovery.ts'; import type { DaemonPlatformServices } from '@agent-device/daemon'; /** @@ -22,5 +22,5 @@ export function composedPlatformServicesFixture( * passes its own factory. */ export function composedClaimRecoveryGatewayFixture(): ClaimRecoveryGatewayFactory { - return createClaimRecoveryGateway; + return createClaimRecoveryGatewayFor(process.env); } diff --git a/src/daemon-composition.ts b/src/daemon-composition.ts index 60d81b5594..13cc987ce0 100644 --- a/src/daemon-composition.ts +++ b/src/daemon-composition.ts @@ -85,13 +85,14 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( sessionsDir, resolveSessionDir, }); - const hostDiagnostics = createHostDiagnostics(); + const hostDiagnostics = createHostDiagnostics(env); const providerComposition = await createDaemonProviderRuntimeComposition(env); // The registry has set up every plugin. Until this function returns, nothing else owns its // cleanup, so any later failure closes it here; success transfers ownership to the daemon. try { const providerDeviceRuntimes = [...providerComposition.runtimes]; const deviceRuntimeGateway = createPlatformRuntimeGateway({ + env, assertShutdownAllowed, providerRuntimes: providerDeviceRuntimes, providerModules: providerComposition.platformModules, @@ -114,6 +115,7 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( }, }); const deviceInventoryGateways = await createPlatformDeviceInventoryGateways( + env, providerRuntimeProviders.deviceInventorySource, ); // Root composition of the platform-services port and the owner-scoped claim-recovery gateway. @@ -121,7 +123,7 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( // composition modules (and the readiness/open-target adapters only the services module pulls) // off `src/daemon.ts`'s eager closure; what each adapter itself loads stays as lazy as it was. const { createDaemonPlatformServices } = await import('./platform-runtime-daemon-services.ts'); - const { createClaimRecoveryGateway } = await import('./platform-runtime-claim-recovery.ts'); + const { createClaimRecoveryGatewayFor } = await import('./platform-runtime-claim-recovery.ts'); return Object.freeze({ ownedProcessRecords, @@ -135,7 +137,7 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( recoverableProviderIds: providerRuntimeProviders.recoverableProviderIds, deviceRuntimeGateway, platformResourceCleanup, - claimRecoveryGateway: createClaimRecoveryGateway, + claimRecoveryGateway: createClaimRecoveryGatewayFor(env), interactorResolution: Object.freeze({ resolve: getInteractor }), providerDeviceAdmission: Object.freeze({ isActive: (device: DeviceInfo) => isActiveProviderDevice(device), diff --git a/src/platform-runtime-android-adb-binding.test.ts b/src/platform-runtime-android-adb-binding.test.ts index eb7190993f..6f1fd2c787 100644 --- a/src/platform-runtime-android-adb-binding.test.ts +++ b/src/platform-runtime-android-adb-binding.test.ts @@ -51,7 +51,7 @@ const device: DeviceInfo = { }; test('the composed gateway lists Android apps from inside the platform package', async () => { - const gateway = createPlatformRuntimeGateway(sessionArtifacts); + const gateway = createPlatformRuntimeGateway({ env: process.env, ...sessionArtifacts }); const binding = await gateway.bind({ device, intent: { kind: 'ordinary' }, scope }); await expect(binding.operations.listApps?.({ device, filter: 'all' })).resolves.toEqual([ @@ -76,7 +76,7 @@ test('the composed gateway lists Android apps from inside the platform package', }); test('the composed gateway reads Android app state from inside the platform package', async () => { - const gateway = createPlatformRuntimeGateway(sessionArtifacts); + const gateway = createPlatformRuntimeGateway({ env: process.env, ...sessionArtifacts }); const binding = await gateway.bind({ device, intent: { kind: 'ordinary' }, scope }); await expect(binding.operations.appState?.()).resolves.toEqual({ diff --git a/src/platform-runtime-android-deployment-executor.test.ts b/src/platform-runtime-android-deployment-executor.test.ts index ce97987b7f..221a2d5923 100644 --- a/src/platform-runtime-android-deployment-executor.test.ts +++ b/src/platform-runtime-android-deployment-executor.test.ts @@ -23,7 +23,7 @@ const device: DeviceInfo = { test('keeps root composition limited to artifact preparation and app identity resolution', async () => { resolveAndroidApp.mockResolvedValueOnce({ type: 'package', value: 'com.example.app' }); - const executor = createAndroidAppDeploymentExecutor(); + const executor = createAndroidAppDeploymentExecutor({}); await expect(executor.resolveAppPackage(device, 'Example')).resolves.toBe('com.example.app'); expect(resolveAndroidApp).toHaveBeenCalledWith(device, 'Example', undefined); @@ -42,6 +42,6 @@ test('rejects an intent where uninstall requires an exact Android package', asyn resolveAndroidApp.mockResolvedValueOnce({ type: 'intent', value: 'example.intent' }); await expect( - createAndroidAppDeploymentExecutor().resolveAppPackage(device, 'example.intent'), + createAndroidAppDeploymentExecutor({}).resolveAppPackage(device, 'example.intent'), ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); }); diff --git a/src/platform-runtime-android-deployment-executor.ts b/src/platform-runtime-android-deployment-executor.ts index 45420cd01a..87720ebe90 100644 --- a/src/platform-runtime-android-deployment-executor.ts +++ b/src/platform-runtime-android-deployment-executor.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { AndroidAppDeploymentExecutor, MaterializeAppSourceInput, @@ -5,9 +6,11 @@ import type { import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; /** Android deployment adapter; native command construction remains package-owned. */ -export function createAndroidAppDeploymentExecutor(): AndroidAppDeploymentExecutor { +export function createAndroidAppDeploymentExecutor( + env: Readonly, +): AndroidAppDeploymentExecutor { return Object.freeze({ - bundletoolJar: process.env.AGENT_DEVICE_BUNDLETOOL_JAR?.trim() || undefined, + bundletoolJar: env.AGENT_DEVICE_BUNDLETOOL_JAR?.trim() || undefined, withInvalidatedAppResolutionCache: async (device, operation) => { const { withAndroidAppResolutionCacheInvalidated } = await loadAndroidMechanics(); return await withAndroidAppResolutionCacheInvalidated(device, operation); diff --git a/src/platform-runtime-claim-recovery.ts b/src/platform-runtime-claim-recovery.ts index 82104cd75f..6be124a168 100644 --- a/src/platform-runtime-claim-recovery.ts +++ b/src/platform-runtime-claim-recovery.ts @@ -20,12 +20,15 @@ import { createPlatformRuntimeGateway } from './platform-runtime.ts'; * claims exist only for local devices — and instance-scopes every stateful piece the gateway owns, * so disposal shuts down only this transaction's handles. */ -export const createClaimRecoveryGateway: ClaimRecoveryGatewayFactory = (input) => - createPlatformRuntimeGateway({ - sessionsDir: input.sessionsDir, - ownedProcesses: input.ownedProcesses, - resolveSessionArtifacts: input.resolveSessionArtifacts, - }); +export const createClaimRecoveryGatewayFor = + (env: EnvMap): ClaimRecoveryGatewayFactory => + (input) => + createPlatformRuntimeGateway({ + env, + sessionsDir: input.sessionsDir, + ownedProcesses: input.ownedProcesses, + resolveSessionArtifacts: input.resolveSessionArtifacts, + }); /** * Daemonless `device release --stale`: settles each provably dead owner @@ -44,7 +47,7 @@ export async function runStaleDeviceClaimRelease( env, reconcile: createOwnerScopedDeviceClaimReconciler({ scope: createRecoveryPlatformScope(), - composeGateway: createClaimRecoveryGateway, + composeGateway: createClaimRecoveryGatewayFor(env), composeRecovery, }), }); diff --git a/src/platform-runtime-gateway-gesture-admission.test.ts b/src/platform-runtime-gateway-gesture-admission.test.ts index d51ff59765..2ae0d5212c 100644 --- a/src/platform-runtime-gateway-gesture-admission.test.ts +++ b/src/platform-runtime-gateway-gesture-admission.test.ts @@ -6,6 +6,7 @@ import { createPlatformRuntimeGateway } from './platform-runtime.ts'; bindTestCommandHost(); const gateway = createPlatformRuntimeGateway({ + env: process.env, resolveSessionArtifacts: () => ({ outputPath: '/sessions/gesture-admission/app.log', pidPath: '/sessions/gesture-admission/app-log.pid', diff --git a/src/platform-runtime-gateway-recording.test.ts b/src/platform-runtime-gateway-recording.test.ts index 3e9387c8e4..b36c4a0bf9 100644 --- a/src/platform-runtime-gateway-recording.test.ts +++ b/src/platform-runtime-gateway-recording.test.ts @@ -20,6 +20,7 @@ test.each(unsupportedRecordingDevices)( async (device) => { const sessionsDir = mkdtempForTestSync('platform-runtime-recording-'); const gateway = createPlatformRuntimeGateway({ + env: process.env, sessionsDir, resolveSessionArtifacts: (sessionId) => ({ outputPath: path.join(sessionsDir, sessionId, 'app.log'), diff --git a/src/platform-runtime-host-diagnostics.test.ts b/src/platform-runtime-host-diagnostics.test.ts index 74f5e5b89b..5bd882e3c8 100644 --- a/src/platform-runtime-host-diagnostics.test.ts +++ b/src/platform-runtime-host-diagnostics.test.ts @@ -35,13 +35,15 @@ test('warmup hands an Apple device to the Apple runner warmup and returns its ch }; mockAppleRunnerWarmupCheck.mockResolvedValue(check); - const result = await createHostDiagnostics().warmupCheck(IOS_SIMULATOR, context); + const result = await createHostDiagnostics(process.env).warmupCheck(IOS_SIMULATOR, context); expect(result).toEqual(check); expect(mockAppleRunnerWarmupCheck).toHaveBeenCalledWith(IOS_SIMULATOR, context); }); test('warmup runs no probe for a non-Apple device', async () => { - expect(await createHostDiagnostics().warmupCheck(ANDROID_EMULATOR, context)).toBeUndefined(); + expect( + await createHostDiagnostics(process.env).warmupCheck(ANDROID_EMULATOR, context), + ).toBeUndefined(); expect(mockAppleRunnerWarmupCheck).not.toHaveBeenCalled(); }); diff --git a/src/platform-runtime-host-diagnostics.ts b/src/platform-runtime-host-diagnostics.ts index 35893c5874..799676cecb 100644 --- a/src/platform-runtime-host-diagnostics.ts +++ b/src/platform-runtime-host-diagnostics.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { DeviceInfo, PlatformSelector } from '@agent-device/kernel/device'; import type { DoctorCheck } from '@agent-device/contracts/observability'; import type { @@ -11,7 +12,7 @@ import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; * execution). Families load lazily: a probe module is imported only when its family's method * actually runs, matching the platform registry's implementation-laziness. */ -export function createHostDiagnostics(): HostDiagnostics { +export function createHostDiagnostics(env: Readonly): HostDiagnostics { return Object.freeze({ toolchainCheck: async ( platform: PlatformSelector | undefined, @@ -19,7 +20,7 @@ export function createHostDiagnostics(): HostDiagnostics { ): Promise => { if (platform === 'android') { const { androidToolchainCheck } = await loadAndroidMechanics(); - return await androidToolchainCheck(process.env, process.platform); + return await androidToolchainCheck(env, process.platform); } if (platform === 'vega') { const { vegaToolchainCheck } = await import('@agent-device/platform-vega'); diff --git a/src/platform-runtime-host.test.ts b/src/platform-runtime-host.test.ts index 5b92b9af6e..693e78f07c 100644 --- a/src/platform-runtime-host.test.ts +++ b/src/platform-runtime-host.test.ts @@ -25,7 +25,7 @@ vi.mock('@agent-device/platform-apple/tool-provider', () => { import { createDeviceInventoryHost } from './platform-runtime-host.ts'; test('inventory host loads scoped Apple mechanics only on first Apple tool use', async () => { - const host = createDeviceInventoryHost(); + const host = createDeviceInventoryHost(process.env); const signal = new AbortController().signal; expect(toolchains.appleEvaluations).toBe(0); diff --git a/src/platform-runtime-host.ts b/src/platform-runtime-host.ts index d82922b9b9..2df43a2d2b 100644 --- a/src/platform-runtime-host.ts +++ b/src/platform-runtime-host.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { DeviceInventoryHost, HostOperatingSystem, @@ -14,7 +15,7 @@ import { createHostToolchainPreparer } from './platform-runtime-toolchain-host.t import { runCmd, whichCmd, commandHostEnvironment } from '@agent-device/host-kit/command'; import { guardedHostCommandArgv } from './platform-runtime-host-device-shell.ts'; -export function createDeviceInventoryHost(): DeviceInventoryHost { +export function createDeviceInventoryHost(env: Readonly): DeviceInventoryHost { return Object.freeze({ commands: Object.freeze({ which: async (executable: string) => @@ -35,7 +36,7 @@ export function createDeviceInventoryHost(): DeviceInventoryHost { }, }), appleTools: createAppleToolHost(), - toolchains: createHostToolchainPreparer(), + toolchains: createHostToolchainPreparer(env), files: Object.freeze({ isExecutable: async (candidate: string) => await isExecutable(candidate), createTemporaryTextFile: async (options: { prefix: string; suffix: string }) => diff --git a/src/platform-runtime-operation-host.test.ts b/src/platform-runtime-operation-host.test.ts index 71b67aa164..79169e37a6 100644 --- a/src/platform-runtime-operation-host.test.ts +++ b/src/platform-runtime-operation-host.test.ts @@ -62,6 +62,7 @@ const shutdownLoaders = { test('operation host composes the shared lazy Apple-tool and toolchain capabilities', () => { const host = createPlatformRuntimeHost({ + env: process.env, sessionsDir: '/tmp/sessions', resolveSessionArtifacts: () => ({ outputPath: '/tmp/sessions/one/app.log', diff --git a/src/platform-runtime-operation-host.ts b/src/platform-runtime-operation-host.ts index b03fe1c7ce..59138275c0 100644 --- a/src/platform-runtime-operation-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { AppLogSessionArtifacts } from '@agent-device/contracts/app-log-runtime'; import type { DeviceShutdownRuntimeLoaders } from '@agent-device/contracts/device-shutdown-runtime'; import type { @@ -53,6 +54,7 @@ export async function loadMacOsSurfaceSnapshot( } export function createPlatformRuntimeHost(options: { + env: Readonly; sessionsDir: string; resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; shutdownLoaders: DeviceShutdownRuntimeLoaders; @@ -92,7 +94,7 @@ export function createPlatformRuntimeHost(options: { return Object.freeze({ commands, appleTools, - toolchains: createHostToolchainPreparer(), + toolchains: createHostToolchainPreparer(options.env), artifacts: Object.freeze({ resolveSession: options.resolveSessionArtifacts }), outputs: Object.freeze({ openAppend: async (pathname: string) => await openAppLogOutput(options.sessionsDir, pathname), @@ -114,7 +116,7 @@ export function createPlatformRuntimeHost(options: { }), ...network, appleDeployment: createAppleAppDeploymentExecutor(), - androidDeployment: createAndroidAppDeploymentExecutor(), + androidDeployment: createAndroidAppDeploymentExecutor(options.env), androidTools: createAndroidToolHost(), temporaryFiles: Object.freeze({ create: async (temporaryFileOptions: { prefix: string; suffix: string }) => diff --git a/src/platform-runtime-perf-host.test.ts b/src/platform-runtime-perf-host.test.ts index 0415ba765b..7f75339e7e 100644 --- a/src/platform-runtime-perf-host.test.ts +++ b/src/platform-runtime-perf-host.test.ts @@ -47,6 +47,7 @@ test('the composed Android perf operation reports an unavailable ADB memory samp adb.calls.length = 0; const sessionsDir = mkdtempForTestSync('platform-runtime-perf-host-'); const gateway = createPlatformRuntimeGateway({ + env: process.env, sessionsDir, resolveSessionArtifacts: (sessionId) => ({ outputPath: `${sessionsDir}/${sessionId}/app.log`, diff --git a/src/platform-runtime-toolchain-host.test.ts b/src/platform-runtime-toolchain-host.test.ts index bf2d9f77c1..a6b2ec4e1c 100644 --- a/src/platform-runtime-toolchain-host.test.ts +++ b/src/platform-runtime-toolchain-host.test.ts @@ -28,7 +28,7 @@ vi.mock('@agent-device/platform-harmonyos', () => { import { createHostToolchainPreparer } from './platform-runtime-toolchain-host.ts'; test('toolchain host loads and prepares only the selected family', async () => { - const host = createHostToolchainPreparer(); + const host = createHostToolchainPreparer(process.env); expect(toolchains).toEqual({ androidEvaluations: 0, diff --git a/src/platform-runtime-toolchain-host.ts b/src/platform-runtime-toolchain-host.ts index 85c4d70520..011b44a646 100644 --- a/src/platform-runtime-toolchain-host.ts +++ b/src/platform-runtime-toolchain-host.ts @@ -1,12 +1,13 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { HostToolchainPreparer } from '@agent-device/contracts/platform-runtime-host'; import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; -export function createHostToolchainPreparer(): HostToolchainPreparer { +export function createHostToolchainPreparer(env: Readonly): HostToolchainPreparer { return Object.freeze({ prepare: async (family) => { if (family === 'android') { const { ensureAndroidSdkPathConfigured } = await loadAndroidMechanics(); - await ensureAndroidSdkPathConfigured(process.env); + await ensureAndroidSdkPathConfigured(env); return; } if (family === 'harmonyos') { diff --git a/src/platform-runtime.test.ts b/src/platform-runtime.test.ts index 373be8b3a8..bbeb6075d3 100644 --- a/src/platform-runtime.test.ts +++ b/src/platform-runtime.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { PLATFORMS } from '@agent-device/kernel/device'; import { describe, expect, test, vi } from 'vitest'; @@ -32,9 +33,13 @@ vi.mock('./platform-runtime-host.ts', () => { import { createPlatformDeviceInventoryGateways, - platformModuleRegistry, + createPlatformModuleRegistryForHost, } from './platform-runtime.ts'; +bindTestCommandHost(); + +const platformModuleRegistry = createPlatformModuleRegistryForHost({}); + describe('platform module metadata composition', () => { test('registers each canonical family exactly once in canonical order', () => { expect(platformModuleRegistry.families).toEqual(PLATFORMS); @@ -61,7 +66,7 @@ describe('platform module metadata composition', () => { diagnostics: { emit: () => {} }, progress: { report: () => {} }, } as const; - const providerGateways = await createPlatformDeviceInventoryGateways({ + const providerGateways = await createPlatformDeviceInventoryGateways(process.env, { discover: async () => ({ kind: 'inventory', devices: [ @@ -81,7 +86,7 @@ describe('platform module metadata composition', () => { ); expect(hostAdapter.evaluations).toBe(0); - const localGateways = await createPlatformDeviceInventoryGateways(); + const localGateways = await createPlatformDeviceInventoryGateways(process.env); expect(await localGateways.localOnly.discover({ platform: 'web' }, scope)).toHaveLength(1); expect(hostAdapter.evaluations).toBe(0); diff --git a/src/platform-runtime.ts b/src/platform-runtime.ts index d58deb05a0..7eb2d03ed0 100644 --- a/src/platform-runtime.ts +++ b/src/platform-runtime.ts @@ -70,10 +70,6 @@ export async function getAndroidAppStateWithAdb( return await readAndroidAppStateWithExecutor(adb, signal); } -const androidInventoryModule = createAndroidInventoryModule({ - sdkRoots: configuredValues(process.env.ANDROID_SDK_ROOT, process.env.ANDROID_HOME), -}); - /** One root-composed Android observer shared by every daemon request. */ export const androidObservation = createPackageAndroidObservationAdapter( createAndroidObservationHost(), @@ -86,29 +82,34 @@ const shutdownLoaders = Object.freeze({ await loadAndroidShutdownRuntime(dependencies), }); -const harmonyosInventoryModule = createHarmonyInventoryModule({ - hdcSdkPath: process.env.HDC_SDK_PATH, - devecoSdkHome: process.env.DEVECO_SDK_HOME, - commandLineToolsHome: process.env.HARMONYOS_COMMAND_LINE_TOOLS, -}); - -export const platformModuleRegistry = createPlatformModuleRegistry([ - appleInventoryModule, - androidInventoryModule, - harmonyosInventoryModule, - vegaInventoryModule, - linuxInventoryModule, - webInventoryModule, -]); +export function createPlatformModuleRegistryForHost( + env: Readonly>, +) { + return createPlatformModuleRegistry([ + appleInventoryModule, + createAndroidInventoryModule({ + sdkRoots: configuredValues(env.ANDROID_SDK_ROOT, env.ANDROID_HOME), + }), + createHarmonyInventoryModule({ + hdcSdkPath: env.HDC_SDK_PATH, + devecoSdkHome: env.DEVECO_SDK_HOME, + commandLineToolsHome: env.HARMONYOS_COMMAND_LINE_TOOLS, + }), + vegaInventoryModule, + linuxInventoryModule, + webInventoryModule, + ]); +} export async function createPlatformDeviceInventoryGateways( + env: Readonly>, provider?: ProviderDeviceInventorySource, ): Promise { return await attachAppleInstalledAppProbe({ - registry: platformModuleRegistry, + registry: createPlatformModuleRegistryForHost(env), loadHost: async () => { const { createDeviceInventoryHost } = await import('./platform-runtime-host.ts'); - return createDeviceInventoryHost(); + return createDeviceInventoryHost(env); }, provider, }); @@ -121,8 +122,8 @@ const androidRuntimeModule = createAndroidRuntimeModule({ }, }); -/** The root composition registry shared by the gateway and bounded host-contract fixtures. */ -export const platformRuntimeModules: ReadonlyMap = new Map< +/** The root composition registry the platform runtime gateway reads. */ +const platformRuntimeModules: ReadonlyMap = new Map< Platform, PlatformRuntimeModule >([ @@ -138,6 +139,7 @@ type Platform = PlatformRuntimeModule['family']; export function createPlatformRuntimeGateway( options: Readonly<{ + env: Readonly>; providerRuntimes?: readonly ProviderDeviceRuntime[]; providerModules?: readonly PlatformRuntimeProviderRegistration[]; resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; @@ -152,6 +154,7 @@ export function createPlatformRuntimeGateway( const { createPlatformRuntimeHost, createSnapshotRuntimeHost, loadMacOsSurfaceSnapshot } = await import('./platform-runtime-operation-host.ts'); return createPlatformRuntimeHost({ + env: options.env, sessionsDir: options.sessionsDir, resolveSessionArtifacts: options.resolveSessionArtifacts, shutdownLoaders, diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index ce2786cc11..f7250dbb48 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -147,6 +147,7 @@ export async function createProviderScenarioHarness( platformRuntime?.providerModules ?? createProviderPlatformRuntimeRegistrations(configuredRuntimes); const defaultDeviceRuntimeGateway = createPlatformRuntimeGateway({ + env: process.env, assertShutdownAllowed: input.assertShutdownAllowed, providerRuntimes: configuredRuntimes, providerModules, diff --git a/test/integration/provider-scenarios/linux-world.ts b/test/integration/provider-scenarios/linux-world.ts index 9c9d40c91c..f6ccd15ee3 100644 --- a/test/integration/provider-scenarios/linux-world.ts +++ b/test/integration/provider-scenarios/linux-world.ts @@ -161,7 +161,7 @@ const inventoryScope: PlatformRequestScope = Object.freeze({ }); async function discoverLocalLinuxInventory(): Promise { - const source = await linuxInventoryModule.loadInventory(createDeviceInventoryHost()); + const source = await linuxInventoryModule.loadInventory(createDeviceInventoryHost(process.env)); return [...(await source.discover({ platform: 'linux' }, inventoryScope))]; } diff --git a/test/integration/smoke-tvos-platform-coverage.test.ts b/test/integration/smoke-tvos-platform-coverage.test.ts index f3dfc59592..c268c20cad 100644 --- a/test/integration/smoke-tvos-platform-coverage.test.ts +++ b/test/integration/smoke-tvos-platform-coverage.test.ts @@ -83,6 +83,7 @@ test('tvOS gesture contract preserves the typed multi-touch denial', async () => // R42: the denial is now the Apple owner's own fact for the tvOS leaf, and the refusal a // gesture reports is that fact's hint under the shared wording. const facts = await createPlatformRuntimeGateway({ + env: process.env, resolveSessionArtifacts: () => ({ outputPath: '/sessions/tvos/app.log', pidPath: '/sessions/tvos/app-log.pid', From 0b4461e376e90bd1d227c51a413c6bc8a320fb54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 11:20:40 +0200 Subject: [PATCH 3/9] refactor(host): refuse a relative provider app path that names no working directory Co-Authored-By: Claude Sonnet 5.5 --- .../src/webdriver-utils.test.ts | 21 +++++++++++++++++++ .../provider-webdriver/src/webdriver-utils.ts | 13 +++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/provider-webdriver/src/webdriver-utils.test.ts b/packages/provider-webdriver/src/webdriver-utils.test.ts index f9a31231bb..9073922b1c 100644 --- a/packages/provider-webdriver/src/webdriver-utils.test.ts +++ b/packages/provider-webdriver/src/webdriver-utils.test.ts @@ -141,6 +141,27 @@ test('the hub app resolver passes references through, uploads local files, and p } }); +test('the hub app resolver refuses a relative app path when the request names no working directory', async () => { + const uploadFile = vi.fn(async () => 'hub://never'); + + await assert.rejects( + resolveHubAppReference({ + service: 'Hub', + app: 'App.apk', + referenceLabel: 'a hub:// app id', + ...hubReferenceGrammar, + uploadFile, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.equal(error.details?.reason, 'provider_app_cwd_missing'); + return true; + }, + ); + assert.equal(uploadFile.mock.calls.length, 0); +}); + test('appFileUploadForm carries a regular app file and refuses anything else typed', async () => { const tempDir = await mkdtempForTest('agent-device-upload-form-'); try { diff --git a/packages/provider-webdriver/src/webdriver-utils.ts b/packages/provider-webdriver/src/webdriver-utils.ts index b2babf7728..d10669977c 100644 --- a/packages/provider-webdriver/src/webdriver-utils.ts +++ b/packages/provider-webdriver/src/webdriver-utils.ts @@ -165,7 +165,7 @@ export async function resolveHubAppReference(options: { const reference = options.parseReference(app); if (reference !== undefined) return reference; if (/^https?:\/\//i.test(app)) return app; - const appPath = path.resolve(options.cwd ?? process.cwd(), app); + const appPath = path.isAbsolute(app) ? app : path.resolve(requireAppCwd(options), app); if (!fs.existsSync(appPath)) { throw new AppError( 'INVALID_ARGS', @@ -176,6 +176,17 @@ export async function resolveHubAppReference(options: { return await options.uploadFile(appPath, options.signal); } +// A relative app path needs the working directory of the client that named it; the daemon fills it +// from the request, and no ambient directory stands in for a request that names none. +function requireAppCwd(options: { service: string; app: string; cwd?: string }): string { + if (options.cwd !== undefined) return options.cwd; + throw new AppError( + 'INVALID_ARGS', + `${options.service} --provider-app ${options.app} is a relative path and the request names no working directory; pass an absolute path.`, + { providerApp: options.app, reason: 'provider_app_cwd_missing' }, + ); +} + const PROVIDER_API_TIMEOUT_MS = 15_000; /** Service name and remediation hints a verification failure carries. */ From 500d86e57ccfe2f9690baa7f02bd4c6a684b1531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 11:23:05 +0200 Subject: [PATCH 4/9] refactor(host): require the host's env and cwd where the CLI resolves options, binding and auth Co-Authored-By: Claude Sonnet 5.5 --- src/__tests__/cli-agent-cdp.test.ts | 2 ++ src/__tests__/cloud-connect-auth.test.ts | 1 - src/cli.ts | 3 +++ src/cli/auth-session.ts | 15 +++++++-------- src/cli/commands/agent-cdp.ts | 10 +++++----- src/cli/commands/auth.ts | 1 + src/cli/connection/cloud-profile.ts | 1 - src/cli/resolve-cli-options.ts | 9 ++++----- src/cli/session-binding.ts | 6 +++--- 9 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/__tests__/cli-agent-cdp.test.ts b/src/__tests__/cli-agent-cdp.test.ts index d9a5f6d952..b7c320a5c6 100644 --- a/src/__tests__/cli-agent-cdp.test.ts +++ b/src/__tests__/cli-agent-cdp.test.ts @@ -159,6 +159,8 @@ test('cdp passes injected remote target url to npm exec', async () => { }); const exitCode = await runAgentCdpCommand(['target', 'list'], { + cwd: process.cwd(), + env: process.env, flags: { leaseBackend: 'ios-instance', metroProxyBaseUrl: 'https://bridge.example.test', diff --git a/src/__tests__/cloud-connect-auth.test.ts b/src/__tests__/cloud-connect-auth.test.ts index 598e3fbf2b..ce001e83ab 100644 --- a/src/__tests__/cloud-connect-auth.test.ts +++ b/src/__tests__/cloud-connect-auth.test.ts @@ -54,7 +54,6 @@ test('cloud access with no-login reports auth requirement after reuse options ar }, env: {}, io: { - env: {}, fetch: vi.fn(), }, }), diff --git a/src/cli.ts b/src/cli.ts index 586d4f7a97..0847e881a1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -352,6 +352,7 @@ async function resolveRunContextOrExit( const explicitFlagKeys = new Set(parsed.providedFlags.map((entry) => entry.key)); try { const binding = resolveBindingSettings({ + env: base.host.env, policyOverrides: parsed.flags, configuredPlatform: parsed.flags.platform, configuredSession: parsed.flags.session, @@ -359,6 +360,7 @@ async function resolveRunContextOrExit( const flags = binding.lockPolicy ? { ...parsed.flags } : applyDefaultPlatformBinding(parsed.flags, { + env: base.host.env, policyOverrides: parsed.flags, configuredPlatform: parsed.flags.platform, configuredSession: parsed.flags.session, @@ -562,6 +564,7 @@ async function dispatchCliCommand( ctx.binding.lockPolicy && ctx.flags.platform === undefined ? { ...step.input } : applyDefaultPlatformBinding(step.input, { + env: ctx.host.env, policyOverrides: effectiveFlags, configuredPlatform: effectiveFlags.platform, configuredSession: effectiveFlags.session, diff --git a/src/cli/auth-session.ts b/src/cli/auth-session.ts index cda05045d8..3c227cde73 100644 --- a/src/cli/auth-session.ts +++ b/src/cli/auth-session.ts @@ -65,7 +65,6 @@ type CliSessionRefreshResponse = { }; type AuthIo = { - env?: EnvMap; stdinIsTTY?: boolean; stdoutIsTTY?: boolean; stderr?: Pick; @@ -80,7 +79,7 @@ export async function resolveRemoteAuthForCli(options: { command: string; flags: CliFlags; stateDir: string; - env?: EnvMap; + env: EnvMap; }): Promise { return await resolveRemoteAuth({ command: options.command, @@ -96,10 +95,10 @@ export async function resolveRemoteAuth(options: { flags: CliFlags; stateDir: string; allowInteractiveLogin: boolean; - env?: EnvMap; + env: EnvMap; io?: AuthIo; }): Promise { - const env = options.env ?? options.io?.env ?? process.env; + const { env } = options; if (!options.flags.daemonBaseUrl) return { flags: options.flags, source: 'none' }; if (hasToken(options.flags.daemonAuthToken)) return { flags: options.flags, source: 'flag' }; if (hasToken(env.AGENT_DEVICE_DAEMON_AUTH_TOKEN)) { @@ -146,13 +145,13 @@ export async function resolveRemoteAuth(options: { export async function resolveCloudAccessForConnect(options: { stateDir: string; flags: CliFlags; - env?: EnvMap; + env: EnvMap; io?: AuthIo; }): Promise<{ accessToken: string; cloudBaseUrl: string; }> { - const env = options.env ?? options.io?.env ?? process.env; + const { env } = options; if (hasToken(options.flags.daemonAuthToken)) { return { accessToken: options.flags.daemonAuthToken, @@ -198,7 +197,7 @@ export async function resolveCloudAccessForConnect(options: { export async function loginWithDeviceAuth(options: { stateDir: string; flags: CliFlags; - env?: EnvMap; + env: EnvMap; io?: AuthIo; commandLabel?: string; }): Promise<{ @@ -206,7 +205,7 @@ export async function loginWithDeviceAuth(options: { expiresAt?: string; session: CliSessionRecord; }> { - const env = options.env ?? options.io?.env ?? process.env; + const { env } = options; const authMode = detectAuthMode(env, options.io); if (authMode === 'non-interactive') { throw buildNonInteractiveLoginError(options.commandLabel ?? 'agent-device connect', env); diff --git a/src/cli/commands/agent-cdp.ts b/src/cli/commands/agent-cdp.ts index 092d8c2cbc..fb783bcad3 100644 --- a/src/cli/commands/agent-cdp.ts +++ b/src/cli/commands/agent-cdp.ts @@ -20,8 +20,8 @@ type AgentCdpCommandOptions = { | 'tenant' >; runtime?: SessionRuntimeHints; - cwd?: string; - env?: NodeJS.ProcessEnv; + cwd: string; + env: NodeJS.ProcessEnv; }; export function buildAgentCdpNpmExecArgs(args: string[]): string[] { @@ -80,12 +80,12 @@ export function buildAgentCdpPassthroughArgs( export async function runAgentCdpCommand( args: string[], - options: AgentCdpCommandOptions = {}, + options: AgentCdpCommandOptions, ): Promise { const passthroughArgs = buildAgentCdpPassthroughArgs(args, options); const result = await runCmdStreaming('npm', buildAgentCdpNpmExecArgs(passthroughArgs), { - cwd: options.cwd ?? process.cwd(), - env: options.env ?? process.env, + cwd: options.cwd, + env: options.env, allowFailure: true, onStdoutChunk: (chunk) => { process.stdout.write(chunk); diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index 327b75d525..605a83ca8e 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -21,6 +21,7 @@ export const authCommand: ClientCommandHandler = async ({ positionals, flags }) stateDir, flags, commandLabel: 'agent-device auth login', + env: commandHostEnvironment().env, }); const data = { authenticated: true, diff --git a/src/cli/connection/cloud-profile.ts b/src/cli/connection/cloud-profile.ts index 5ea12be939..fe58324e96 100644 --- a/src/cli/connection/cloud-profile.ts +++ b/src/cli/connection/cloud-profile.ts @@ -32,7 +32,6 @@ export async function resolveCloudConnectProfile(options: { flags: options.flags, env: options.env, io: { - env: options.env, fetch: options.fetchImpl, }, }); diff --git a/src/cli/resolve-cli-options.ts b/src/cli/resolve-cli-options.ts index 96ff93bb51..69eb8d3737 100644 --- a/src/cli/resolve-cli-options.ts +++ b/src/cli/resolve-cli-options.ts @@ -7,15 +7,14 @@ import { type EnvMap } from '@agent-device/kernel/source-value'; export function resolveCliOptions( argv: string[], - options?: { - cwd?: string; - env?: EnvMap; + options: { + cwd: string; + env: EnvMap; strictFlags?: boolean; }, ) { const rawParsed = parseRawArgs(argv); - const env = options?.env ?? process.env; - const cwd = options?.cwd ?? process.cwd(); + const { env, cwd } = options; const remoteConfigDefaults = shouldApplyRemoteConfigDefaults(rawParsed.command) ? resolveRemoteConfigDefaults({ remoteConfig: rawParsed.flags.remoteConfig, diff --git a/src/cli/session-binding.ts b/src/cli/session-binding.ts index 89757bcdd5..2e2f85b38f 100644 --- a/src/cli/session-binding.ts +++ b/src/cli/session-binding.ts @@ -21,7 +21,7 @@ type LockableFlags = Pick< >; type BindingOptions = { - env?: NodeJS.ProcessEnv; + env: NodeJS.ProcessEnv; policyOverrides?: BindingPolicyOverrides; configuredPlatform?: CliFlags['platform']; configuredSession?: string; @@ -30,7 +30,7 @@ type BindingOptions = { export function applyDefaultPlatformBinding( flags: T, - options: BindingOptions = {}, + options: BindingOptions, ): T { const settings = resolveBindingSettings(options); const nextFlags = { ...flags }; @@ -43,7 +43,7 @@ export function applyDefaultPlatformBinding( } export function resolveBindingSettings(options: BindingOptions): BindingSettings { - const env = options.env ?? process.env; + const { env } = options; const defaultPlatform = options.inheritedPlatform ?? options.configuredPlatform ?? From 636fa2b7b4ae49d100fe0a7b1ba2bbe8cda23ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 11:41:50 +0200 Subject: [PATCH 5/9] test(host): keep the upload client suite under its size ratchet Co-Authored-By: Claude Sonnet 5.5 --- .../src/__tests__/upload-client.test.ts | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/packages/remote/src/__tests__/upload-client.test.ts b/packages/remote/src/__tests__/upload-client.test.ts index bc2a0be605..0b57ce1fdb 100644 --- a/packages/remote/src/__tests__/upload-client.test.ts +++ b/packages/remote/src/__tests__/upload-client.test.ts @@ -4,7 +4,10 @@ import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs'; import http, { type IncomingMessage, type ServerResponse } from 'node:http'; import path from 'node:path'; -import { uploadArtifact } from '../upload-client.ts'; +import { uploadArtifact as uploadArtifactOnHost } from '../upload-client.ts'; + +const uploadArtifact = (options: Omit[0], 'env'>) => + uploadArtifactOnHost({ ...options, env: process.env }); import type { UploadProgressEvent } from '../upload-progress.ts'; import { runCmdSync, withCommandExecutorOverride } from '@agent-device/host-kit/command'; import { closeLoopbackServer, listenOnLoopback, mkdtempForTestSync } from '@agent-device/test-kit'; @@ -61,7 +64,6 @@ test('uploadArtifact returns preflight uploadId without uploading bytes on cache try { const uploadId = await uploadArtifact({ - env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -105,7 +107,6 @@ test('uploadArtifact uploads with hash headers after preflight cache miss', asyn try { const uploadId = await uploadArtifact({ - env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -145,7 +146,6 @@ test('uploadArtifact falls back to upload when preflight is unsupported', async try { const uploadId = await uploadArtifact({ - env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -194,7 +194,6 @@ test('uploadArtifact falls back to upload when preflight fails', async () => { try { const uploadId = await uploadArtifact({ - env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -248,7 +247,6 @@ test('uploadArtifact follows up to five upload redirects', async () => { try { const uploadId = await uploadArtifact({ - env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -301,7 +299,6 @@ test('uploadArtifact fails cleanly on the sixth upload redirect', async () => { await assert.rejects( async () => await uploadArtifact({ - env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -365,7 +362,6 @@ test('uploadArtifact falls back to legacy upload when direct upload exceeds redi try { const uploadId = await uploadArtifact({ - env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -448,7 +444,6 @@ test('uploadArtifact uses direct upload ticket and finalize flow', async () => { try { const uploadId = await uploadArtifact({ - env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -537,7 +532,6 @@ test.each([ try { const uploadId = await uploadArtifact({ - env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -620,7 +614,6 @@ test('uploadArtifact resumes a direct upload from the server-reported offset', a try { const uploadId = await uploadArtifact({ - env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -722,7 +715,6 @@ test('uploadArtifact re-preflights and resumes after an interrupted direct uploa try { const uploadId = await uploadArtifact({ - env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -795,7 +787,6 @@ test('uploadArtifact preflights and legacy-uploads compressed app bundle directo try { const uploadId = await uploadArtifact({ - env: process.env, localPath: appPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -842,7 +833,6 @@ test('uploadArtifact disables macOS AppleDouble entries when archiving app bundl }, async () => await uploadArtifact({ - env: process.env, localPath: appPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -896,7 +886,6 @@ test('uploadArtifact uploads APK, AAB, and IPA files without wrapping them', asy try { await uploadArtifact({ - env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -932,7 +921,6 @@ test.each([ for (const leaseId of ['lease-1', undefined]) { leases = new Map(); await uploadArtifact({ - env: process.env, localPath, baseUrl: server.baseUrl, token: TEST_TOKEN, From 674ee5ea2eea965d89f8018e618a2484a84baafd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 12:47:58 +0200 Subject: [PATCH 6/9] refactor(host): pass the run's host to CLI command handlers Co-Authored-By: Claude Sonnet 5.5 --- src/__tests__/cli-client-commands.test.ts | 27 +++++++- src/__tests__/cli-install-from-source.test.ts | 2 + src/__tests__/cloud-connect-auth.test.ts | 7 +- src/__tests__/cloud-connect-profile.test.ts | 38 +++++----- .../remote-connection-harmonyos.test.ts | 7 +- .../remote-connection-macos-app.test.ts | 7 +- .../remote-connection-platform-axis.test.ts | 14 +++- ...-connection-proxy-allocate-by-spec.test.ts | 9 ++- .../remote-connection-proxy-lease-id.test.ts | 5 +- src/__tests__/remote-connection.test.ts | 52 ++++++++++++++ src/__tests__/takeover-command.test.ts | 37 ++++++++-- src/__tests__/test-utils/connect-command.ts | 4 ++ src/cli.ts | 7 +- src/cli/commands/__tests__/daemon.test.ts | 12 +++- src/cli/commands/__tests__/generic.test.ts | 5 +- src/cli/commands/__tests__/proxy.test.ts | 4 ++ src/cli/commands/__tests__/recording.test.ts | 2 + src/cli/commands/__tests__/screenshot.test.ts | 9 +-- src/cli/commands/auth.ts | 9 ++- .../connection-runtime-metro-teardown.test.ts | 3 +- src/cli/commands/connection-runtime.ts | 13 ++-- src/cli/commands/connection.ts | 69 ++++++++++++------- src/cli/commands/daemon.ts | 7 +- src/cli/commands/device.ts | 12 ++-- src/cli/commands/proxy.ts | 14 ++-- src/cli/commands/recording.ts | 11 ++- src/cli/commands/replay.ts | 8 +-- src/cli/commands/router-types.ts | 3 + src/cli/commands/router.ts | 1 + src/cli/commands/screenshot.ts | 13 ++-- src/cli/connection/ended-lease.test.ts | 3 +- src/cli/connection/ended-lease.ts | 12 ++-- 32 files changed, 311 insertions(+), 115 deletions(-) diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index f65ae9f46c..851d0a1040 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -15,7 +15,7 @@ import type { import type { SettingsUpdateOptions } from '@agent-device/contracts/client'; import { AppError } from '@agent-device/kernel/errors'; import { resolveCliOptions } from '../cli/resolve-cli-options.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { createStubClientLeases } from './test-utils/client-lease-fixtures.ts'; import { withCapturedOutput, withCapturedStdoutAsync } from './test-utils/stream-capture.ts'; @@ -42,6 +42,7 @@ test('install-from-source forwards URL and repeated headers to client.apps.insta }); const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'install-from-source', positionals: ['https://example.com/app.apk'], flags: { @@ -84,6 +85,7 @@ test('install-from-source forwards GitHub Actions artifact flag to client.apps.i }); const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'install-from-source', positionals: [], flags: { @@ -120,6 +122,7 @@ test('install-from-source preserves colons in GitHub Actions artifact names', as }); await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'install-from-source', positionals: [], flags: { @@ -153,6 +156,7 @@ test('install-from-source forwards configured GitHub Actions artifact name to cl }); const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'install-from-source', positionals: [], flags: { @@ -189,6 +193,7 @@ test('install-from-source rejects malformed header syntax', async () => { await assert.rejects( () => tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'install-from-source', positionals: ['https://example.com/app.apk'], flags: { @@ -216,6 +221,7 @@ test('install-from-source rejects headers with GitHub Actions artifact sources', await assert.rejects( () => tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'install-from-source', positionals: [], flags: { @@ -268,6 +274,7 @@ test('metro prepare forwards normalized options to client.metro.prepare', async const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'metro', positionals: ['prepare'], flags: { @@ -330,6 +337,7 @@ test('metro prepare rejects when no public or proxy base URL is provided', async await assert.rejects( () => tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'metro', positionals: ['prepare'], flags: { @@ -366,6 +374,7 @@ test('metro reload forwards host, port, bundle URL, and timeout to client.metro. const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'metro', positionals: ['reload'], flags: { @@ -420,6 +429,7 @@ test('events prints a parsed session timeline from the generic CLI route', async const output = await withCapturedOutput(async () => { const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'events', positionals: [], flags: { @@ -461,6 +471,7 @@ test('screenshot forwards --overlay-refs to the client capture API', async () => }); const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'screenshot', positionals: ['/tmp/screenshot.png'], flags: { @@ -510,6 +521,7 @@ test('diff screenshot forwards --surface to live client screenshot capture', asy await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'diff', positionals: ['screenshot'], flags: { @@ -554,6 +566,7 @@ test('open forwards macOS surface to the client apps API', async () => { }); const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'open', positionals: ['MenuBarApp'], flags: { @@ -589,6 +602,7 @@ test('open forwards launch console option to the client apps API', async () => { }); const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'open', positionals: ['Agent Device Tester'], flags: { @@ -622,6 +636,7 @@ test('apps command defaults to user-installed and prints discovery hint', async const output = await withCapturedOutput(async () => { const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'apps', positionals: [], flags: { @@ -669,6 +684,7 @@ test('screenshot reports annotated ref count in non-json mode', async () => { const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'screenshot', positionals: ['/tmp/screenshot.png'], flags: { @@ -703,6 +719,7 @@ click text="Continue" const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'replay', positionals: ['export', sourcePath], flags: { @@ -784,6 +801,7 @@ test('replay rejects extra plain replay paths before daemon dispatch', async () await assert.rejects( async () => await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'replay', positionals: ['one.ad', 'two.ad'], flags: { @@ -814,6 +832,7 @@ test('wait keeps CLI bare text behavior through the typed client command API', a }; const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'wait', positionals: ['Continue', '1500'], flags: { @@ -848,6 +867,7 @@ test('keyboard status prints Android input ownership in human output', async () const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'keyboard', positionals: ['status'], flags: { @@ -880,6 +900,7 @@ test('metro prepare wraps output in the standard success envelope for --json', a const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'metro', positionals: ['prepare'], flags: { @@ -953,6 +974,7 @@ test('metro prepare with --remote-config loads profile defaults', async () => { const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'metro', positionals: ['prepare'], flags: parsed.flags, @@ -993,6 +1015,7 @@ test('install prints command-owned success output in human mode', async () => { const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'install', positionals: ['Demo', '/tmp/Demo.app'], flags: { @@ -1023,6 +1046,7 @@ test('settings location set forwards coordinates to client settings update', asy }); const handled = await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'settings', positionals: ['location', 'set', '37.3349', '-122.009'], flags: { @@ -1064,6 +1088,7 @@ test('settings location set rejects invalid coordinates before client call', asy await assert.rejects( () => tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'settings', positionals, flags: { diff --git a/src/__tests__/cli-install-from-source.test.ts b/src/__tests__/cli-install-from-source.test.ts index bc103e7649..f592030db8 100644 --- a/src/__tests__/cli-install-from-source.test.ts +++ b/src/__tests__/cli-install-from-source.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; @@ -15,6 +16,7 @@ async function runInstallFromSource(source: string) { }, } as unknown as AgentDeviceClient; return await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'install-from-source', positionals: [source], flags: { json: false, help: false, version: false }, diff --git a/src/__tests__/cloud-connect-auth.test.ts b/src/__tests__/cloud-connect-auth.test.ts index ce001e83ab..c5472fd85e 100644 --- a/src/__tests__/cloud-connect-auth.test.ts +++ b/src/__tests__/cloud-connect-auth.test.ts @@ -7,7 +7,11 @@ import { beginStdoutCapture } from './test-utils/stream-capture.ts'; import { resolveCloudAccessForConnect } from '../cli/auth-session.ts'; import { readActiveConnectionState } from '@agent-device/remote/remote-connection-state'; import type { AgentDeviceClient } from '../agent-device-client.ts'; -import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; bindTestCommandHost(); @@ -86,6 +90,7 @@ async function connectWithNoLogin(stateDir: string): Promise { const stdoutCapture = beginStdoutCapture(); try { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index a10719697c..e10b3312f9 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -1,4 +1,3 @@ -import { withCommandHost } from '@agent-device/host-kit/command'; import { afterEach, beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -21,7 +20,11 @@ import { verifyAwsDeviceFarmConnection, verifyBrowserStackConnection, } from '@agent-device/provider-webdriver/connection-verification'; -import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { connectWithGeneratedProviderProfile } from './test-utils/connect-command.ts'; bindTestCommandHost(); @@ -164,6 +167,7 @@ test('connect limrun generates a local daemon remote profile', async () => { try { await captureConnectStdout(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: ['limrun'], flags: { json: true, @@ -217,6 +221,7 @@ test('connect limrun attaches to an existing instance without storing its token' await captureConnectStdout(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: ['limrun'], flags: { json: true, help: false, version: false, stateDir, platform: 'ios' }, client: {} as AgentDeviceClient, @@ -237,6 +242,7 @@ test('connect limrun persists deferred Metro bridge settings', async () => { try { await captureConnectStdout(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: ['limrun'], flags: { json: true, @@ -295,6 +301,7 @@ test('connect limrun requires LIMRUN_API_KEY or access to an existing instance', try { await assert.rejects( connectCommand({ + host: createTestHostEnvironment(), positionals: ['limrun'], flags: { json: true, @@ -554,20 +561,17 @@ test('connect browserstack persists a local app artifact as an absolute path', a try { // The host the command runs in decides what `./sample.apk` is relative to. - await withCommandHost( - { host: { env: process.env, cwd: fs.realpathSync(tempRoot) }, requestEnv: undefined }, - async () => - await connectWithGeneratedProviderProfile({ - stateDir, - positionals: ['browserstack'], - flags: { - platform: 'android', - device: 'Google Pixel 8', - providerOsVersion: '14.0', - providerApp: './sample.apk', - }, - }), - ); + await connectWithGeneratedProviderProfile({ + stateDir, + host: { env: process.env, cwd: fs.realpathSync(tempRoot) }, + positionals: ['browserstack'], + flags: { + platform: 'android', + device: 'Google Pixel 8', + providerOsVersion: '14.0', + providerApp: './sample.apk', + }, + }); const state = readRequiredActiveState(stateDir); assert.equal(readGeneratedConfig(state.remoteConfigPath).providerApp, fs.realpathSync(appPath)); @@ -795,6 +799,7 @@ test('connect does not activate provider state when verification fails', async ( try { await assert.rejects( connectCommand({ + host: createTestHostEnvironment(), positionals: ['limrun'], flags: { json: true, @@ -903,6 +908,7 @@ function fetchProfileUrl(fetchMock: ReturnType): string | undefine async function connectWithGeneratedCloudProfile(stateDir: string): Promise { await captureConnectStdout(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, diff --git a/src/__tests__/remote-connection-harmonyos.test.ts b/src/__tests__/remote-connection-harmonyos.test.ts index bc301bb5b2..6e2f955a71 100644 --- a/src/__tests__/remote-connection-harmonyos.test.ts +++ b/src/__tests__/remote-connection-harmonyos.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { expect, test } from 'vitest'; import fs from 'node:fs'; import { @@ -45,6 +45,7 @@ test('stored Harmony runtime compatibility keeps the HarmonyOS runtime for a Har }, }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', client: createTestClient(), flags: { @@ -73,6 +74,7 @@ async function expectRejectedSelection( ): Promise { await expect( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', client: createTestClient(), flags: { @@ -176,6 +178,7 @@ test('proxy HarmonyOS inventory materializes a scoped lease and closes that same session: 'harmony-proxy', }; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags, client, @@ -189,7 +192,7 @@ test('proxy HarmonyOS inventory materializes a scoped lease and closes that same deviceKey: 'harmonyos:mobile:127.0.0.1:16001', }); expect(readRemoteConnectionState({ stateDir, session: flags.session })?.leaseId).toBe(leaseId); - await disconnectCommand({ positionals: [], flags, client }); + await disconnectCommand({ host: createTestHostEnvironment(), positionals: [], flags, client }); expect(released).toEqual([leaseId]); expect(readRemoteConnectionState({ stateDir, session: flags.session })).toBeNull(); }); diff --git a/src/__tests__/remote-connection-macos-app.test.ts b/src/__tests__/remote-connection-macos-app.test.ts index 9f0e5e5bb6..ecf9582a97 100644 --- a/src/__tests__/remote-connection-macos-app.test.ts +++ b/src/__tests__/remote-connection-macos-app.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { expect, test } from 'vitest'; import fs from 'node:fs'; import { AppError } from '@agent-device/kernel/errors'; @@ -68,6 +68,7 @@ test('a macos-app remote config opens on the lease the host allocated, unchanged }); const flags = flagsFor(stateDir, remoteConfigPath); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', positionals: ['com.example.app'], flags, @@ -110,6 +111,7 @@ test('an inactive host lease is reported, never replaced by a tenant allocation' }); await expect( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: flagsFor(stateDir, remoteConfigPath), client, @@ -126,6 +128,7 @@ async function connectHostedMacosApp( const stdoutCapture = beginStdoutCapture(); try { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { ...flagsFor(workspace.stateDir, workspace.remoteConfigPath), @@ -165,6 +168,7 @@ test('connect binds the lease and device a macos-app remote config names, and op }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', positionals: ['com.example.app'], flags: { ...flagsFor(workspace.stateDir, workspace.remoteConfigPath), platform: 'macos' }, @@ -245,6 +249,7 @@ test('disconnect drops the local connection and never releases the host lease', const stdoutCapture = beginStdoutCapture(); try { await disconnectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: flagsFor(workspace.stateDir, workspace.remoteConfigPath), client: createTestClient({ diff --git a/src/__tests__/remote-connection-platform-axis.test.ts b/src/__tests__/remote-connection-platform-axis.test.ts index 66b832e281..92793830ce 100644 --- a/src/__tests__/remote-connection-platform-axis.test.ts +++ b/src/__tests__/remote-connection-platform-axis.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; // The platform axis a remote connection is named on (#2962). // // A `DeviceInfo` carries the INTERNAL `apple` platform while everything a connection records or @@ -54,6 +54,7 @@ test('proxy install against an iOS-bound connection is not refused as a platform const allocate = recordedLeaseAllocate({ leaseId: 'ios-lease-1', backend: 'ios-instance' }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { json: true, @@ -114,6 +115,7 @@ test('proxy install with the apple family selector matches an ios-bound connecti const allocate = recordedLeaseAllocate({ leaseId: 'ios-lease-1', backend: 'ios-instance' }); await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { json: true, @@ -173,6 +175,7 @@ test('remote command with the apple family selector matches an ios-bound connect const heartbeats: string[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: { json: true, @@ -228,6 +231,7 @@ test('proxy install against a differently-bound platform is still refused', asyn await assert.rejects( async () => await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { json: true, @@ -291,6 +295,7 @@ test('proxy install records the public platform and the next command reuses that }); const install = () => materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { json: true, @@ -376,6 +381,7 @@ test('a connection opened on the apple family collapses to the bound device leaf }); const install = (platform: 'apple' | 'ios' | 'macos') => materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { json: true, @@ -438,6 +444,7 @@ test('a default-policy connection collapses its recorded apple alias when the le const allocate = recordedLeaseAllocate({ leaseId: 'default-lease-1', backend: 'ios-instance' }); const command = (platform: 'apple' | 'ios' | 'macos') => materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: { json: true, @@ -501,6 +508,7 @@ test('a stored apple record refuses the other leaf even though nothing rewrote i const heartbeats: string[] = []; const command = (platform: 'macos') => materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: { json: true, @@ -562,6 +570,7 @@ test('connect refuses to reuse an apple-bound connection for the other leaf', as }); const connect = (platform: 'apple' | 'ios' | 'macos') => connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -625,6 +634,7 @@ test('connect keeps the bound leaf when re-declared with the family selector', a }); await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -674,6 +684,7 @@ test('an undecided record narrows to the requested leaf or refuses, and never se }); allocations.push(allocation); return materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: { json: true, @@ -758,6 +769,7 @@ test('connect --force refuses a backend and platform that name different devices await assert.rejects( async () => await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, diff --git a/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts b/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts index f18f74fa4b..f5cc8f1cf9 100644 --- a/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts +++ b/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -99,6 +99,7 @@ test('proxy open without an exact device allocates by its selectors and keeps th const requests: LeaseAllocateRequest[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, device: 'iPhone 17', target: 'mobile', iosSimulatorDeviceSet: '/sims' }, client: createTestClient({ @@ -152,6 +153,7 @@ test('a later open on the live lease uses the device its key names instead of pi const heartbeats: string[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, device: 'iPhone 17' }, // Another simulator with the same name must not replace the leased one. @@ -172,6 +174,7 @@ test('a later open naming another device on the live lease is refused instead of await assert.rejects( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, device: 'iPhone 16' }, client: renewingClient([], [LEASED_SIMULATOR, { ...IPHONE_SIMULATOR, name: 'iPhone 16' }]), @@ -213,6 +216,7 @@ test('a later open scoped to a simulator set that excludes the leased device is await assert.rejects( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, iosSimulatorDeviceSet: '/other-sims' }, client, @@ -236,6 +240,7 @@ test('a daemon whose lease names no device gets that lease released and the devi const releases: LeaseReleaseRequest[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, device: 'iPhone 17' }, client: createTestClient({ @@ -270,6 +275,7 @@ test('a renewed lease that names no device is refused and left to its owner', as await assert.rejects( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, device: 'iPhone 17' }, client: createTestClient({ @@ -302,6 +308,7 @@ test('proxy open with an exact udid still picks locally and allocates that devic const requests: LeaseAllocateRequest[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, udid: 'SIM-LOCAL' }, client: createTestClient({ diff --git a/src/__tests__/remote-connection-proxy-lease-id.test.ts b/src/__tests__/remote-connection-proxy-lease-id.test.ts index 4145680887..6cfff6fc47 100644 --- a/src/__tests__/remote-connection-proxy-lease-id.test.ts +++ b/src/__tests__/remote-connection-proxy-lease-id.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -82,11 +82,13 @@ test('proxy allocation sends a fresh lease id on every attempt and keeps the id } as const; const first = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { ...flags }, client, }); const second = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { ...flags }, client, @@ -124,6 +126,7 @@ test('a non-proxy remote connection leaves the lease id to the daemon', async () const requests: LeaseAllocateRequest[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'screenshot', flags: { json: true, diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 16b5b5b438..1a153fe270 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { withCapturedStdoutAsync } from './test-utils/stream-capture.ts'; @@ -78,6 +79,7 @@ test('connect auto-generates a local session and writes minimal remote state', a await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -113,6 +115,7 @@ test('connect proxy writes normal remote state with generated non-secret profile await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: ['proxy'], flags: { json: true, @@ -161,6 +164,7 @@ test('connect daemon-base-url shortcut uses proxy profile for direct proxy URLs' await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -193,6 +197,7 @@ test('connect proxy scopes generated client identity by explicit session', async for (const session of ['agent-a', 'agent-b']) { await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: ['proxy'], flags: { json: true, @@ -222,6 +227,7 @@ test('connect proxy notice distinguishes safe inventory from lease allocation', const stdout = await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: ['proxy'], flags: { json: false, @@ -276,6 +282,7 @@ test('connect proxy rejects remote-config and unknown provider combinations', as await assert.rejects( async () => await connectCommand({ + host: createTestHostEnvironment(), positionals: ['proxy'], flags: { json: true, @@ -292,6 +299,7 @@ test('connect proxy rejects remote-config and unknown provider combinations', as await assert.rejects( async () => await connectCommand({ + host: createTestHostEnvironment(), positionals: ['wat'], flags: { json: true, @@ -321,6 +329,7 @@ test('connect reports deferred Metro runtime preparation when remote config has const stdout = await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: false, @@ -371,6 +380,7 @@ test('connection status re-emits the deferred Metro command scoped to the named ); await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -389,6 +399,7 @@ test('connection status re-emits the deferred Metro command scoped to the named const stdout = await withCapturedStdoutAsync(async () => { await connectionCommand({ + host: createTestHostEnvironment(), positionals: ['status'], flags: { json: true, @@ -432,6 +443,7 @@ test('connect without a session creates a fresh connection without replacing the await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: connectFlags, client: createTestClient(), @@ -441,6 +453,7 @@ test('connect without a session creates a fresh connection without replacing the await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: connectFlags, client: createTestClient(), @@ -470,6 +483,7 @@ test('connect missing scope errors mention remote config or flags', async () => await assert.rejects( async () => await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -488,6 +502,7 @@ test('connect missing scope errors mention remote config or flags', async () => await assert.rejects( async () => await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -526,6 +541,7 @@ test('deferred materialization allocates lease and prepares Metro for open', asy let observedBridgeScope: { tenantId: string; runId: string; leaseId: string } | undefined; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { json: true, @@ -596,6 +612,7 @@ test('proxy open resolves device key before allocating lease', async () => { const allocate = recordedLeaseAllocate({ leaseId: 'abc123abc123abc1', backend: 'ios-instance' }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { json: true, @@ -664,6 +681,7 @@ test.each(['install', 'uninstall'])('proxy %s allocates a lease first', async (c }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command, flags: { json: true, @@ -714,6 +732,7 @@ test('artifacts command reuses the stored cloud lease without allocating', async }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'artifacts', flags: { json: true, @@ -766,6 +785,7 @@ test('cloud webdriver connection allocates and heartbeats with extended lease TT let allocateRequest: Parameters[0] | undefined; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'screenshot', flags: { ...baseFlags }, client: createTestClient({ @@ -788,6 +808,7 @@ test('cloud webdriver connection allocates and heartbeats with extended lease TT let heartbeatRequest: Parameters[0] | undefined; await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'screenshot', flags: { ...baseFlags }, client: createTestClient({ @@ -830,6 +851,7 @@ test('proxy commands without active device lease fail before allocation', async await assert.rejects( async () => await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: { json: true, @@ -872,6 +894,7 @@ test('direct remote-config materialization creates state and prepares Metro for let observedBridgeScope: { tenantId: string; runId: string; leaseId: string } | undefined; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { json: true, @@ -937,6 +960,7 @@ test('deferred materialization prepares Metro for batch when a step opens an app }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'batch', flags: { json: true, @@ -999,6 +1023,7 @@ test('deferred materialization re-prepares runtime when explicit Metro overrides let prepareRequest: Parameters[0] | undefined; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { json: true, @@ -1065,6 +1090,7 @@ test('cdp remote materialization prepares Metro runtime for bridge target discov let prepareRequest: Parameters[0] | undefined; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'cdp', positionals: ['target', 'list'], flags: { @@ -1126,6 +1152,7 @@ test('cdp remote materialization skips Metro runtime for non-target commands', a try { const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'cdp', positionals: ['memory', 'usage', 'sample'], flags: { @@ -1168,6 +1195,7 @@ test('cdp remote materialization skips Metro runtime without public CDP url', as try { const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'cdp', positionals: ['target', 'list'], flags: { @@ -1222,6 +1250,7 @@ test('deferred materialization heartbeats an existing lease before dispatch', as let allocateCount = 0; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'apps', flags: { json: true, @@ -1287,6 +1316,7 @@ test('deferred materialization allocates pending lease for devices', async () => let allocateCount = 0; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'devices', flags: { json: true, @@ -1360,6 +1390,7 @@ test('deferred provider materialization forwards provider profile fields to leas }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { json: true, @@ -1412,6 +1443,7 @@ test('deferred materialization reallocates when the persisted lease is inactive' }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'apps', flags: { json: true, @@ -1469,6 +1501,7 @@ test('deferred materialization preserves auth failures from lease allocation', a await assert.rejects( async () => await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'apps', flags: { json: true, @@ -1513,6 +1546,7 @@ test('deferred materialization does not require a lease backend for close', asyn }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'close', flags: { json: true, @@ -1562,6 +1596,7 @@ test('connect requires force when compatible scope changes platform', async () = await assert.rejects( async () => await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -1605,6 +1640,7 @@ test('connect requires force when the daemon endpoint changes', async () => { await assert.rejects( async () => await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -1652,6 +1688,7 @@ test('connect --force stops replaced Metro companion after state is updated', as await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ stateDir, @@ -1700,6 +1737,7 @@ test('connect --force without a session does not replace the active generated co await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ stateDir, @@ -1741,6 +1779,7 @@ test("connect --force releases the previous lease with the previous connection's await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ stateDir, @@ -1769,6 +1808,7 @@ test('connect --force skips releasing the previous lease when its token cannot b const stdout = await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ json: false, @@ -1803,6 +1843,7 @@ test('connect --force does not misclassify an env-sourced new token as the previ const stdout = await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], // No daemonAuthToken flag: the ambient token above flows in purely // through the environment, matching production's resolution chain. @@ -1850,6 +1891,7 @@ test('connect --force does not treat a re-pointed config path’s token as the p const stdout = await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ json: false, stateDir, remoteConfig: remoteConfigPath }), client: createTestClient({ release: release.stub }), @@ -1885,6 +1927,7 @@ test('connect --force does not trust an unchanged profile token for a CLI-overri const stdout = await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ json: false, @@ -1924,6 +1967,7 @@ test('connect --force still releases with a rotated credential when the config k await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ stateDir, remoteConfig: newRemoteConfigPath }), client: createTestClient({ release: release.stub }), @@ -1952,6 +1996,7 @@ test('connect --force reuses the ambient token to release the previous lease whe await withCapturedStdoutAsync(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ stateDir, @@ -1997,6 +2042,7 @@ test('disconnect tolerates prior close and removes local connection state', asyn let handled = false; await withCapturedStdoutAsync(async () => { handled = await disconnectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -2045,6 +2091,7 @@ test('disconnect after connect-only cleanup stays local when no session resource await withCapturedStdoutAsync(async () => { await disconnectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: false, @@ -2086,6 +2133,7 @@ test('disconnect without a session uses active connection state', async () => { await withCapturedStdoutAsync(async () => { await disconnectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -2131,6 +2179,7 @@ test('disconnect human output surfaces provider release warnings and artifact li const output = await withCapturedStdoutAsync(async () => { await disconnectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: false, @@ -2209,6 +2258,7 @@ test('disconnect releases proxy lease with provider client and device metadata', await withCapturedStdoutAsync(async () => { await disconnectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -2241,6 +2291,7 @@ test('connection status reports missing state without daemon calls', async () => let handled = false; await withCapturedStdoutAsync(async () => { handled = await connectionCommand({ + host: createTestHostEnvironment(), positionals: ['status'], flags: { json: true, @@ -2275,6 +2326,7 @@ test('connection status reports active connection state', async () => { const output = await withCapturedStdoutAsync(async () => { await connectionCommand({ + host: createTestHostEnvironment(), positionals: ['status'], flags: { json: true, diff --git a/src/__tests__/takeover-command.test.ts b/src/__tests__/takeover-command.test.ts index 75f53163ae..fe728dfb88 100644 --- a/src/__tests__/takeover-command.test.ts +++ b/src/__tests__/takeover-command.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { afterEach, beforeEach, test, vi } from 'vitest'; import { createAgentDeviceClient } from '../agent-device-client.ts'; @@ -52,8 +53,14 @@ test('takeover output explains the hold and release gesture', () => { test('takeover status and release use the configured lease client', async () => { const { client, transport } = clientForTest(); - await takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals: ['status'] }); await takeoverCommand({ + host: createTestHostEnvironment(), + client, + flags: TAKEOVER_CLI_FLAGS, + positionals: ['status'], + }); + await takeoverCommand({ + host: createTestHostEnvironment(), client, flags: TAKEOVER_CLI_FLAGS, positionals: ['release', 'operator-1'], @@ -72,9 +79,17 @@ test('takeover status and release use the configured lease client', async () => test('takeover rejects malformed actions without contacting the daemon', async () => { const { client, transport } = clientForTest(); for (const positionals of [['release'], ['status', 'extra'], ['unknown']]) { - await assert.rejects(takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals }), { - code: 'INVALID_ARGS', - }); + await assert.rejects( + takeoverCommand({ + host: createTestHostEnvironment(), + client, + flags: TAKEOVER_CLI_FLAGS, + positionals, + }), + { + code: 'INVALID_ARGS', + }, + ); } assert.equal(transport.mock.calls.length, 0); }); @@ -84,7 +99,12 @@ test('foreground takeover renews its admitted lease hold and releases it on Ctrl const { client, transport } = clientForTest(); const started = createControlLatch(); mocks.writeCommandOutput.mockImplementationOnce(() => started.resolve()); - const pending = takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals: [] }); + const pending = takeoverCommand({ + host: createTestHostEnvironment(), + client, + flags: TAKEOVER_CLI_FLAGS, + positionals: [], + }); await started.promise; await vi.advanceTimersByTimeAsync(5_000); process.emit('SIGINT'); @@ -110,7 +130,12 @@ test('a failed heartbeat stops foreground takeover and attempts release', async const { client, transport } = clientForTest(); const started = createControlLatch(); mocks.writeCommandOutput.mockImplementationOnce(() => started.resolve()); - const pending = takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals: [] }); + const pending = takeoverCommand({ + host: createTestHostEnvironment(), + client, + flags: TAKEOVER_CLI_FLAGS, + positionals: [], + }); const rejected = assert.rejects(pending, /heartbeat failed/); await started.promise; transport.mockRejectedValueOnce(new Error('heartbeat failed')); diff --git a/src/__tests__/test-utils/connect-command.ts b/src/__tests__/test-utils/connect-command.ts index 5550ee9916..9ac6fb5866 100644 --- a/src/__tests__/test-utils/connect-command.ts +++ b/src/__tests__/test-utils/connect-command.ts @@ -1,3 +1,5 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { connectCommand } from '../../cli/commands/connection.ts'; import type { AgentDeviceClient } from '../../agent-device-client.ts'; import { beginStdoutCapture } from './stream-capture.ts'; @@ -5,12 +7,14 @@ import { beginStdoutCapture } from './stream-capture.ts'; /** Runs `connect` the way the CLI does, writing its generated profile under `stateDir`. */ export async function connectWithGeneratedProviderProfile(options: { stateDir: string; + host?: HostEnvironment; positionals: string[]; flags: Partial[0]['flags']>; }): Promise { const stdoutCapture = beginStdoutCapture(); try { await connectCommand({ + host: options.host ?? createTestHostEnvironment(), positionals: options.positionals, flags: { json: true, diff --git a/src/cli.ts b/src/cli.ts index 0847e881a1..aaecb8c0b5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -219,6 +219,7 @@ async function runCliOnHost(argv: string[], deps: CliDeps, host: HostEnvironment const client = createAgentDeviceClient(buildClientConfig(ctx), { host: ctx.host, transport: createCliDaemonTransport({ + host, command, flags: ctx.effectiveFlags, replayTestReporterRuntime, @@ -448,6 +449,7 @@ async function resolveRemoteContext( const { materializeRemoteConnectionForCommand } = await import('./cli/commands/connection-runtime.ts'); const materialized = await materializeRemoteConnectionForCommand({ + host: ctx.host, command: ctx.command, flags: ctx.effectiveFlags, client: materializationClient, @@ -573,6 +575,7 @@ async function dispatchCliCommand( })); if ( await tryRunClientBackedCommand({ + host: ctx.host, command, positionals, flags: { ...effectiveFlags, batchSteps }, @@ -591,6 +594,7 @@ async function dispatchCliCommand( ); } else if ( await tryRunClientBackedCommand({ + host: ctx.host, command, positionals, flags: effectiveFlags, @@ -852,6 +856,7 @@ function hasExplicitMetroRuntimeOverrides(explicitFlagKeys: Set): boole } type CliDaemonTransportSetup = { + host: HostEnvironment; command: string; flags: CliFlags; replayTestReporterRuntime?: ReplayTestReporterRuntime; @@ -869,7 +874,7 @@ function createCliDaemonTransport(options: CliDaemonTransportSetup): AgentDevice return async (req, context) => { const response = await send(req, context); const { forgetEndedProxyLease } = await import('./cli/connection/ended-lease.ts'); - forgetEndedProxyLease(options.flags, response); + forgetEndedProxyLease(options.flags, response, options.host); return response; }; } diff --git a/src/cli/commands/__tests__/daemon.test.ts b/src/cli/commands/__tests__/daemon.test.ts index d10e2f4197..14e3f197b2 100644 --- a/src/cli/commands/__tests__/daemon.test.ts +++ b/src/cli/commands/__tests__/daemon.test.ts @@ -2,7 +2,11 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { afterEach, expect, test, vi } from 'vitest'; import type { DaemonStopResult } from '@agent-device/daemon-contracts/daemon-stop'; -import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; const mocks = vi.hoisted(() => ({ cleanupDaemonOwner: vi.fn(async () => undefined), @@ -56,6 +60,7 @@ test('accepts only daemon stop and status', async () => { await assert.rejects( async () => await daemonCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { help: false, json: false, version: false }, client: {} as never, @@ -65,6 +70,7 @@ test('accepts only daemon stop and status', async () => { await assert.rejects( async () => await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['stop', 'extra'], flags: { help: false, json: false, version: false }, client: {} as never, @@ -93,6 +99,7 @@ test('merges a graceful shutdown report and cleans runner leases with the start- try { await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['stop'], flags: { clean: true, help: false, json: false, stateDir, version: false }, client: {} as never, @@ -132,6 +139,7 @@ test('reports graceful provider cleanup as unknown when the shutdown report is u try { await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['stop'], flags: { help: false, json: false, stateDir, version: false }, client: {} as never, @@ -169,6 +177,7 @@ test('warns in text output when a graceful stop leaves an orphaned claim', async try { await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['stop'], flags: { clean: false, help: false, json: false, stateDir, version: false }, client: {} as never, @@ -206,6 +215,7 @@ test('routes an unattributable claim to the default status view, never to --stal try { await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['stop'], flags: { clean: false, help: false, json: false, stateDir, version: false }, client: {} as never, diff --git a/src/cli/commands/__tests__/generic.test.ts b/src/cli/commands/__tests__/generic.test.ts index 846b552391..d32e33716a 100644 --- a/src/cli/commands/__tests__/generic.test.ts +++ b/src/cli/commands/__tests__/generic.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import { withCapturedStdoutAsync } from '../../../__tests__/test-utils/stream-capture.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { createAgentDeviceClient } from '../../../agent-device-client.ts'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { CliFlags } from '@agent-device/contracts/command'; @@ -25,6 +25,7 @@ test('snapshot --level digest --json preserves the digest through the generic CL const out = await withCapturedStdoutAsync(() => runGenericClientBackedCommand({ + host: createTestHostEnvironment(), command: 'snapshot' as ClientBackedCliCommandName, positionals: [], flags, @@ -66,6 +67,7 @@ test('replay human output keeps the composable warnings channel (#2560)', async const out = await withCapturedStdoutAsync(() => runGenericClientBackedCommand({ + host: createTestHostEnvironment(), command: 'replay' as ClientBackedCliCommandName, positionals: [scriptPath], flags: {} as CliFlags, @@ -100,6 +102,7 @@ test('the generic CLI path prints the response warnings', async () => { const out = await withCapturedStdoutAsync(() => runGenericClientBackedCommand({ + host: createTestHostEnvironment(), command: 'type' as ClientBackedCliCommandName, positionals: ['hi'], flags: {} as CliFlags, diff --git a/src/cli/commands/__tests__/proxy.test.ts b/src/cli/commands/__tests__/proxy.test.ts index 3ed9d958a7..1957a7164a 100644 --- a/src/cli/commands/__tests__/proxy.test.ts +++ b/src/cli/commands/__tests__/proxy.test.ts @@ -11,6 +11,7 @@ import { mkdtempForTestSync, skipWhenLoopbackUnavailable, bindTestCommandHost, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; @@ -70,6 +71,7 @@ function runProxy(flags: Partial[0]['flags']> = // The command streams its startup card and then serves until interrupted, so the handler // promise intentionally never settles; the test observes the emitted output and lets it float. void proxyCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, help: false, version: false, ...flags }, client: unreferencedClient, @@ -93,6 +95,7 @@ async function waitForStartupOutput(): Promise<{ test('proxy rejects positional arguments', async () => { await assert.rejects( proxyCommand({ + host: createTestHostEnvironment(), positionals: ['extra'], flags: { json: true, help: false, version: false }, client: unreferencedClient, @@ -110,6 +113,7 @@ test('proxy refuses to start when the local daemon has no HTTP endpoint', async await assert.rejects( proxyCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, help: false, version: false }, client: unreferencedClient, diff --git a/src/cli/commands/__tests__/recording.test.ts b/src/cli/commands/__tests__/recording.test.ts index 72963b7106..ad7c028465 100644 --- a/src/cli/commands/__tests__/recording.test.ts +++ b/src/cli/commands/__tests__/recording.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { describe, expect, test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import type { ClientCommandParams } from '../router-types.ts'; @@ -5,6 +6,7 @@ import { recordingCommand } from '../recording.ts'; function params(positionals: string[]): ClientCommandParams { return { + host: createTestHostEnvironment(), positionals, flags: {} as ClientCommandParams['flags'], client: {} as ClientCommandParams['client'], diff --git a/src/cli/commands/__tests__/screenshot.test.ts b/src/cli/commands/__tests__/screenshot.test.ts index 1828612407..e108e2945c 100644 --- a/src/cli/commands/__tests__/screenshot.test.ts +++ b/src/cli/commands/__tests__/screenshot.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import { withCapturedStdoutAsync } from '../../../__tests__/test-utils/stream-capture.ts'; @@ -32,7 +33,7 @@ test('screenshot --level digest --json preserves the digest payload through the const flags = { json: true, responseLevel: 'digest' } as CliFlags; const out = await withCapturedStdoutAsync(() => - screenshotCommand({ positionals: [], flags, client }), + screenshotCommand({ host: createTestHostEnvironment(), positionals: [], flags, client }), ); const parsed = JSON.parse(out) as { success: boolean; data: Record }; @@ -50,7 +51,7 @@ test('screenshot surfaces response-level warnings under the summary', async () = const flags = { json: false } as CliFlags; const out = await withCapturedStdoutAsync(() => - screenshotCommand({ positionals: [], flags, client }), + screenshotCommand({ host: createTestHostEnvironment(), positionals: [], flags, client }), ); assert.equal(out, `/tmp/shot.png (40x20)\n${CROP_WARNING}\n`); @@ -62,7 +63,7 @@ test('screenshot --json carries response-level warnings in the data', async () = const flags = { json: true } as CliFlags; const out = await withCapturedStdoutAsync(() => - screenshotCommand({ positionals: [], flags, client }), + screenshotCommand({ host: createTestHostEnvironment(), positionals: [], flags, client }), ); const parsed = JSON.parse(out) as { data: { path: string; warnings?: string[] } }; @@ -84,7 +85,7 @@ test('screenshot --json at the default level still emits normalized screenshot m const flags = { json: true } as CliFlags; const out = await withCapturedStdoutAsync(() => - screenshotCommand({ positionals: [], flags, client }), + screenshotCommand({ host: createTestHostEnvironment(), positionals: [], flags, client }), ); const parsed = JSON.parse(out) as { data: { path: string; width?: number; pixelDensity?: number }; diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index 605a83ca8e..797f127335 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -1,15 +1,14 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { AppError } from '@agent-device/kernel/errors'; import { loginWithDeviceAuth, removeCliSession, summarizeCliSession } from '../auth-session.ts'; import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; -export const authCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const authCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { const subcommand = positionals[0] ?? 'status'; const stateDir = resolveDaemonPaths(flags.stateDir, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).baseDir; if (subcommand === 'status') { const status = summarizeCliSession({ stateDir }); @@ -21,7 +20,7 @@ export const authCommand: ClientCommandHandler = async ({ positionals, flags }) stateDir, flags, commandLabel: 'agent-device auth login', - env: commandHostEnvironment().env, + env: host.env, }); const data = { authenticated: true, diff --git a/src/cli/commands/connection-runtime-metro-teardown.test.ts b/src/cli/commands/connection-runtime-metro-teardown.test.ts index 30a0ceb3d6..5c68b32452 100644 --- a/src/cli/commands/connection-runtime-metro-teardown.test.ts +++ b/src/cli/commands/connection-runtime-metro-teardown.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -60,6 +60,7 @@ test('deferred materialization stops the new Metro companion if state persistenc await assert.rejects( async () => await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { json: true, diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index a99feeb2e6..28a8a36e32 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import crypto from 'node:crypto'; import type { MetroBridgeScope } from '@agent-device/contracts/remote'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; @@ -61,6 +61,7 @@ export const PROXY_REMOTE_LEASE_TTL_MS = 5 * 60 * 1000; export const CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS = 10 * 60 * 1000; export async function materializeRemoteConnectionForCommand(options: { + host: HostEnvironment; command: string; flags: CliFlags; client: AgentDeviceClient; @@ -73,19 +74,19 @@ export async function materializeRemoteConnectionForCommand(options: { runtime?: SessionRuntimeHints; connection?: RemoteConnectionRequestMetadata; }> { - const { command, flags, client } = options; + const { command, flags, client, host } = options; if (!flags.remoteConfig) { return { flags, runtime: options.runtime }; } const stateDir = resolveDaemonPaths(flags.stateDir, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).baseDir; const remoteConfig = resolveRemoteConfigProfile({ configPath: flags.remoteConfig, - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }); const profileFlags = profileToCliFlags(remoteConfig.profile); const mergedFlags = { diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 9741225747..0f50ded8e0 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { CloudArtifact, CloudProviderSessionResult, @@ -54,10 +54,15 @@ import { type RuntimePreparationNotice, } from './connection-presentation.ts'; -export const connectCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { +export const connectCommand: ClientCommandHandler = async ({ + positionals, + flags, + client, + host, +}) => { const stateDir = resolveDaemonPaths(flags.stateDir, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).baseDir; await primeProviderConnection(positionals[0]); const provider = readConnectProvider(positionals); @@ -66,11 +71,11 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, provider, flags, stateDir, - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }); const connectFlags = resolved.flags; - const connectionMetadata = readRemoteConfigConnectionMetadata(resolved.remoteConfigPath); + const connectionMetadata = readRemoteConfigConnectionMetadata(resolved.remoteConfigPath, host); const scope = readRequiredConnectScope(connectFlags, connectionMetadata); const context = resolveConnectContext({ stateDir, @@ -86,7 +91,7 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, connection: connectionMetadata, daemon: context.daemon, }); - const verification = await verifyResolvedConnectProvider(resolved, commandHostEnvironment().env); + const verification = await verifyResolvedConnectProvider(resolved, host.env); const state = buildConnectedState({ flags: connectFlags, scope, @@ -100,8 +105,9 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, connectFlags, context.previous, state, + host, ); - const runtimePreparation = buildRuntimePreparationNotice(connectFlags, state); + const runtimePreparation = buildRuntimePreparationNotice(connectFlags, state, host); const readiness = presentConnectReadiness(state, verification); await writeCommandOutput( @@ -280,6 +286,7 @@ async function cleanupForcedPreviousConnection( flags: CliFlags, previous: RemoteConnectionState | null, next: RemoteConnectionState, + host: HostEnvironment, ): Promise { if (!previous || !flags.force) return undefined; await stopMetroCleanup(previous.metro); @@ -287,24 +294,25 @@ async function cleanupForcedPreviousConnection( return await releasePreviousLease(client, previous, { nextDaemonBaseUrl: next.daemon?.baseUrl, ambientDaemonAuthToken: flags.daemonAuthToken, - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }); } function readRemoteConfigConnectionMetadata( remoteConfigPath: string, + host: HostEnvironment, ): RemoteConnectionRequestMetadata | undefined { const profile = resolveRemoteConfigProfile({ configPath: remoteConfigPath, - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).profile; return buildRemoteConnectionRequestMetadata(profile); } -export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) => { - const { session, stateDir, state } = readRequestedConnectionState(flags); +export const disconnectCommand: ClientCommandHandler = async ({ flags, client, host }) => { + const { session, stateDir, state } = readRequestedConnectionState(flags, host); if (!state) { await writeNoRemoteConnectionOutput(flags, session); return true; @@ -346,18 +354,18 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) return true; }; -export const connectionCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const connectionCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { if (positionals[0] !== 'status') { throw new AppError('INVALID_ARGS', 'connection accepts only: status'); } - const { session, state } = readRequestedConnectionState(flags); + const { session, state } = readRequestedConnectionState(flags, host); if (!state) { await writeNoRemoteConnectionOutput(flags, session); return true; } await primeProviderConnection(state.leaseProvider); const leasePreparation = buildLeasePreparationNotice(state); - const runtimePreparation = buildRuntimePreparationNoticeFromState(state); + const runtimePreparation = buildRuntimePreparationNoticeFromState(state, host); await writeCommandOutput(flags, serializeConnectionState({ state, runtimePreparation }), () => [ `Configured remote session "${state.session}".`, @@ -424,15 +432,18 @@ function readConnectProvider(positionals: string[]): ConnectProvider | undefined ); } -function readRequestedConnectionState(flags: CliFlags): { +function readRequestedConnectionState( + flags: CliFlags, + host: HostEnvironment, +): { session: string; stateDir: string; state: RemoteConnectionState | null; } { const session = flags.session ?? 'default'; const stateDir = resolveDaemonPaths(flags.stateDir, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).baseDir; return { session, @@ -518,9 +529,13 @@ function isSameDaemonState( function buildRuntimePreparationNotice( flags: CliFlags, state: RemoteConnectionState, + host: HostEnvironment, ): RuntimePreparationNotice | undefined { if (state.runtime) return undefined; - if (!hasDeferredMetroConfig(flags) && !remoteConfigHasMetroSettings(state.remoteConfigPath)) { + if ( + !hasDeferredMetroConfig(flags) && + !remoteConfigHasMetroSettings(state.remoteConfigPath, host) + ) { return undefined; } return buildDeferredRuntimeNotice(state); @@ -528,8 +543,10 @@ function buildRuntimePreparationNotice( function buildRuntimePreparationNoticeFromState( state: RemoteConnectionState, + host: HostEnvironment, ): RuntimePreparationNotice | undefined { - if (state.runtime || !remoteConfigHasMetroSettings(state.remoteConfigPath)) return undefined; + if (state.runtime || !remoteConfigHasMetroSettings(state.remoteConfigPath, host)) + return undefined; return buildDeferredRuntimeNotice(state); } @@ -547,12 +564,12 @@ function buildDeferredRuntimeNotice(state: RemoteConnectionState): RuntimePrepar }; } -function remoteConfigHasMetroSettings(remoteConfigPath: string): boolean { +function remoteConfigHasMetroSettings(remoteConfigPath: string, host: HostEnvironment): boolean { try { const remoteConfig = resolveRemoteConfigProfile({ configPath: remoteConfigPath, - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }); const profile = remoteConfig.profile; return Boolean( diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 6ec23edaa1..8d3499452f 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { readPluginInventory, type PluginInventoryEntry, @@ -22,14 +21,14 @@ import { createDaemonOwnerCleanup } from '../../platform-runtime-daemon-owner-cl const daemonOwnerCleanup = createDaemonOwnerCleanup(); -export const daemonCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const daemonCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { const subcommand = positionals[0]; if ((subcommand !== 'stop' && subcommand !== 'status') || positionals.length !== 1) { throw new AppError('INVALID_ARGS', 'daemon accepts only: stop, status'); } const paths = resolveDaemonPaths(flags.stateDir, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }); if (subcommand === 'status') { const status = readDaemonStatus(paths); diff --git a/src/cli/commands/device.ts b/src/cli/commands/device.ts index 113f556398..2e2a40ea95 100644 --- a/src/cli/commands/device.ts +++ b/src/cli/commands/device.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { AppError } from '@agent-device/kernel/errors'; import { publicPlatformString } from '@agent-device/kernel/device'; import { @@ -14,11 +14,11 @@ import type { CliFlags } from '@agent-device/contracts/command'; import type { ClientCommandHandler } from './router-types.ts'; // Loaded dynamically by dedicatedCliCommandHandlerLoaders in router.ts. -export const deviceCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const deviceCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { if (positionals.length !== 1 || (positionals[0] !== 'status' && positionals[0] !== 'release')) { throw new AppError('INVALID_ARGS', 'device accepts only: status, release'); } - if (positionals[0] === 'release') return await runDeviceRelease(flags); + if (positionals[0] === 'release') return await runDeviceRelease(flags, host); const inspectedClaims = inspectDeviceClaims( { platform: flags.platform, @@ -26,7 +26,7 @@ export const deviceCommand: ClientCommandHandler = async ({ positionals, flags } udid: flags.udid, serial: flags.serial, }, - commandHostEnvironment().env, + host.env, ); const staleClaims = inspectedClaims.filter((claim) => deviceClaimRequiresStaleInspection(claim.classification), @@ -59,7 +59,7 @@ export const deviceCommand: ClientCommandHandler = async ({ positionals, flags } * provably cannot release them. Everything else fails closed and is reported * with the reason, so a live or uncertain owner is never interrupted. */ -async function runDeviceRelease(flags: CliFlags): Promise { +async function runDeviceRelease(flags: CliFlags, host: HostEnvironment): Promise { if (flags.stale !== true) { throw new AppError( 'INVALID_ARGS', @@ -77,7 +77,7 @@ async function runDeviceRelease(flags: CliFlags): Promise { udid: flags.udid, serial: flags.serial, }, - commandHostEnvironment().env, + host.env, ); const data = { released: outcomes.filter((outcome) => outcome.status === 'released').map(serializeOutcome), diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index 5358ec58d4..4cfb6fe3c8 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { randomBytes } from 'node:crypto'; import { createDaemonProxyServer } from '@agent-device/proxy'; import { buildDaemonHttpBaseUrl } from '@agent-device/contracts/daemon-http'; @@ -20,17 +20,21 @@ type ProxyStartup = { stateDir: string; }; -export const proxyCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const proxyCommand: ClientCommandHandler = async ({ + positionals, + flags, + host: clientHost, +}) => { if (positionals.length > 0) { throw new AppError('INVALID_ARGS', 'proxy does not accept positional arguments.'); } - const startup = await startProxy(flags); + const startup = await startProxy(flags, clientHost); await writeCommandOutput(flags, startup, () => renderProxyStartup(startup)); await waitForever(); return true; }; -async function startProxy(flags: CliFlags): Promise { +async function startProxy(flags: CliFlags, clientHost: HostEnvironment): Promise { const settings = resolveClientSettings( { session: 'default', @@ -44,7 +48,7 @@ async function startProxy(flags: CliFlags): Promise { }, }, undefined, - commandHostEnvironment(), + clientHost, ); const daemon = await ensureManagedDaemon(settings); const upstreamBaseUrl = resolveLocalDaemonBaseUrl(daemon.info.httpPort); diff --git a/src/cli/commands/recording.ts b/src/cli/commands/recording.ts index 8be1aa533f..868ce38b24 100644 --- a/src/cli/commands/recording.ts +++ b/src/cli/commands/recording.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { AppError } from '@agent-device/kernel/errors'; import { localCommandPolicy } from '@agent-device/contracts/command-policy'; import type { RecordingContactSheetCommandResult } from '../../commands/recording/runtime/contact-sheet.ts'; @@ -9,7 +8,7 @@ import type { ClientCommandHandler } from './router-types.ts'; /** The `record` action that reads a finished file locally instead of asking a device for anything. */ const CONTACT_SHEET_ACTION = 'contact-sheet'; -export const recordingCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const recordingCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { if (positionals[0] !== CONTACT_SHEET_ACTION) return false; if (positionals.length > 2) { throw new AppError( @@ -19,12 +18,10 @@ export const recordingCommand: ClientCommandHandler = async ({ positionals, flag } const recordingPath = resolveUserPath(readRequiredPositional(positionals[1]), { - cwd: commandHostEnvironment().cwd, + cwd: host.cwd, }); const outputPath = - typeof flags.out === 'string' - ? resolveUserPath(flags.out, { cwd: commandHostEnvironment().cwd }) - : undefined; + typeof flags.out === 'string' ? resolveUserPath(flags.out, { cwd: host.cwd }) : undefined; // Lazy: createAgentDevice pulls in the client-side command runtime, which only this action needs. const [{ createAgentDevice }, { createLocalArtifactAdapter }] = await Promise.all([ @@ -33,7 +30,7 @@ export const recordingCommand: ClientCommandHandler = async ({ positionals, flag ]); const runtime = createAgentDevice({ backend: { platform: 'ios' }, - artifacts: createLocalArtifactAdapter(), + artifacts: createLocalArtifactAdapter({ cwd: host.cwd }), sessions: { get: (name) => ({ name }), set: () => {}, diff --git a/src/cli/commands/replay.ts b/src/cli/commands/replay.ts index c979b63587..96d8caf968 100644 --- a/src/cli/commands/replay.ts +++ b/src/cli/commands/replay.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; @@ -31,6 +30,7 @@ function handleReplayRunCommand({ positionals, flags }: ReplayCommandParams): fa async function handleReplayExportCommand({ positionals, flags, + host, }: ReplayCommandParams): Promise { validateReplayExportOptions(positionals, flags); const inputPath = positionals[1]; @@ -38,7 +38,7 @@ async function handleReplayExportCommand({ throw new AppError('INVALID_ARGS', 'replay export requires an input path.'); } - const sourcePath = resolveUserPath(inputPath, { cwd: commandHostEnvironment().cwd }); + const sourcePath = resolveUserPath(inputPath, { cwd: host.cwd }); const script = fs.readFileSync(sourcePath, 'utf8'); const parsed = parseReplayScriptDetailed(script); // `replay export` exports to Maestro, so the conversion goes through that backend's registry @@ -52,9 +52,7 @@ async function handleReplayExportCommand({ metadata: readReplayScriptMetadata(script), }); const outputPath = - typeof flags.out === 'string' - ? resolveUserPath(flags.out, { cwd: commandHostEnvironment().cwd }) - : undefined; + typeof flags.out === 'string' ? resolveUserPath(flags.out, { cwd: host.cwd }) : undefined; if (outputPath) { fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, result.yaml); diff --git a/src/cli/commands/router-types.ts b/src/cli/commands/router-types.ts index 2d0c9e38f0..609b223ef8 100644 --- a/src/cli/commands/router-types.ts +++ b/src/cli/commands/router-types.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { CliFlags } from '@agent-device/contracts/command'; import type { AgentDeviceClient } from '../../agent-device-client.ts'; import type { CliCommandName } from '@agent-device/command-registry/catalog'; @@ -5,6 +6,8 @@ import type { CommandProgressState } from '../../commands/command-progress.ts'; import type { ReplayTestReporterRuntime } from '../replay-test/reporting.ts'; export type ClientCommandParams = { + /** The host this run reads its environment and working directory from. */ + host: HostEnvironment; positionals: string[]; flags: CliFlags; client: AgentDeviceClient; diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index 6774e2a6d4..14220317d1 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -26,6 +26,7 @@ const dedicatedCliCommandHandlerLoaders = { } satisfies ClientCommandHandlerMap; export async function tryRunClientBackedCommand(params: { + host: ClientCommandParams['host']; command: string; positionals: string[]; flags: CliFlags; diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts index f3ee104fb7..f12fe61f20 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { localCommandPolicy } from '@agent-device/contracts/command-policy'; import { formatScreenshotDiffText, formatSnapshotDiffText } from '../../commands/output/diff.ts'; import type { ScreenshotDiffResult } from '@agent-device/capture-kit/screenshot-diff'; @@ -38,7 +37,7 @@ export const screenshotCommand: ClientCommandHandler = async ({ positionals, fla return true; }; -export const diffCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { +export const diffCommand: ClientCommandHandler = async ({ positionals, flags, client, host }) => { if (positionals[0] === 'snapshot') { const result = await runCliCommand({ client, command: 'diff', positionals, flags }); await writeCommandOutput(flags, result, () => formatSnapshotDiffText(result)); @@ -52,11 +51,9 @@ export const diffCommand: ClientCommandHandler = async ({ positionals, flags, cl throw new AppError('INVALID_ARGS', 'diff screenshot requires --baseline '); } - const baselinePath = resolveUserPath(baselineRaw, { cwd: commandHostEnvironment().cwd }); + const baselinePath = resolveUserPath(baselineRaw, { cwd: host.cwd }); const outputPath = - typeof flags.out === 'string' - ? resolveUserPath(flags.out, { cwd: commandHostEnvironment().cwd }) - : undefined; + typeof flags.out === 'string' ? resolveUserPath(flags.out, { cwd: host.cwd }) : undefined; const currentRaw = positionals[1]; if (positionals.length > 2) { throw new AppError( @@ -73,7 +70,7 @@ export const diffCommand: ClientCommandHandler = async ({ positionals, flags, cl ]); const runtime = createAgentDevice({ backend: createClientScreenshotBackend(client, flags), - artifacts: createLocalArtifactAdapter(), + artifacts: createLocalArtifactAdapter({ cwd: host.cwd }), sessions: { get: (name) => ({ name }), set: () => {}, @@ -85,7 +82,7 @@ export const diffCommand: ClientCommandHandler = async ({ positionals, flags, cl session: flags.session, baseline: { kind: 'path', path: baselinePath }, current: currentRaw - ? { kind: 'path', path: resolveUserPath(currentRaw, { cwd: commandHostEnvironment().cwd }) } + ? { kind: 'path', path: resolveUserPath(currentRaw, { cwd: host.cwd }) } : { kind: 'live' }, ...(outputPath ? { out: { kind: 'path', path: outputPath } } : {}), threshold: parseCliThreshold(flags.threshold), diff --git a/src/cli/connection/ended-lease.test.ts b/src/cli/connection/ended-lease.test.ts index 453b7ee489..d73f4b4eeb 100644 --- a/src/cli/connection/ended-lease.test.ts +++ b/src/cli/connection/ended-lease.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { test } from 'vitest'; @@ -46,6 +46,7 @@ function storedLeaseIdAfter( session: 'adc-proxy', }, response, + createTestHostEnvironment(), ); return readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId; } diff --git a/src/cli/connection/ended-lease.ts b/src/cli/connection/ended-lease.ts index d1c84ae002..0415d5a12c 100644 --- a/src/cli/connection/ended-lease.ts +++ b/src/cli/connection/ended-lease.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { CliFlags } from '@agent-device/contracts/command'; import { readEndedLeases } from '@agent-device/contracts/lease-scope'; @@ -14,13 +14,17 @@ import { connectionProviderCapabilities } from './provider-policy.ts'; * Forgets the stored proxy lease once a daemon reply says its request ended that lease, so the next * `open` allocates instead of heartbeating a lease that no longer exists. */ -export function forgetEndedProxyLease(flags: CliFlags, response: DaemonResponse): void { +export function forgetEndedProxyLease( + flags: CliFlags, + response: DaemonResponse, + host: HostEnvironment, +): void { if (!flags.remoteConfig) return; const endedLeaseIds = readEndedLeases(response).map((lease) => lease.leaseId); if (endedLeaseIds.length === 0) return; const stateDir = resolveDaemonPaths(flags.stateDir, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).baseDir; const state = readRemoteConnectionState({ stateDir, session: flags.session ?? 'default' }); if (!state?.leaseId || !endedLeaseIds.includes(state.leaseId)) return; From ffefdc18bab93c3bfd05eb196fd9b77ca0034abf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 12:59:09 +0200 Subject: [PATCH 7/9] test(host): bind host-taking commands once in a shared helper Co-Authored-By: Claude Sonnet 5.5 --- src/__tests__/cli-client-commands.test.ts | 29 +--------- src/__tests__/remote-connection.test.ts | 56 +------------------ .../test-utils/host-bound-commands.ts | 17 ++++++ src/__tests__/test-utils/with-test-host.ts | 9 +++ 4 files changed, 30 insertions(+), 81 deletions(-) create mode 100644 src/__tests__/test-utils/host-bound-commands.ts create mode 100644 src/__tests__/test-utils/with-test-host.ts diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index 851d0a1040..4192c655c2 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import { PNG } from '@agent-device/capture-kit/png'; -import { tryRunClientBackedCommand } from '../cli/commands/router.ts'; +import { tryRunClientBackedCommand } from './test-utils/host-bound-commands.ts'; import { runCliCapture } from './cli-capture.ts'; import type { AgentDeviceClient, @@ -15,7 +15,7 @@ import type { import type { SettingsUpdateOptions } from '@agent-device/contracts/client'; import { AppError } from '@agent-device/kernel/errors'; import { resolveCliOptions } from '../cli/resolve-cli-options.ts'; -import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; import { createStubClientLeases } from './test-utils/client-lease-fixtures.ts'; import { withCapturedOutput, withCapturedStdoutAsync } from './test-utils/stream-capture.ts'; @@ -42,7 +42,6 @@ test('install-from-source forwards URL and repeated headers to client.apps.insta }); const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'install-from-source', positionals: ['https://example.com/app.apk'], flags: { @@ -85,7 +84,6 @@ test('install-from-source forwards GitHub Actions artifact flag to client.apps.i }); const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'install-from-source', positionals: [], flags: { @@ -122,7 +120,6 @@ test('install-from-source preserves colons in GitHub Actions artifact names', as }); await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'install-from-source', positionals: [], flags: { @@ -156,7 +153,6 @@ test('install-from-source forwards configured GitHub Actions artifact name to cl }); const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'install-from-source', positionals: [], flags: { @@ -193,7 +189,6 @@ test('install-from-source rejects malformed header syntax', async () => { await assert.rejects( () => tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'install-from-source', positionals: ['https://example.com/app.apk'], flags: { @@ -221,7 +216,6 @@ test('install-from-source rejects headers with GitHub Actions artifact sources', await assert.rejects( () => tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'install-from-source', positionals: [], flags: { @@ -274,7 +268,6 @@ test('metro prepare forwards normalized options to client.metro.prepare', async const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'metro', positionals: ['prepare'], flags: { @@ -337,7 +330,6 @@ test('metro prepare rejects when no public or proxy base URL is provided', async await assert.rejects( () => tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'metro', positionals: ['prepare'], flags: { @@ -374,7 +366,6 @@ test('metro reload forwards host, port, bundle URL, and timeout to client.metro. const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'metro', positionals: ['reload'], flags: { @@ -429,7 +420,6 @@ test('events prints a parsed session timeline from the generic CLI route', async const output = await withCapturedOutput(async () => { const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'events', positionals: [], flags: { @@ -471,7 +461,6 @@ test('screenshot forwards --overlay-refs to the client capture API', async () => }); const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'screenshot', positionals: ['/tmp/screenshot.png'], flags: { @@ -521,7 +510,6 @@ test('diff screenshot forwards --surface to live client screenshot capture', asy await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'diff', positionals: ['screenshot'], flags: { @@ -566,7 +554,6 @@ test('open forwards macOS surface to the client apps API', async () => { }); const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'open', positionals: ['MenuBarApp'], flags: { @@ -602,7 +589,6 @@ test('open forwards launch console option to the client apps API', async () => { }); const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'open', positionals: ['Agent Device Tester'], flags: { @@ -636,7 +622,6 @@ test('apps command defaults to user-installed and prints discovery hint', async const output = await withCapturedOutput(async () => { const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'apps', positionals: [], flags: { @@ -684,7 +669,6 @@ test('screenshot reports annotated ref count in non-json mode', async () => { const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'screenshot', positionals: ['/tmp/screenshot.png'], flags: { @@ -719,7 +703,6 @@ click text="Continue" const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'replay', positionals: ['export', sourcePath], flags: { @@ -801,7 +784,6 @@ test('replay rejects extra plain replay paths before daemon dispatch', async () await assert.rejects( async () => await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'replay', positionals: ['one.ad', 'two.ad'], flags: { @@ -832,7 +814,6 @@ test('wait keeps CLI bare text behavior through the typed client command API', a }; const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'wait', positionals: ['Continue', '1500'], flags: { @@ -867,7 +848,6 @@ test('keyboard status prints Android input ownership in human output', async () const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'keyboard', positionals: ['status'], flags: { @@ -900,7 +880,6 @@ test('metro prepare wraps output in the standard success envelope for --json', a const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'metro', positionals: ['prepare'], flags: { @@ -974,7 +953,6 @@ test('metro prepare with --remote-config loads profile defaults', async () => { const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'metro', positionals: ['prepare'], flags: parsed.flags, @@ -1015,7 +993,6 @@ test('install prints command-owned success output in human mode', async () => { const stdout = await withCapturedStdoutAsync(async () => { const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'install', positionals: ['Demo', '/tmp/Demo.app'], flags: { @@ -1046,7 +1023,6 @@ test('settings location set forwards coordinates to client settings update', asy }); const handled = await tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'settings', positionals: ['location', 'set', '37.3349', '-122.009'], flags: { @@ -1088,7 +1064,6 @@ test('settings location set rejects invalid coordinates before client call', asy await assert.rejects( () => tryRunClientBackedCommand({ - host: createTestHostEnvironment(), command: 'settings', positionals, flags: { diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 1a153fe270..d903cda234 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -1,4 +1,3 @@ -import { createTestHostEnvironment } from '@agent-device/test-kit'; import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { withCapturedStdoutAsync } from './test-utils/stream-capture.ts'; @@ -26,11 +25,11 @@ import { connectCommand, connectionCommand, disconnectCommand, -} from '../cli/commands/connection.ts'; + materializeRemoteConnectionForCommand, +} from './test-utils/host-bound-commands.ts'; import { writeGeneratedRemoteConfig } from '../cli/connection/generated-config.ts'; import { hasDeferredMetroConfig, - materializeRemoteConnectionForCommand, CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS, PROXY_REMOTE_LEASE_TTL_MS, } from '../cli/commands/connection-runtime.ts'; @@ -79,7 +78,6 @@ test('connect auto-generates a local session and writes minimal remote state', a await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -115,7 +113,6 @@ test('connect proxy writes normal remote state with generated non-secret profile await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: ['proxy'], flags: { json: true, @@ -164,7 +161,6 @@ test('connect daemon-base-url shortcut uses proxy profile for direct proxy URLs' await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -197,7 +193,6 @@ test('connect proxy scopes generated client identity by explicit session', async for (const session of ['agent-a', 'agent-b']) { await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: ['proxy'], flags: { json: true, @@ -227,7 +222,6 @@ test('connect proxy notice distinguishes safe inventory from lease allocation', const stdout = await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: ['proxy'], flags: { json: false, @@ -282,7 +276,6 @@ test('connect proxy rejects remote-config and unknown provider combinations', as await assert.rejects( async () => await connectCommand({ - host: createTestHostEnvironment(), positionals: ['proxy'], flags: { json: true, @@ -299,7 +292,6 @@ test('connect proxy rejects remote-config and unknown provider combinations', as await assert.rejects( async () => await connectCommand({ - host: createTestHostEnvironment(), positionals: ['wat'], flags: { json: true, @@ -329,7 +321,6 @@ test('connect reports deferred Metro runtime preparation when remote config has const stdout = await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: { json: false, @@ -380,7 +371,6 @@ test('connection status re-emits the deferred Metro command scoped to the named ); await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -399,7 +389,6 @@ test('connection status re-emits the deferred Metro command scoped to the named const stdout = await withCapturedStdoutAsync(async () => { await connectionCommand({ - host: createTestHostEnvironment(), positionals: ['status'], flags: { json: true, @@ -443,7 +432,6 @@ test('connect without a session creates a fresh connection without replacing the await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: connectFlags, client: createTestClient(), @@ -453,7 +441,6 @@ test('connect without a session creates a fresh connection without replacing the await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: connectFlags, client: createTestClient(), @@ -483,7 +470,6 @@ test('connect missing scope errors mention remote config or flags', async () => await assert.rejects( async () => await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -502,7 +488,6 @@ test('connect missing scope errors mention remote config or flags', async () => await assert.rejects( async () => await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -541,7 +526,6 @@ test('deferred materialization allocates lease and prepares Metro for open', asy let observedBridgeScope: { tenantId: string; runId: string; leaseId: string } | undefined; const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'open', flags: { json: true, @@ -612,7 +596,6 @@ test('proxy open resolves device key before allocating lease', async () => { const allocate = recordedLeaseAllocate({ leaseId: 'abc123abc123abc1', backend: 'ios-instance' }); const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'open', flags: { json: true, @@ -681,7 +664,6 @@ test.each(['install', 'uninstall'])('proxy %s allocates a lease first', async (c }); const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command, flags: { json: true, @@ -732,7 +714,6 @@ test('artifacts command reuses the stored cloud lease without allocating', async }); const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'artifacts', flags: { json: true, @@ -785,7 +766,6 @@ test('cloud webdriver connection allocates and heartbeats with extended lease TT let allocateRequest: Parameters[0] | undefined; const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'screenshot', flags: { ...baseFlags }, client: createTestClient({ @@ -808,7 +788,6 @@ test('cloud webdriver connection allocates and heartbeats with extended lease TT let heartbeatRequest: Parameters[0] | undefined; await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'screenshot', flags: { ...baseFlags }, client: createTestClient({ @@ -851,7 +830,6 @@ test('proxy commands without active device lease fail before allocation', async await assert.rejects( async () => await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'snapshot', flags: { json: true, @@ -894,7 +872,6 @@ test('direct remote-config materialization creates state and prepares Metro for let observedBridgeScope: { tenantId: string; runId: string; leaseId: string } | undefined; const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'open', flags: { json: true, @@ -960,7 +937,6 @@ test('deferred materialization prepares Metro for batch when a step opens an app }); const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'batch', flags: { json: true, @@ -1023,7 +999,6 @@ test('deferred materialization re-prepares runtime when explicit Metro overrides let prepareRequest: Parameters[0] | undefined; const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'open', flags: { json: true, @@ -1090,7 +1065,6 @@ test('cdp remote materialization prepares Metro runtime for bridge target discov let prepareRequest: Parameters[0] | undefined; const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'cdp', positionals: ['target', 'list'], flags: { @@ -1152,7 +1126,6 @@ test('cdp remote materialization skips Metro runtime for non-target commands', a try { const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'cdp', positionals: ['memory', 'usage', 'sample'], flags: { @@ -1195,7 +1168,6 @@ test('cdp remote materialization skips Metro runtime without public CDP url', as try { const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'cdp', positionals: ['target', 'list'], flags: { @@ -1250,7 +1222,6 @@ test('deferred materialization heartbeats an existing lease before dispatch', as let allocateCount = 0; const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'apps', flags: { json: true, @@ -1316,7 +1287,6 @@ test('deferred materialization allocates pending lease for devices', async () => let allocateCount = 0; const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'devices', flags: { json: true, @@ -1390,7 +1360,6 @@ test('deferred provider materialization forwards provider profile fields to leas }); const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'open', flags: { json: true, @@ -1443,7 +1412,6 @@ test('deferred materialization reallocates when the persisted lease is inactive' }); const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'apps', flags: { json: true, @@ -1501,7 +1469,6 @@ test('deferred materialization preserves auth failures from lease allocation', a await assert.rejects( async () => await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'apps', flags: { json: true, @@ -1546,7 +1513,6 @@ test('deferred materialization does not require a lease backend for close', asyn }); const materialized = await materializeRemoteConnectionForCommand({ - host: createTestHostEnvironment(), command: 'close', flags: { json: true, @@ -1596,7 +1562,6 @@ test('connect requires force when compatible scope changes platform', async () = await assert.rejects( async () => await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -1640,7 +1605,6 @@ test('connect requires force when the daemon endpoint changes', async () => { await assert.rejects( async () => await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -1688,7 +1652,6 @@ test('connect --force stops replaced Metro companion after state is updated', as await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ stateDir, @@ -1737,7 +1700,6 @@ test('connect --force without a session does not replace the active generated co await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ stateDir, @@ -1779,7 +1741,6 @@ test("connect --force releases the previous lease with the previous connection's await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ stateDir, @@ -1808,7 +1769,6 @@ test('connect --force skips releasing the previous lease when its token cannot b const stdout = await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ json: false, @@ -1843,7 +1803,6 @@ test('connect --force does not misclassify an env-sourced new token as the previ const stdout = await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: [], // No daemonAuthToken flag: the ambient token above flows in purely // through the environment, matching production's resolution chain. @@ -1891,7 +1850,6 @@ test('connect --force does not treat a re-pointed config path’s token as the p const stdout = await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ json: false, stateDir, remoteConfig: remoteConfigPath }), client: createTestClient({ release: release.stub }), @@ -1927,7 +1885,6 @@ test('connect --force does not trust an unchanged profile token for a CLI-overri const stdout = await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ json: false, @@ -1967,7 +1924,6 @@ test('connect --force still releases with a rotated credential when the config k await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ stateDir, remoteConfig: newRemoteConfigPath }), client: createTestClient({ release: release.stub }), @@ -1996,7 +1952,6 @@ test('connect --force reuses the ambient token to release the previous lease whe await withCapturedStdoutAsync(async () => { await connectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: forceConnectFlags({ stateDir, @@ -2042,7 +1997,6 @@ test('disconnect tolerates prior close and removes local connection state', asyn let handled = false; await withCapturedStdoutAsync(async () => { handled = await disconnectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -2091,7 +2045,6 @@ test('disconnect after connect-only cleanup stays local when no session resource await withCapturedStdoutAsync(async () => { await disconnectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: { json: false, @@ -2133,7 +2086,6 @@ test('disconnect without a session uses active connection state', async () => { await withCapturedStdoutAsync(async () => { await disconnectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -2179,7 +2131,6 @@ test('disconnect human output surfaces provider release warnings and artifact li const output = await withCapturedStdoutAsync(async () => { await disconnectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: { json: false, @@ -2258,7 +2209,6 @@ test('disconnect releases proxy lease with provider client and device metadata', await withCapturedStdoutAsync(async () => { await disconnectCommand({ - host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -2291,7 +2241,6 @@ test('connection status reports missing state without daemon calls', async () => let handled = false; await withCapturedStdoutAsync(async () => { handled = await connectionCommand({ - host: createTestHostEnvironment(), positionals: ['status'], flags: { json: true, @@ -2326,7 +2275,6 @@ test('connection status reports active connection state', async () => { const output = await withCapturedStdoutAsync(async () => { await connectionCommand({ - host: createTestHostEnvironment(), positionals: ['status'], flags: { json: true, diff --git a/src/__tests__/test-utils/host-bound-commands.ts b/src/__tests__/test-utils/host-bound-commands.ts new file mode 100644 index 0000000000..0dbdd655c7 --- /dev/null +++ b/src/__tests__/test-utils/host-bound-commands.ts @@ -0,0 +1,17 @@ +import { + connectCommand as connectCommandOnHost, + connectionCommand as connectionCommandOnHost, + disconnectCommand as disconnectCommandOnHost, +} from '../../cli/commands/connection.ts'; +import { materializeRemoteConnectionForCommand as materializeRemoteConnectionForCommandOnHost } from '../../cli/commands/connection-runtime.ts'; +import { tryRunClientBackedCommand as tryRunClientBackedCommandOnHost } from '../../cli/commands/router.ts'; +import { withTestHost } from './with-test-host.ts'; + +/** The CLI commands that take a run's host, bound to a fresh test host. */ +export const connectCommand = withTestHost(connectCommandOnHost); +export const disconnectCommand = withTestHost(disconnectCommandOnHost); +export const connectionCommand = withTestHost(connectionCommandOnHost); +export const materializeRemoteConnectionForCommand = withTestHost( + materializeRemoteConnectionForCommandOnHost, +); +export const tryRunClientBackedCommand = withTestHost(tryRunClientBackedCommandOnHost); diff --git a/src/__tests__/test-utils/with-test-host.ts b/src/__tests__/test-utils/with-test-host.ts new file mode 100644 index 0000000000..d0fe3d0967 --- /dev/null +++ b/src/__tests__/test-utils/with-test-host.ts @@ -0,0 +1,9 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; + +/** Binds a command that takes a `host` to a fresh test host, so a call site names only its inputs. */ +export function withTestHost

( + command: (params: P) => R, +): (params: Omit) => R { + return (params) => command({ host: createTestHostEnvironment(), ...params } as P); +} From 2aa9e5b19ea942b671ce4bd5cee4415a06f4ece1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 14:53:20 +0200 Subject: [PATCH 8/9] docs(changelog): resolveHubAppReference needs cwd for a relative app path Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a9c8b4468..1569ffaeee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Behavior change: `resolveHubAppReference`, shipped through `agent-device/plugins/webdriver`, now needs `cwd` to resolve a relative app path. Without it the call fails with `INVALID_ARGS` and `details.reason: provider_app_cwd_missing`; it used to resolve against the process's working directory. Absolute paths, URLs and provider references are unaffected. The daemon fills it from the client's working directory (or its host's) for every provider lease request, so `connect` and `lease_allocate` are unchanged. - Fix `ensureDaemon()`, the CLI and MCP killing a daemon they had just asked to leave. After the daemon answers the quiesce request with `draining`, the client now waits up to 60 s for it to exit by itself and clean up its registration, so plugin disposal and lease finalization finish (on Windows a signal ends the process outright, and on POSIX a drain longer than 3 s was killed). A daemon still running at the deadline that fails its liveness probes is force-retired; one that still answers is left running and the call fails with `details.reason: 'daemon-drain-timeout'`, with a hint naming `agent-device daemon stop --state-dir `. - `@agent-device/testmu` is now a version 2 provider plugin. `import testmu from '@agent-device/testmu'` gives a factory, `testmu({ credentials: { username, accessKey } })`, that reads those credentials instead of `LT_USERNAME` and `LT_ACCESS_KEY`; without options it reads the variables as before. The package still declares the version 1 plugin entry, so `agent-device plugins add @agent-device/testmu` keeps working on hosts that predate the version 2 contract. - Provider plugins have a new contract. A package exports a factory at `./agent-device` that returns `{ apiVersion: 2, provider, credentials, connection?, setup(host) }`, typed by `AgentDevicePluginFactory` from `agent-device/plugins`. The store calls the factory with the package's `config.json` options, and a JavaScript host can call it directly. Version 1 plugins (`agentDevicePlugin.apiVersion: 1`) keep loading. From e248942bf2738cd46171e41a6e3ecd1b182716a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 15:24:42 +0200 Subject: [PATCH 9/9] test(host): give the new handler suites a host Co-Authored-By: Claude Sonnet 5.5 --- src/cli/commands/__tests__/daemon.test.ts | 1 + .../connection-runtime-plugin-capabilities.test.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/__tests__/daemon.test.ts b/src/cli/commands/__tests__/daemon.test.ts index 14e3f197b2..580714bf22 100644 --- a/src/cli/commands/__tests__/daemon.test.ts +++ b/src/cli/commands/__tests__/daemon.test.ts @@ -270,6 +270,7 @@ test('daemon status reads the daemon record without stopping or contacting the d try { await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['status'], flags: { help: false, json: true, stateDir, version: false }, client: {} as never, diff --git a/src/cli/commands/connection-runtime-plugin-capabilities.test.ts b/src/cli/commands/connection-runtime-plugin-capabilities.test.ts index 9eca35ee0c..74e0092545 100644 --- a/src/cli/commands/connection-runtime-plugin-capabilities.test.ts +++ b/src/cli/commands/connection-runtime-plugin-capabilities.test.ts @@ -1,6 +1,11 @@ import { expect, test, vi } from 'vitest'; import fs from 'node:fs'; -import { pluginHome, selectV2Plugin, v2RegistrationSource } from '@agent-device/test-kit'; +import { + pluginHome, + selectV2Plugin, + v2RegistrationSource, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { connectionWorkspace, createTestClient, @@ -43,6 +48,7 @@ test('a stored profile for a v2 plugin provider needs no remote daemon URL', asy // refused for lacking a daemonBaseUrl. await expect( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', client: createTestClient(), flags: {