diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a9c8b4468..1569ffaeee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Behavior change: `resolveHubAppReference`, shipped through `agent-device/plugins/webdriver`, now needs `cwd` to resolve a relative app path. Without it the call fails with `INVALID_ARGS` and `details.reason: provider_app_cwd_missing`; it used to resolve against the process's working directory. Absolute paths, URLs and provider references are unaffected. The daemon fills it from the client's working directory (or its host's) for every provider lease request, so `connect` and `lease_allocate` are unchanged. - Fix `ensureDaemon()`, the CLI and MCP killing a daemon they had just asked to leave. After the daemon answers the quiesce request with `draining`, the client now waits up to 60 s for it to exit by itself and clean up its registration, so plugin disposal and lease finalization finish (on Windows a signal ends the process outright, and on POSIX a drain longer than 3 s was killed). A daemon still running at the deadline that fails its liveness probes is force-retired; one that still answers is left running and the call fails with `details.reason: 'daemon-drain-timeout'`, with a hint naming `agent-device daemon stop --state-dir `. - `@agent-device/testmu` is now a version 2 provider plugin. `import testmu from '@agent-device/testmu'` gives a factory, `testmu({ credentials: { username, accessKey } })`, that reads those credentials instead of `LT_USERNAME` and `LT_ACCESS_KEY`; without options it reads the variables as before. The package still declares the version 1 plugin entry, so `agent-device plugins add @agent-device/testmu` keeps working on hosts that predate the version 2 contract. - Provider plugins have a new contract. A package exports a factory at `./agent-device` that returns `{ apiVersion: 2, provider, credentials, connection?, setup(host) }`, typed by `AgentDevicePluginFactory` from `agent-device/plugins`. The store calls the factory with the package's `config.json` options, and a JavaScript host can call it directly. Version 1 plugins (`agentDevicePlugin.apiVersion: 1`) keep loading. diff --git a/packages/provider-webdriver/src/webdriver-utils.test.ts b/packages/provider-webdriver/src/webdriver-utils.test.ts index f9a31231bb..9073922b1c 100644 --- a/packages/provider-webdriver/src/webdriver-utils.test.ts +++ b/packages/provider-webdriver/src/webdriver-utils.test.ts @@ -141,6 +141,27 @@ test('the hub app resolver passes references through, uploads local files, and p } }); +test('the hub app resolver refuses a relative app path when the request names no working directory', async () => { + const uploadFile = vi.fn(async () => 'hub://never'); + + await assert.rejects( + resolveHubAppReference({ + service: 'Hub', + app: 'App.apk', + referenceLabel: 'a hub:// app id', + ...hubReferenceGrammar, + uploadFile, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.equal(error.details?.reason, 'provider_app_cwd_missing'); + return true; + }, + ); + assert.equal(uploadFile.mock.calls.length, 0); +}); + test('appFileUploadForm carries a regular app file and refuses anything else typed', async () => { const tempDir = await mkdtempForTest('agent-device-upload-form-'); try { diff --git a/packages/provider-webdriver/src/webdriver-utils.ts b/packages/provider-webdriver/src/webdriver-utils.ts index b2babf7728..d10669977c 100644 --- a/packages/provider-webdriver/src/webdriver-utils.ts +++ b/packages/provider-webdriver/src/webdriver-utils.ts @@ -165,7 +165,7 @@ export async function resolveHubAppReference(options: { const reference = options.parseReference(app); if (reference !== undefined) return reference; if (/^https?:\/\//i.test(app)) return app; - const appPath = path.resolve(options.cwd ?? process.cwd(), app); + const appPath = path.isAbsolute(app) ? app : path.resolve(requireAppCwd(options), app); if (!fs.existsSync(appPath)) { throw new AppError( 'INVALID_ARGS', @@ -176,6 +176,17 @@ export async function resolveHubAppReference(options: { return await options.uploadFile(appPath, options.signal); } +// A relative app path needs the working directory of the client that named it; the daemon fills it +// from the request, and no ambient directory stands in for a request that names none. +function requireAppCwd(options: { service: string; app: string; cwd?: string }): string { + if (options.cwd !== undefined) return options.cwd; + throw new AppError( + 'INVALID_ARGS', + `${options.service} --provider-app ${options.app} is a relative path and the request names no working directory; pass an absolute path.`, + { providerApp: options.app, reason: 'provider_app_cwd_missing' }, + ); +} + const PROVIDER_API_TIMEOUT_MS = 15_000; /** Service name and remediation hints a verification failure carries. */ diff --git a/packages/remote/src/__tests__/daemon-artifacts-save-script.test.ts b/packages/remote/src/__tests__/daemon-artifacts-save-script.test.ts index 137217d2ba..5cb0b64a60 100644 --- a/packages/remote/src/__tests__/daemon-artifacts-save-script.test.ts +++ b/packages/remote/src/__tests__/daemon-artifacts-save-script.test.ts @@ -7,6 +7,7 @@ * remote request that would produce a file the caller can never see is refused here instead of * appearing to succeed. */ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; @@ -28,7 +29,13 @@ function replayRequest(saveScript?: boolean | string) { test('a remote daemon refuses --save-script, naming the caller/daemon split', async () => { await assert.rejects( - async () => await prepareRemoteRequestArtifacts(replayRequest(true), REMOTE, NO_CANCELLATION), + async () => + await prepareRemoteRequestArtifacts( + replayRequest(true), + REMOTE, + NO_CANCELLATION, + createTestHostEnvironment(), + ), (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS' && @@ -43,20 +50,31 @@ test('a remote daemon refuses an explicit --save-script output path too', async replayRequest('./flows/login.healed.ad'), REMOTE, NO_CANCELLATION, + createTestHostEnvironment(), ), (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS', ); }); test('a local daemon leaves --save-script untouched', async () => { - const prepared = await prepareRemoteRequestArtifacts(replayRequest(true), LOCAL, NO_CANCELLATION); + const prepared = await prepareRemoteRequestArtifacts( + replayRequest(true), + LOCAL, + NO_CANCELLATION, + createTestHostEnvironment(), + ); assert.equal(prepared.flags?.saveScript, true); assert.deepEqual(prepared.positionals, ['./flows/login.ad']); }); test('a remote replay without --save-script passes through unchanged', async () => { - const prepared = await prepareRemoteRequestArtifacts(replayRequest(), REMOTE, NO_CANCELLATION); + const prepared = await prepareRemoteRequestArtifacts( + replayRequest(), + REMOTE, + NO_CANCELLATION, + createTestHostEnvironment(), + ); assert.deepEqual(prepared.positionals, ['./flows/login.ad']); assert.equal(prepared.uploadedArtifactId, undefined); diff --git a/packages/remote/src/__tests__/daemon-artifacts-test-command.test.ts b/packages/remote/src/__tests__/daemon-artifacts-test-command.test.ts index 898ecc3543..a672274891 100644 --- a/packages/remote/src/__tests__/daemon-artifacts-test-command.test.ts +++ b/packages/remote/src/__tests__/daemon-artifacts-test-command.test.ts @@ -5,6 +5,7 @@ * command: the client resolves the real local artifacts root itself and redirects the daemon to * a temp path it owns, exactly as it already does for `screenshot`/`record start`. */ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import path from 'node:path'; import { test } from 'vitest'; @@ -29,6 +30,7 @@ test('a remote daemon redirects an explicit --artifacts-dir to a temp path it ow testRequest('remote-device-artifacts/ad-test'), REMOTE, NO_CANCELLATION, + createTestHostEnvironment(), ); const redirected = (prepared.flags as Record | undefined)?.artifactsDir; @@ -45,6 +47,7 @@ test('a remote daemon redirects the default artifacts directory too', async () = testRequest(undefined), REMOTE, NO_CANCELLATION, + createTestHostEnvironment(), ); const redirected = (prepared.flags as Record | undefined)?.artifactsDir; @@ -61,6 +64,7 @@ test('a remote daemon leaves an already-absolute --artifacts-dir as the download testRequest('/ci/artifacts/ad-test'), REMOTE, NO_CANCELLATION, + createTestHostEnvironment(), ); const redirected = (prepared.flags as Record | undefined)?.artifactsDir; @@ -73,6 +77,7 @@ test('a local daemon leaves --artifacts-dir untouched', async () => { testRequest('remote-device-artifacts/ad-test'), LOCAL, NO_CANCELLATION, + createTestHostEnvironment(), ); assert.equal( diff --git a/packages/remote/src/__tests__/upload-client-cancellation.test.ts b/packages/remote/src/__tests__/upload-client-cancellation.test.ts index 4c961d8b64..a2949e1ffb 100644 --- a/packages/remote/src/__tests__/upload-client-cancellation.test.ts +++ b/packages/remote/src/__tests__/upload-client-cancellation.test.ts @@ -62,6 +62,7 @@ test('an aborted signal ends a legacy upload mid-stream instead of finishing the await assert.rejects( async () => await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -92,6 +93,7 @@ test('an aborted signal before preflight refuses to ask the daemon for a ticket' await assert.rejects( async () => await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, @@ -129,6 +131,7 @@ test('an upload with no signal behaves exactly as before', async () => { try { const uploadId = await uploadArtifact({ + env: process.env, localPath: artifactPath, baseUrl: server.baseUrl, token: TEST_TOKEN, diff --git a/packages/remote/src/__tests__/upload-client.test.ts b/packages/remote/src/__tests__/upload-client.test.ts index 4d782b0739..0b57ce1fdb 100644 --- a/packages/remote/src/__tests__/upload-client.test.ts +++ b/packages/remote/src/__tests__/upload-client.test.ts @@ -4,7 +4,10 @@ import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs'; import http, { type IncomingMessage, type ServerResponse } from 'node:http'; import path from 'node:path'; -import { uploadArtifact } from '../upload-client.ts'; +import { uploadArtifact as uploadArtifactOnHost } from '../upload-client.ts'; + +const uploadArtifact = (options: Omit[0], 'env'>) => + uploadArtifactOnHost({ ...options, env: process.env }); import type { UploadProgressEvent } from '../upload-progress.ts'; import { runCmdSync, withCommandExecutorOverride } from '@agent-device/host-kit/command'; import { closeLoopbackServer, listenOnLoopback, mkdtempForTestSync } from '@agent-device/test-kit'; @@ -917,7 +920,12 @@ test.each([ const localPath = createTempFile('app.apk', `${flow}-payload`); for (const leaseId of ['lease-1', undefined]) { leases = new Map(); - await uploadArtifact({ localPath, baseUrl: server.baseUrl, token: TEST_TOKEN, leaseId }); + await uploadArtifact({ + localPath, + baseUrl: server.baseUrl, + token: TEST_TOKEN, + leaseId, + }); const expected = routes.map((route) => [route, route === unleased ? undefined : leaseId]); assert.deepEqual([...leases], expected); } diff --git a/packages/remote/src/__tests__/upload-progress.test.ts b/packages/remote/src/__tests__/upload-progress.test.ts index 91423c4342..adb6e7c7c5 100644 --- a/packages/remote/src/__tests__/upload-progress.test.ts +++ b/packages/remote/src/__tests__/upload-progress.test.ts @@ -15,7 +15,7 @@ function renderEvents(events: UploadProgressEvent[]): string { const isTTYDescriptor = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY'); Object.defineProperty(process.stderr, 'isTTY', { configurable: true, value: true }); try { - const sink = createStderrUploadProgressReporter(); + const sink = createStderrUploadProgressReporter(process.env); assert.equal(sink !== undefined, true); for (const event of events) sink!(event); } finally { diff --git a/packages/remote/src/daemon-artifacts.ts b/packages/remote/src/daemon-artifacts.ts index f0537ec3ed..5c80b18365 100644 --- a/packages/remote/src/daemon-artifacts.ts +++ b/packages/remote/src/daemon-artifacts.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; import type { DaemonArtifact, DaemonResponse } from '@agent-device/kernel/contracts'; import fs from 'node:fs'; @@ -38,13 +39,14 @@ export async function prepareRemoteRequestArtifacts( req: Omit, info: DaemonArtifactEndpoint, signal: AbortSignal, + host: HostEnvironment, ): Promise { const positionals = [...(req.positionals ?? [])]; let flags = req.flags ? { ...req.flags } : undefined; let installSource = req.meta?.installSource; const clientArtifactPaths: Record = {}; let uploadedArtifactId: string | undefined; - const uploadProgress = createStderrUploadProgressReporter(); + const uploadProgress = createStderrUploadProgressReporter(host.env); if (!isRemoteDaemon(info)) { return createPreparedRemoteRequest({ @@ -57,8 +59,14 @@ export async function prepareRemoteRequestArtifacts( } assertRemoteDaemonSupportsSaveScript(req); - flags = applyRemoteArtifactCommand(req, positionals, flags, clientArtifactPaths); - const remoteInstallSource = await prepareRemoteInstallSource(req, info, uploadProgress, signal); + flags = applyRemoteArtifactCommand(req, positionals, flags, clientArtifactPaths, host); + const remoteInstallSource = await prepareRemoteInstallSource( + req, + info, + uploadProgress, + signal, + host, + ); if (remoteInstallSource) { installSource = remoteInstallSource.installSource; uploadedArtifactId = remoteInstallSource.uploadedArtifactId ?? uploadedArtifactId; @@ -80,6 +88,7 @@ export async function prepareRemoteRequestArtifacts( positionals, uploadProgress, signal, + host, ); uploadedArtifactId = installPackageResult ?? uploadedArtifactId; return baseResult(); @@ -112,6 +121,7 @@ async function prepareRemoteInstallPackage( positionals: string[], onProgress: UploadProgressSink | undefined, signal: AbortSignal, + host: HostEnvironment, ): Promise { const pathIndex = positionals.length === 1 ? 0 : 1; const rawPath = positionals[pathIndex]; @@ -121,10 +131,11 @@ async function prepareRemoteInstallPackage( return undefined; } - const localPath = resolveLocalInstallPath(rawPath, req.meta?.cwd); + const localPath = resolveLocalInstallPath(rawPath, req.meta?.cwd, host); if (!localPath) return undefined; return await uploadArtifact({ + env: host.env, localPath, baseUrl: info.baseUrl!, token: info.token, @@ -140,8 +151,9 @@ function applyRemoteArtifactCommand( positionals: string[], flags: DaemonWireRequest['flags'] | undefined, clientArtifactPaths: Record, + host: HostEnvironment, ): DaemonWireRequest['flags'] | undefined { - const remoteArtifact = prepareRemoteArtifactCommand(req, positionals); + const remoteArtifact = prepareRemoteArtifactCommand(req, positionals, host); if (!remoteArtifact) return flags; if (remoteArtifact.positionalIndex !== undefined && remoteArtifact.positionalPath !== undefined) { positionals[remoteArtifact.positionalIndex] = remoteArtifact.positionalPath; @@ -164,10 +176,12 @@ function applyRemoteArtifactFlag( return { ...(flags ?? {}), [flagKey]: flagPath }; } -function resolveLocalInstallPath(rawPath: string, cwd: string | undefined): string | undefined { - const localPath = path.isAbsolute(rawPath) - ? rawPath - : path.resolve(cwd ?? process.cwd(), rawPath); +function resolveLocalInstallPath( + rawPath: string, + cwd: string | undefined, + host: HostEnvironment, +): string | undefined { + const localPath = path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd ?? host.cwd, rawPath); return fs.existsSync(localPath) ? localPath : undefined; } @@ -190,6 +204,7 @@ async function prepareRemoteInstallSource( info: DaemonArtifactEndpoint, onProgress: UploadProgressSink | undefined, signal: AbortSignal, + host: HostEnvironment, ): Promise<{ installSource: NonNullable['installSource']; uploadedArtifactId?: string; @@ -214,7 +229,7 @@ async function prepareRemoteInstallSource( const localPath = path.isAbsolute(rawPath) ? rawPath - : path.resolve(req.meta?.cwd ?? process.cwd(), rawPath); + : path.resolve(req.meta?.cwd ?? host.cwd, rawPath); if (!fs.existsSync(localPath)) { return { installSource: { @@ -225,6 +240,7 @@ async function prepareRemoteInstallSource( } const uploadedArtifactId = await uploadArtifact({ + env: host.env, localPath, baseUrl: info.baseUrl!, token: info.token, @@ -245,6 +261,7 @@ async function prepareRemoteInstallSource( function prepareRemoteArtifactCommand( req: Omit, positionals: string[], + host: HostEnvironment, ): { field: string; localPath: string; @@ -254,7 +271,7 @@ function prepareRemoteArtifactCommand( flagPath?: string; } | null { if (req.command === 'screenshot') { - const localPath = resolveClientArtifactOutputPath(req, 'path', '.png'); + const localPath = resolveClientArtifactOutputPath(req, 'path', '.png', host); if (positionals[0]) { return { field: 'path', @@ -276,12 +293,12 @@ function prepareRemoteArtifactCommand( // this destination and records to a temp path on its own host. return { field: 'outPath', - localPath: resolveAbsoluteClientPath(`recording-${Date.now()}`, req.meta?.cwd), + localPath: resolveAbsoluteClientPath(`recording-${Date.now()}`, req.meta?.cwd, host), }; } const fallbackExtension = recordingFallbackExtension(req); const localPath = normalizeRecordingClientArtifactPath( - resolveClientArtifactOutputPath(req, 'outPath', fallbackExtension, 1), + resolveClientArtifactOutputPath(req, 'outPath', fallbackExtension, host, 1), req, ); return { @@ -301,7 +318,7 @@ function prepareRemoteArtifactCommand( // the same command: the daemon must never resolve a path against the caller's `cwd`. return { field: 'artifactsDir', - localPath: resolveClientArtifactOutputRoot(req), + localPath: resolveClientArtifactOutputRoot(req, host), flagKey: 'artifactsDir', flagPath: buildRemoteTempArtifactDirPath('test-artifacts'), }; @@ -309,10 +326,13 @@ function prepareRemoteArtifactCommand( return null; } -function resolveClientArtifactOutputRoot(req: Omit): string { +function resolveClientArtifactOutputRoot( + req: Omit, + host: HostEnvironment, +): string { const requested = req.flags?.artifactsDir; const rawPath = hasNonEmptyString(requested) ? requested : DEFAULT_TEST_ARTIFACTS_ROOT; - return resolveAbsoluteClientPath(rawPath, req.meta?.cwd); + return resolveAbsoluteClientPath(rawPath, req.meta?.cwd, host); } function recordingFallbackExtension(req: Omit): string { @@ -335,6 +355,7 @@ function resolveClientArtifactOutputPath( req: Omit, field: 'path' | 'outPath', fallbackExtension: string, + host: HostEnvironment, positionalIndex: number = 0, ): string { const requested = req.positionals?.[positionalIndex] ?? req.flags?.out; @@ -342,12 +363,17 @@ function resolveClientArtifactOutputPath( return resolveAbsoluteClientPath( hasNonEmptyString(requested) ? requested : fallbackName, req.meta?.cwd, + host, ); } /** Shared tail of every "what local path did the caller mean" resolution in this file. */ -function resolveAbsoluteClientPath(rawPath: string, cwd: string | undefined): string { - return path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd ?? process.cwd(), rawPath); +function resolveAbsoluteClientPath( + rawPath: string, + cwd: string | undefined, + host: HostEnvironment, +): string { + return path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd ?? host.cwd, rawPath); } function hasNonEmptyString(value: unknown): value is string { @@ -358,6 +384,7 @@ export async function materializeRemoteArtifacts( info: DaemonArtifactEndpoint, req: DaemonWireRequest, response: Extract, + host: HostEnvironment, ): Promise { const artifacts = Array.isArray(response.data?.artifacts) ? response.data.artifacts : []; if (artifacts.length === 0 || !info.baseUrl) return response; @@ -368,7 +395,7 @@ export async function materializeRemoteArtifacts( nextArtifacts.push(artifact); continue; } - const downloadPath = resolveMaterializedArtifactPath(artifact, req); + const downloadPath = resolveMaterializedArtifactPath(artifact, req, host); const materializedPath = await downloadRemoteArtifact({ baseUrl: info.baseUrl, token: info.token, @@ -394,7 +421,11 @@ export async function materializeRemoteArtifacts( return { ok: true, data: nextData }; } -function resolveMaterializedArtifactPath(artifact: DaemonArtifact, req: DaemonWireRequest): string { +function resolveMaterializedArtifactPath( + artifact: DaemonArtifact, + req: DaemonWireRequest, + host: HostEnvironment, +): string { if (artifact.localPath && artifact.localPath.trim().length > 0) { return artifact.localPath; } @@ -403,7 +434,7 @@ function resolveMaterializedArtifactPath(artifact: DaemonArtifact, req: DaemonWi return requestedPath; } const fallbackName = artifact.fileName?.trim() || `${artifact.field}-${Date.now()}`; - return path.resolve(req.meta?.cwd ?? process.cwd(), fallbackName); + return path.resolve(req.meta?.cwd ?? host.cwd, fallbackName); } type DownloadRemoteArtifactParams = { diff --git a/packages/remote/src/remote-config-core.ts b/packages/remote/src/remote-config-core.ts index 063e8527eb..f5913ebb6c 100644 --- a/packages/remote/src/remote-config-core.ts +++ b/packages/remote/src/remote-config-core.ts @@ -5,12 +5,18 @@ import { getRemoteConfigEnvNames, getRemoteConfigFieldSpec, type RemoteConfigProfile, - type RemoteConfigProfileOptions, type ResolvedRemoteConfigProfile, } from './remote-config-schema.ts'; import { AppError } from '@agent-device/kernel/errors'; import { resolveUserPath } from '@agent-device/host-kit/file'; -import { parseSourceValue } from '@agent-device/kernel/source-value'; +import { parseSourceValue, type EnvMap } from '@agent-device/kernel/source-value'; + +/** The remote-config location and the host environment it resolves against. */ +export type HostRemoteConfigProfileOptions = { + configPath: string; + cwd: string; + env: EnvMap; +}; // Deliberately narrower than `resolveRemoteConfigProfile`: this reads only // what the config *file itself* declares, with no ambient environment @@ -19,9 +25,9 @@ import { parseSourceValue } from '@agent-device/kernel/source-value'; // somewhere") must use this, not the env-merged resolver, or provenance is // lost — an env var is global and cannot say which endpoint it belongs to. export function readRemoteConfigFile( - options: RemoteConfigProfileOptions, + options: HostRemoteConfigProfileOptions, ): ResolvedRemoteConfigProfile { - const env = options.env ?? process.env; + const { env } = options; const resolvedPath = resolveRemoteConfigPath(options); if (!fs.existsSync(resolvedPath)) { throw new AppError('INVALID_ARGS', `Remote config file not found: ${resolvedPath}`); @@ -78,9 +84,7 @@ export function readRemoteConfigFile( return { resolvedPath, profile }; } -function readRemoteConfigEnvDefaults( - env: Record = process.env, -): RemoteConfigProfile { +function readRemoteConfigEnvDefaults(env: EnvMap): RemoteConfigProfile { const profile: RemoteConfigProfile = {}; for (const spec of REMOTE_CONFIG_PROFILE_FIELD_SPECS) { const envMatch = getRemoteConfigEnvNames(spec.key) @@ -113,13 +117,13 @@ function mergeRemoteConfigProfile( return merged; } -export function resolveRemoteConfigPath(options: RemoteConfigProfileOptions): string { - const env = options.env ?? process.env; +export function resolveRemoteConfigPath(options: HostRemoteConfigProfileOptions): string { + const { env } = options; return resolveUserPath(options.configPath, { cwd: options.cwd, env }); } export function resolveRemoteConfigProfile( - options: RemoteConfigProfileOptions, + options: HostRemoteConfigProfileOptions, ): ResolvedRemoteConfigProfile { const loaded = readRemoteConfigFile(options); return { diff --git a/packages/remote/src/upload-client-artifact.ts b/packages/remote/src/upload-client-artifact.ts index 2cd6296cbd..d4de4a4f9c 100644 --- a/packages/remote/src/upload-client-artifact.ts +++ b/packages/remote/src/upload-client-artifact.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; @@ -24,6 +25,7 @@ export type PreparedUploadArtifact = { export async function prepareUploadArtifact( localPath: string, requestedPlatform: string | undefined, + env: Readonly, ): Promise { const stat = fs.statSync(localPath); const fileName = path.basename(localPath); @@ -33,7 +35,7 @@ export async function prepareUploadArtifact( const cleanupPaths: string[] = []; try { const payloadPath = isDirectory - ? await createGzipTarArchive(localPath, cleanupPaths) + ? await createGzipTarArchive(localPath, cleanupPaths, env) : localPath; const payloadStat = fs.statSync(payloadPath); @@ -53,7 +55,11 @@ export async function prepareUploadArtifact( } } -async function createGzipTarArchive(localPath: string, cleanupPaths: string[]): Promise { +async function createGzipTarArchive( + localPath: string, + cleanupPaths: string[], + env: Readonly, +): Promise { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `agent-device-upload-${randomUUID()}-`)); cleanupPaths.push(tempDir); const archivePath = path.join(tempDir, `${path.basename(localPath)}.tar.gz`); @@ -62,7 +68,7 @@ async function createGzipTarArchive(localPath: string, cleanupPaths: string[]): ['czf', archivePath, '-C', path.dirname(localPath), path.basename(localPath)], { env: { - ...process.env, + ...env, COPYFILE_DISABLE: '1', }, }, diff --git a/packages/remote/src/upload-client.ts b/packages/remote/src/upload-client.ts index d503c4aa07..bc6526c704 100644 --- a/packages/remote/src/upload-client.ts +++ b/packages/remote/src/upload-client.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import { randomUUID } from 'node:crypto'; import { AppError } from '@agent-device/kernel/errors'; import { @@ -16,6 +17,8 @@ type UploadArtifactOptions = { baseUrl: string; token: string; platform?: string; + /** The environment a packaging command (`tar`) starts from. */ + env: Readonly; /** The lease this upload is for; every request to the daemon names it in a header. */ leaseId?: string; onProgress?: UploadProgressSink; @@ -54,7 +57,7 @@ type UploadPreflightResult = }; export async function uploadArtifact(options: UploadArtifactOptions): Promise { - const prepared = await prepareUploadArtifact(options.localPath, options.platform); + const prepared = await prepareUploadArtifact(options.localPath, options.platform, options.env); const normalizedBase = options.baseUrl.endsWith('/') ? options.baseUrl : `${options.baseUrl}/`; const uploadAttemptId = randomUUID(); diff --git a/packages/remote/src/upload-progress.ts b/packages/remote/src/upload-progress.ts index bc5560a9d1..8f537d5263 100644 --- a/packages/remote/src/upload-progress.ts +++ b/packages/remote/src/upload-progress.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import { Transform } from 'node:stream'; const DEFAULT_UPLOAD_PROGRESS_STEP_BYTES = 8 * 1024 * 1024; @@ -58,8 +59,10 @@ export function createUploadProgressTransform(options: { }); } -export function createStderrUploadProgressReporter(): UploadProgressSink | undefined { - if (process.stderr.isTTY !== true || process.env.CI) return undefined; +export function createStderrUploadProgressReporter( + env: Readonly, +): UploadProgressSink | undefined { + if (process.stderr.isTTY !== true || env.CI) return undefined; return (event) => { process.stderr.write(`${formatUploadProgressEvent(event)}\n`); }; diff --git a/src/__tests__/cli-agent-cdp.test.ts b/src/__tests__/cli-agent-cdp.test.ts index d9a5f6d952..b7c320a5c6 100644 --- a/src/__tests__/cli-agent-cdp.test.ts +++ b/src/__tests__/cli-agent-cdp.test.ts @@ -159,6 +159,8 @@ test('cdp passes injected remote target url to npm exec', async () => { }); const exitCode = await runAgentCdpCommand(['target', 'list'], { + cwd: process.cwd(), + env: process.env, flags: { leaseBackend: 'ios-instance', metroProxyBaseUrl: 'https://bridge.example.test', diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index f65ae9f46c..4192c655c2 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import { PNG } from '@agent-device/capture-kit/png'; -import { tryRunClientBackedCommand } from '../cli/commands/router.ts'; +import { tryRunClientBackedCommand } from './test-utils/host-bound-commands.ts'; import { runCliCapture } from './cli-capture.ts'; import type { AgentDeviceClient, diff --git a/src/__tests__/cli-install-from-source.test.ts b/src/__tests__/cli-install-from-source.test.ts index bc103e7649..f592030db8 100644 --- a/src/__tests__/cli-install-from-source.test.ts +++ b/src/__tests__/cli-install-from-source.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; @@ -15,6 +16,7 @@ async function runInstallFromSource(source: string) { }, } as unknown as AgentDeviceClient; return await tryRunClientBackedCommand({ + host: createTestHostEnvironment(), command: 'install-from-source', positionals: [source], flags: { json: false, help: false, version: false }, diff --git a/src/__tests__/cloud-connect-auth.test.ts b/src/__tests__/cloud-connect-auth.test.ts index 598e3fbf2b..c5472fd85e 100644 --- a/src/__tests__/cloud-connect-auth.test.ts +++ b/src/__tests__/cloud-connect-auth.test.ts @@ -7,7 +7,11 @@ import { beginStdoutCapture } from './test-utils/stream-capture.ts'; import { resolveCloudAccessForConnect } from '../cli/auth-session.ts'; import { readActiveConnectionState } from '@agent-device/remote/remote-connection-state'; import type { AgentDeviceClient } from '../agent-device-client.ts'; -import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; bindTestCommandHost(); @@ -54,7 +58,6 @@ test('cloud access with no-login reports auth requirement after reuse options ar }, env: {}, io: { - env: {}, fetch: vi.fn(), }, }), @@ -87,6 +90,7 @@ async function connectWithNoLogin(stateDir: string): Promise { const stdoutCapture = beginStdoutCapture(); try { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index a10719697c..e10b3312f9 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -1,4 +1,3 @@ -import { withCommandHost } from '@agent-device/host-kit/command'; import { afterEach, beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -21,7 +20,11 @@ import { verifyAwsDeviceFarmConnection, verifyBrowserStackConnection, } from '@agent-device/provider-webdriver/connection-verification'; -import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { connectWithGeneratedProviderProfile } from './test-utils/connect-command.ts'; bindTestCommandHost(); @@ -164,6 +167,7 @@ test('connect limrun generates a local daemon remote profile', async () => { try { await captureConnectStdout(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: ['limrun'], flags: { json: true, @@ -217,6 +221,7 @@ test('connect limrun attaches to an existing instance without storing its token' await captureConnectStdout(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: ['limrun'], flags: { json: true, help: false, version: false, stateDir, platform: 'ios' }, client: {} as AgentDeviceClient, @@ -237,6 +242,7 @@ test('connect limrun persists deferred Metro bridge settings', async () => { try { await captureConnectStdout(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: ['limrun'], flags: { json: true, @@ -295,6 +301,7 @@ test('connect limrun requires LIMRUN_API_KEY or access to an existing instance', try { await assert.rejects( connectCommand({ + host: createTestHostEnvironment(), positionals: ['limrun'], flags: { json: true, @@ -554,20 +561,17 @@ test('connect browserstack persists a local app artifact as an absolute path', a try { // The host the command runs in decides what `./sample.apk` is relative to. - await withCommandHost( - { host: { env: process.env, cwd: fs.realpathSync(tempRoot) }, requestEnv: undefined }, - async () => - await connectWithGeneratedProviderProfile({ - stateDir, - positionals: ['browserstack'], - flags: { - platform: 'android', - device: 'Google Pixel 8', - providerOsVersion: '14.0', - providerApp: './sample.apk', - }, - }), - ); + await connectWithGeneratedProviderProfile({ + stateDir, + host: { env: process.env, cwd: fs.realpathSync(tempRoot) }, + positionals: ['browserstack'], + flags: { + platform: 'android', + device: 'Google Pixel 8', + providerOsVersion: '14.0', + providerApp: './sample.apk', + }, + }); const state = readRequiredActiveState(stateDir); assert.equal(readGeneratedConfig(state.remoteConfigPath).providerApp, fs.realpathSync(appPath)); @@ -795,6 +799,7 @@ test('connect does not activate provider state when verification fails', async ( try { await assert.rejects( connectCommand({ + host: createTestHostEnvironment(), positionals: ['limrun'], flags: { json: true, @@ -903,6 +908,7 @@ function fetchProfileUrl(fetchMock: ReturnType): string | undefine async function connectWithGeneratedCloudProfile(stateDir: string): Promise { await captureConnectStdout(async () => { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, diff --git a/src/__tests__/daemon-composition/platform-services-fixture.ts b/src/__tests__/daemon-composition/platform-services-fixture.ts index f88ebf7653..7a3a4fc51d 100644 --- a/src/__tests__/daemon-composition/platform-services-fixture.ts +++ b/src/__tests__/daemon-composition/platform-services-fixture.ts @@ -1,6 +1,6 @@ import { type ClaimRecoveryGatewayFactory } from '@agent-device/daemon-contracts/device-claim-recovery'; import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts'; -import { createClaimRecoveryGateway } from '../../platform-runtime-claim-recovery.ts'; +import { createClaimRecoveryGatewayFor } from '../../platform-runtime-claim-recovery.ts'; import type { DaemonPlatformServices } from '@agent-device/daemon'; /** @@ -22,5 +22,5 @@ export function composedPlatformServicesFixture( * passes its own factory. */ export function composedClaimRecoveryGatewayFixture(): ClaimRecoveryGatewayFactory { - return createClaimRecoveryGateway; + return createClaimRecoveryGatewayFor(process.env); } diff --git a/src/__tests__/remote-connection-harmonyos.test.ts b/src/__tests__/remote-connection-harmonyos.test.ts index bc301bb5b2..6e2f955a71 100644 --- a/src/__tests__/remote-connection-harmonyos.test.ts +++ b/src/__tests__/remote-connection-harmonyos.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { expect, test } from 'vitest'; import fs from 'node:fs'; import { @@ -45,6 +45,7 @@ test('stored Harmony runtime compatibility keeps the HarmonyOS runtime for a Har }, }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', client: createTestClient(), flags: { @@ -73,6 +74,7 @@ async function expectRejectedSelection( ): Promise { await expect( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', client: createTestClient(), flags: { @@ -176,6 +178,7 @@ test('proxy HarmonyOS inventory materializes a scoped lease and closes that same session: 'harmony-proxy', }; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags, client, @@ -189,7 +192,7 @@ test('proxy HarmonyOS inventory materializes a scoped lease and closes that same deviceKey: 'harmonyos:mobile:127.0.0.1:16001', }); expect(readRemoteConnectionState({ stateDir, session: flags.session })?.leaseId).toBe(leaseId); - await disconnectCommand({ positionals: [], flags, client }); + await disconnectCommand({ host: createTestHostEnvironment(), positionals: [], flags, client }); expect(released).toEqual([leaseId]); expect(readRemoteConnectionState({ stateDir, session: flags.session })).toBeNull(); }); diff --git a/src/__tests__/remote-connection-macos-app.test.ts b/src/__tests__/remote-connection-macos-app.test.ts index 9f0e5e5bb6..ecf9582a97 100644 --- a/src/__tests__/remote-connection-macos-app.test.ts +++ b/src/__tests__/remote-connection-macos-app.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { expect, test } from 'vitest'; import fs from 'node:fs'; import { AppError } from '@agent-device/kernel/errors'; @@ -68,6 +68,7 @@ test('a macos-app remote config opens on the lease the host allocated, unchanged }); const flags = flagsFor(stateDir, remoteConfigPath); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', positionals: ['com.example.app'], flags, @@ -110,6 +111,7 @@ test('an inactive host lease is reported, never replaced by a tenant allocation' }); await expect( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: flagsFor(stateDir, remoteConfigPath), client, @@ -126,6 +128,7 @@ async function connectHostedMacosApp( const stdoutCapture = beginStdoutCapture(); try { await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { ...flagsFor(workspace.stateDir, workspace.remoteConfigPath), @@ -165,6 +168,7 @@ test('connect binds the lease and device a macos-app remote config names, and op }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', positionals: ['com.example.app'], flags: { ...flagsFor(workspace.stateDir, workspace.remoteConfigPath), platform: 'macos' }, @@ -245,6 +249,7 @@ test('disconnect drops the local connection and never releases the host lease', const stdoutCapture = beginStdoutCapture(); try { await disconnectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: flagsFor(workspace.stateDir, workspace.remoteConfigPath), client: createTestClient({ diff --git a/src/__tests__/remote-connection-platform-axis.test.ts b/src/__tests__/remote-connection-platform-axis.test.ts index 66b832e281..92793830ce 100644 --- a/src/__tests__/remote-connection-platform-axis.test.ts +++ b/src/__tests__/remote-connection-platform-axis.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; // The platform axis a remote connection is named on (#2962). // // A `DeviceInfo` carries the INTERNAL `apple` platform while everything a connection records or @@ -54,6 +54,7 @@ test('proxy install against an iOS-bound connection is not refused as a platform const allocate = recordedLeaseAllocate({ leaseId: 'ios-lease-1', backend: 'ios-instance' }); const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { json: true, @@ -114,6 +115,7 @@ test('proxy install with the apple family selector matches an ios-bound connecti const allocate = recordedLeaseAllocate({ leaseId: 'ios-lease-1', backend: 'ios-instance' }); await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { json: true, @@ -173,6 +175,7 @@ test('remote command with the apple family selector matches an ios-bound connect const heartbeats: string[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: { json: true, @@ -228,6 +231,7 @@ test('proxy install against a differently-bound platform is still refused', asyn await assert.rejects( async () => await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { json: true, @@ -291,6 +295,7 @@ test('proxy install records the public platform and the next command reuses that }); const install = () => materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { json: true, @@ -376,6 +381,7 @@ test('a connection opened on the apple family collapses to the bound device leaf }); const install = (platform: 'apple' | 'ios' | 'macos') => materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { json: true, @@ -438,6 +444,7 @@ test('a default-policy connection collapses its recorded apple alias when the le const allocate = recordedLeaseAllocate({ leaseId: 'default-lease-1', backend: 'ios-instance' }); const command = (platform: 'apple' | 'ios' | 'macos') => materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: { json: true, @@ -501,6 +508,7 @@ test('a stored apple record refuses the other leaf even though nothing rewrote i const heartbeats: string[] = []; const command = (platform: 'macos') => materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: { json: true, @@ -562,6 +570,7 @@ test('connect refuses to reuse an apple-bound connection for the other leaf', as }); const connect = (platform: 'apple' | 'ios' | 'macos') => connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -625,6 +634,7 @@ test('connect keeps the bound leaf when re-declared with the family selector', a }); await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, @@ -674,6 +684,7 @@ test('an undecided record narrows to the requested leaf or refuses, and never se }); allocations.push(allocation); return materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', flags: { json: true, @@ -758,6 +769,7 @@ test('connect --force refuses a backend and platform that name different devices await assert.rejects( async () => await connectCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, diff --git a/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts b/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts index f18f74fa4b..f5cc8f1cf9 100644 --- a/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts +++ b/src/__tests__/remote-connection-proxy-allocate-by-spec.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -99,6 +99,7 @@ test('proxy open without an exact device allocates by its selectors and keeps th const requests: LeaseAllocateRequest[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, device: 'iPhone 17', target: 'mobile', iosSimulatorDeviceSet: '/sims' }, client: createTestClient({ @@ -152,6 +153,7 @@ test('a later open on the live lease uses the device its key names instead of pi const heartbeats: string[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, device: 'iPhone 17' }, // Another simulator with the same name must not replace the leased one. @@ -172,6 +174,7 @@ test('a later open naming another device on the live lease is refused instead of await assert.rejects( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, device: 'iPhone 16' }, client: renewingClient([], [LEASED_SIMULATOR, { ...IPHONE_SIMULATOR, name: 'iPhone 16' }]), @@ -213,6 +216,7 @@ test('a later open scoped to a simulator set that excludes the leased device is await assert.rejects( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, iosSimulatorDeviceSet: '/other-sims' }, client, @@ -236,6 +240,7 @@ test('a daemon whose lease names no device gets that lease released and the devi const releases: LeaseReleaseRequest[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, device: 'iPhone 17' }, client: createTestClient({ @@ -270,6 +275,7 @@ test('a renewed lease that names no device is refused and left to its owner', as await assert.rejects( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, device: 'iPhone 17' }, client: createTestClient({ @@ -302,6 +308,7 @@ test('proxy open with an exact udid still picks locally and allocates that devic const requests: LeaseAllocateRequest[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { ...flags, udid: 'SIM-LOCAL' }, client: createTestClient({ diff --git a/src/__tests__/remote-connection-proxy-lease-id.test.ts b/src/__tests__/remote-connection-proxy-lease-id.test.ts index 4145680887..6cfff6fc47 100644 --- a/src/__tests__/remote-connection-proxy-lease-id.test.ts +++ b/src/__tests__/remote-connection-proxy-lease-id.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -82,11 +82,13 @@ test('proxy allocation sends a fresh lease id on every attempt and keeps the id } as const; const first = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { ...flags }, client, }); const second = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'install', flags: { ...flags }, client, @@ -124,6 +126,7 @@ test('a non-proxy remote connection leaves the lease id to the daemon', async () const requests: LeaseAllocateRequest[] = []; const materialized = await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'screenshot', flags: { json: true, diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 16b5b5b438..d903cda234 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -25,11 +25,11 @@ import { connectCommand, connectionCommand, disconnectCommand, -} from '../cli/commands/connection.ts'; + materializeRemoteConnectionForCommand, +} from './test-utils/host-bound-commands.ts'; import { writeGeneratedRemoteConfig } from '../cli/connection/generated-config.ts'; import { hasDeferredMetroConfig, - materializeRemoteConnectionForCommand, CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS, PROXY_REMOTE_LEASE_TTL_MS, } from '../cli/commands/connection-runtime.ts'; diff --git a/src/__tests__/takeover-command.test.ts b/src/__tests__/takeover-command.test.ts index 75f53163ae..fe728dfb88 100644 --- a/src/__tests__/takeover-command.test.ts +++ b/src/__tests__/takeover-command.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { afterEach, beforeEach, test, vi } from 'vitest'; import { createAgentDeviceClient } from '../agent-device-client.ts'; @@ -52,8 +53,14 @@ test('takeover output explains the hold and release gesture', () => { test('takeover status and release use the configured lease client', async () => { const { client, transport } = clientForTest(); - await takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals: ['status'] }); await takeoverCommand({ + host: createTestHostEnvironment(), + client, + flags: TAKEOVER_CLI_FLAGS, + positionals: ['status'], + }); + await takeoverCommand({ + host: createTestHostEnvironment(), client, flags: TAKEOVER_CLI_FLAGS, positionals: ['release', 'operator-1'], @@ -72,9 +79,17 @@ test('takeover status and release use the configured lease client', async () => test('takeover rejects malformed actions without contacting the daemon', async () => { const { client, transport } = clientForTest(); for (const positionals of [['release'], ['status', 'extra'], ['unknown']]) { - await assert.rejects(takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals }), { - code: 'INVALID_ARGS', - }); + await assert.rejects( + takeoverCommand({ + host: createTestHostEnvironment(), + client, + flags: TAKEOVER_CLI_FLAGS, + positionals, + }), + { + code: 'INVALID_ARGS', + }, + ); } assert.equal(transport.mock.calls.length, 0); }); @@ -84,7 +99,12 @@ test('foreground takeover renews its admitted lease hold and releases it on Ctrl const { client, transport } = clientForTest(); const started = createControlLatch(); mocks.writeCommandOutput.mockImplementationOnce(() => started.resolve()); - const pending = takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals: [] }); + const pending = takeoverCommand({ + host: createTestHostEnvironment(), + client, + flags: TAKEOVER_CLI_FLAGS, + positionals: [], + }); await started.promise; await vi.advanceTimersByTimeAsync(5_000); process.emit('SIGINT'); @@ -110,7 +130,12 @@ test('a failed heartbeat stops foreground takeover and attempts release', async const { client, transport } = clientForTest(); const started = createControlLatch(); mocks.writeCommandOutput.mockImplementationOnce(() => started.resolve()); - const pending = takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals: [] }); + const pending = takeoverCommand({ + host: createTestHostEnvironment(), + client, + flags: TAKEOVER_CLI_FLAGS, + positionals: [], + }); const rejected = assert.rejects(pending, /heartbeat failed/); await started.promise; transport.mockRejectedValueOnce(new Error('heartbeat failed')); diff --git a/src/__tests__/test-utils/connect-command.ts b/src/__tests__/test-utils/connect-command.ts index 5550ee9916..9ac6fb5866 100644 --- a/src/__tests__/test-utils/connect-command.ts +++ b/src/__tests__/test-utils/connect-command.ts @@ -1,3 +1,5 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { connectCommand } from '../../cli/commands/connection.ts'; import type { AgentDeviceClient } from '../../agent-device-client.ts'; import { beginStdoutCapture } from './stream-capture.ts'; @@ -5,12 +7,14 @@ import { beginStdoutCapture } from './stream-capture.ts'; /** Runs `connect` the way the CLI does, writing its generated profile under `stateDir`. */ export async function connectWithGeneratedProviderProfile(options: { stateDir: string; + host?: HostEnvironment; positionals: string[]; flags: Partial[0]['flags']>; }): Promise { const stdoutCapture = beginStdoutCapture(); try { await connectCommand({ + host: options.host ?? createTestHostEnvironment(), positionals: options.positionals, flags: { json: true, diff --git a/src/__tests__/test-utils/host-bound-commands.ts b/src/__tests__/test-utils/host-bound-commands.ts new file mode 100644 index 0000000000..0dbdd655c7 --- /dev/null +++ b/src/__tests__/test-utils/host-bound-commands.ts @@ -0,0 +1,17 @@ +import { + connectCommand as connectCommandOnHost, + connectionCommand as connectionCommandOnHost, + disconnectCommand as disconnectCommandOnHost, +} from '../../cli/commands/connection.ts'; +import { materializeRemoteConnectionForCommand as materializeRemoteConnectionForCommandOnHost } from '../../cli/commands/connection-runtime.ts'; +import { tryRunClientBackedCommand as tryRunClientBackedCommandOnHost } from '../../cli/commands/router.ts'; +import { withTestHost } from './with-test-host.ts'; + +/** The CLI commands that take a run's host, bound to a fresh test host. */ +export const connectCommand = withTestHost(connectCommandOnHost); +export const disconnectCommand = withTestHost(disconnectCommandOnHost); +export const connectionCommand = withTestHost(connectionCommandOnHost); +export const materializeRemoteConnectionForCommand = withTestHost( + materializeRemoteConnectionForCommandOnHost, +); +export const tryRunClientBackedCommand = withTestHost(tryRunClientBackedCommandOnHost); diff --git a/src/__tests__/test-utils/with-test-host.ts b/src/__tests__/test-utils/with-test-host.ts new file mode 100644 index 0000000000..d0fe3d0967 --- /dev/null +++ b/src/__tests__/test-utils/with-test-host.ts @@ -0,0 +1,9 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; + +/** Binds a command that takes a `host` to a fresh test host, so a call site names only its inputs. */ +export function withTestHost

( + command: (params: P) => R, +): (params: Omit) => R { + return (params) => command({ host: createTestHostEnvironment(), ...params } as P); +} diff --git a/src/cli.ts b/src/cli.ts index 586d4f7a97..aaecb8c0b5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -219,6 +219,7 @@ async function runCliOnHost(argv: string[], deps: CliDeps, host: HostEnvironment const client = createAgentDeviceClient(buildClientConfig(ctx), { host: ctx.host, transport: createCliDaemonTransport({ + host, command, flags: ctx.effectiveFlags, replayTestReporterRuntime, @@ -352,6 +353,7 @@ async function resolveRunContextOrExit( const explicitFlagKeys = new Set(parsed.providedFlags.map((entry) => entry.key)); try { const binding = resolveBindingSettings({ + env: base.host.env, policyOverrides: parsed.flags, configuredPlatform: parsed.flags.platform, configuredSession: parsed.flags.session, @@ -359,6 +361,7 @@ async function resolveRunContextOrExit( const flags = binding.lockPolicy ? { ...parsed.flags } : applyDefaultPlatformBinding(parsed.flags, { + env: base.host.env, policyOverrides: parsed.flags, configuredPlatform: parsed.flags.platform, configuredSession: parsed.flags.session, @@ -446,6 +449,7 @@ async function resolveRemoteContext( const { materializeRemoteConnectionForCommand } = await import('./cli/commands/connection-runtime.ts'); const materialized = await materializeRemoteConnectionForCommand({ + host: ctx.host, command: ctx.command, flags: ctx.effectiveFlags, client: materializationClient, @@ -562,6 +566,7 @@ async function dispatchCliCommand( ctx.binding.lockPolicy && ctx.flags.platform === undefined ? { ...step.input } : applyDefaultPlatformBinding(step.input, { + env: ctx.host.env, policyOverrides: effectiveFlags, configuredPlatform: effectiveFlags.platform, configuredSession: effectiveFlags.session, @@ -570,6 +575,7 @@ async function dispatchCliCommand( })); if ( await tryRunClientBackedCommand({ + host: ctx.host, command, positionals, flags: { ...effectiveFlags, batchSteps }, @@ -588,6 +594,7 @@ async function dispatchCliCommand( ); } else if ( await tryRunClientBackedCommand({ + host: ctx.host, command, positionals, flags: effectiveFlags, @@ -849,6 +856,7 @@ function hasExplicitMetroRuntimeOverrides(explicitFlagKeys: Set): boole } type CliDaemonTransportSetup = { + host: HostEnvironment; command: string; flags: CliFlags; replayTestReporterRuntime?: ReplayTestReporterRuntime; @@ -866,7 +874,7 @@ function createCliDaemonTransport(options: CliDaemonTransportSetup): AgentDevice return async (req, context) => { const response = await send(req, context); const { forgetEndedProxyLease } = await import('./cli/connection/ended-lease.ts'); - forgetEndedProxyLease(options.flags, response); + forgetEndedProxyLease(options.flags, response, options.host); return response; }; } diff --git a/src/cli/auth-session.ts b/src/cli/auth-session.ts index cda05045d8..3c227cde73 100644 --- a/src/cli/auth-session.ts +++ b/src/cli/auth-session.ts @@ -65,7 +65,6 @@ type CliSessionRefreshResponse = { }; type AuthIo = { - env?: EnvMap; stdinIsTTY?: boolean; stdoutIsTTY?: boolean; stderr?: Pick; @@ -80,7 +79,7 @@ export async function resolveRemoteAuthForCli(options: { command: string; flags: CliFlags; stateDir: string; - env?: EnvMap; + env: EnvMap; }): Promise { return await resolveRemoteAuth({ command: options.command, @@ -96,10 +95,10 @@ export async function resolveRemoteAuth(options: { flags: CliFlags; stateDir: string; allowInteractiveLogin: boolean; - env?: EnvMap; + env: EnvMap; io?: AuthIo; }): Promise { - const env = options.env ?? options.io?.env ?? process.env; + const { env } = options; if (!options.flags.daemonBaseUrl) return { flags: options.flags, source: 'none' }; if (hasToken(options.flags.daemonAuthToken)) return { flags: options.flags, source: 'flag' }; if (hasToken(env.AGENT_DEVICE_DAEMON_AUTH_TOKEN)) { @@ -146,13 +145,13 @@ export async function resolveRemoteAuth(options: { export async function resolveCloudAccessForConnect(options: { stateDir: string; flags: CliFlags; - env?: EnvMap; + env: EnvMap; io?: AuthIo; }): Promise<{ accessToken: string; cloudBaseUrl: string; }> { - const env = options.env ?? options.io?.env ?? process.env; + const { env } = options; if (hasToken(options.flags.daemonAuthToken)) { return { accessToken: options.flags.daemonAuthToken, @@ -198,7 +197,7 @@ export async function resolveCloudAccessForConnect(options: { export async function loginWithDeviceAuth(options: { stateDir: string; flags: CliFlags; - env?: EnvMap; + env: EnvMap; io?: AuthIo; commandLabel?: string; }): Promise<{ @@ -206,7 +205,7 @@ export async function loginWithDeviceAuth(options: { expiresAt?: string; session: CliSessionRecord; }> { - const env = options.env ?? options.io?.env ?? process.env; + const { env } = options; const authMode = detectAuthMode(env, options.io); if (authMode === 'non-interactive') { throw buildNonInteractiveLoginError(options.commandLabel ?? 'agent-device connect', env); diff --git a/src/cli/commands/__tests__/daemon.test.ts b/src/cli/commands/__tests__/daemon.test.ts index d10e2f4197..580714bf22 100644 --- a/src/cli/commands/__tests__/daemon.test.ts +++ b/src/cli/commands/__tests__/daemon.test.ts @@ -2,7 +2,11 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { afterEach, expect, test, vi } from 'vitest'; import type { DaemonStopResult } from '@agent-device/daemon-contracts/daemon-stop'; -import { mkdtempForTestSync, bindTestCommandHost } from '@agent-device/test-kit'; +import { + mkdtempForTestSync, + bindTestCommandHost, + createTestHostEnvironment, +} from '@agent-device/test-kit'; const mocks = vi.hoisted(() => ({ cleanupDaemonOwner: vi.fn(async () => undefined), @@ -56,6 +60,7 @@ test('accepts only daemon stop and status', async () => { await assert.rejects( async () => await daemonCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { help: false, json: false, version: false }, client: {} as never, @@ -65,6 +70,7 @@ test('accepts only daemon stop and status', async () => { await assert.rejects( async () => await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['stop', 'extra'], flags: { help: false, json: false, version: false }, client: {} as never, @@ -93,6 +99,7 @@ test('merges a graceful shutdown report and cleans runner leases with the start- try { await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['stop'], flags: { clean: true, help: false, json: false, stateDir, version: false }, client: {} as never, @@ -132,6 +139,7 @@ test('reports graceful provider cleanup as unknown when the shutdown report is u try { await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['stop'], flags: { help: false, json: false, stateDir, version: false }, client: {} as never, @@ -169,6 +177,7 @@ test('warns in text output when a graceful stop leaves an orphaned claim', async try { await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['stop'], flags: { clean: false, help: false, json: false, stateDir, version: false }, client: {} as never, @@ -206,6 +215,7 @@ test('routes an unattributable claim to the default status view, never to --stal try { await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['stop'], flags: { clean: false, help: false, json: false, stateDir, version: false }, client: {} as never, @@ -260,6 +270,7 @@ test('daemon status reads the daemon record without stopping or contacting the d try { await daemonCommand({ + host: createTestHostEnvironment(), positionals: ['status'], flags: { help: false, json: true, stateDir, version: false }, client: {} as never, diff --git a/src/cli/commands/__tests__/generic.test.ts b/src/cli/commands/__tests__/generic.test.ts index 846b552391..d32e33716a 100644 --- a/src/cli/commands/__tests__/generic.test.ts +++ b/src/cli/commands/__tests__/generic.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import { withCapturedStdoutAsync } from '../../../__tests__/test-utils/stream-capture.ts'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { mkdtempForTestSync, createTestHostEnvironment } from '@agent-device/test-kit'; import { createAgentDeviceClient } from '../../../agent-device-client.ts'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { CliFlags } from '@agent-device/contracts/command'; @@ -25,6 +25,7 @@ test('snapshot --level digest --json preserves the digest through the generic CL const out = await withCapturedStdoutAsync(() => runGenericClientBackedCommand({ + host: createTestHostEnvironment(), command: 'snapshot' as ClientBackedCliCommandName, positionals: [], flags, @@ -66,6 +67,7 @@ test('replay human output keeps the composable warnings channel (#2560)', async const out = await withCapturedStdoutAsync(() => runGenericClientBackedCommand({ + host: createTestHostEnvironment(), command: 'replay' as ClientBackedCliCommandName, positionals: [scriptPath], flags: {} as CliFlags, @@ -100,6 +102,7 @@ test('the generic CLI path prints the response warnings', async () => { const out = await withCapturedStdoutAsync(() => runGenericClientBackedCommand({ + host: createTestHostEnvironment(), command: 'type' as ClientBackedCliCommandName, positionals: ['hi'], flags: {} as CliFlags, diff --git a/src/cli/commands/__tests__/proxy.test.ts b/src/cli/commands/__tests__/proxy.test.ts index 3ed9d958a7..1957a7164a 100644 --- a/src/cli/commands/__tests__/proxy.test.ts +++ b/src/cli/commands/__tests__/proxy.test.ts @@ -11,6 +11,7 @@ import { mkdtempForTestSync, skipWhenLoopbackUnavailable, bindTestCommandHost, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; @@ -70,6 +71,7 @@ function runProxy(flags: Partial[0]['flags']> = // The command streams its startup card and then serves until interrupted, so the handler // promise intentionally never settles; the test observes the emitted output and lets it float. void proxyCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, help: false, version: false, ...flags }, client: unreferencedClient, @@ -93,6 +95,7 @@ async function waitForStartupOutput(): Promise<{ test('proxy rejects positional arguments', async () => { await assert.rejects( proxyCommand({ + host: createTestHostEnvironment(), positionals: ['extra'], flags: { json: true, help: false, version: false }, client: unreferencedClient, @@ -110,6 +113,7 @@ test('proxy refuses to start when the local daemon has no HTTP endpoint', async await assert.rejects( proxyCommand({ + host: createTestHostEnvironment(), positionals: [], flags: { json: true, help: false, version: false }, client: unreferencedClient, diff --git a/src/cli/commands/__tests__/recording.test.ts b/src/cli/commands/__tests__/recording.test.ts index 72963b7106..ad7c028465 100644 --- a/src/cli/commands/__tests__/recording.test.ts +++ b/src/cli/commands/__tests__/recording.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { describe, expect, test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import type { ClientCommandParams } from '../router-types.ts'; @@ -5,6 +6,7 @@ import { recordingCommand } from '../recording.ts'; function params(positionals: string[]): ClientCommandParams { return { + host: createTestHostEnvironment(), positionals, flags: {} as ClientCommandParams['flags'], client: {} as ClientCommandParams['client'], diff --git a/src/cli/commands/__tests__/screenshot.test.ts b/src/cli/commands/__tests__/screenshot.test.ts index 1828612407..e108e2945c 100644 --- a/src/cli/commands/__tests__/screenshot.test.ts +++ b/src/cli/commands/__tests__/screenshot.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import { withCapturedStdoutAsync } from '../../../__tests__/test-utils/stream-capture.ts'; @@ -32,7 +33,7 @@ test('screenshot --level digest --json preserves the digest payload through the const flags = { json: true, responseLevel: 'digest' } as CliFlags; const out = await withCapturedStdoutAsync(() => - screenshotCommand({ positionals: [], flags, client }), + screenshotCommand({ host: createTestHostEnvironment(), positionals: [], flags, client }), ); const parsed = JSON.parse(out) as { success: boolean; data: Record }; @@ -50,7 +51,7 @@ test('screenshot surfaces response-level warnings under the summary', async () = const flags = { json: false } as CliFlags; const out = await withCapturedStdoutAsync(() => - screenshotCommand({ positionals: [], flags, client }), + screenshotCommand({ host: createTestHostEnvironment(), positionals: [], flags, client }), ); assert.equal(out, `/tmp/shot.png (40x20)\n${CROP_WARNING}\n`); @@ -62,7 +63,7 @@ test('screenshot --json carries response-level warnings in the data', async () = const flags = { json: true } as CliFlags; const out = await withCapturedStdoutAsync(() => - screenshotCommand({ positionals: [], flags, client }), + screenshotCommand({ host: createTestHostEnvironment(), positionals: [], flags, client }), ); const parsed = JSON.parse(out) as { data: { path: string; warnings?: string[] } }; @@ -84,7 +85,7 @@ test('screenshot --json at the default level still emits normalized screenshot m const flags = { json: true } as CliFlags; const out = await withCapturedStdoutAsync(() => - screenshotCommand({ positionals: [], flags, client }), + screenshotCommand({ host: createTestHostEnvironment(), positionals: [], flags, client }), ); const parsed = JSON.parse(out) as { data: { path: string; width?: number; pixelDensity?: number }; diff --git a/src/cli/commands/agent-cdp.ts b/src/cli/commands/agent-cdp.ts index 092d8c2cbc..fb783bcad3 100644 --- a/src/cli/commands/agent-cdp.ts +++ b/src/cli/commands/agent-cdp.ts @@ -20,8 +20,8 @@ type AgentCdpCommandOptions = { | 'tenant' >; runtime?: SessionRuntimeHints; - cwd?: string; - env?: NodeJS.ProcessEnv; + cwd: string; + env: NodeJS.ProcessEnv; }; export function buildAgentCdpNpmExecArgs(args: string[]): string[] { @@ -80,12 +80,12 @@ export function buildAgentCdpPassthroughArgs( export async function runAgentCdpCommand( args: string[], - options: AgentCdpCommandOptions = {}, + options: AgentCdpCommandOptions, ): Promise { const passthroughArgs = buildAgentCdpPassthroughArgs(args, options); const result = await runCmdStreaming('npm', buildAgentCdpNpmExecArgs(passthroughArgs), { - cwd: options.cwd ?? process.cwd(), - env: options.env ?? process.env, + cwd: options.cwd, + env: options.env, allowFailure: true, onStdoutChunk: (chunk) => { process.stdout.write(chunk); diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index 327b75d525..797f127335 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -1,15 +1,14 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { AppError } from '@agent-device/kernel/errors'; import { loginWithDeviceAuth, removeCliSession, summarizeCliSession } from '../auth-session.ts'; import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; -export const authCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const authCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { const subcommand = positionals[0] ?? 'status'; const stateDir = resolveDaemonPaths(flags.stateDir, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).baseDir; if (subcommand === 'status') { const status = summarizeCliSession({ stateDir }); @@ -21,6 +20,7 @@ export const authCommand: ClientCommandHandler = async ({ positionals, flags }) stateDir, flags, commandLabel: 'agent-device auth login', + env: host.env, }); const data = { authenticated: true, diff --git a/src/cli/commands/connection-runtime-metro-teardown.test.ts b/src/cli/commands/connection-runtime-metro-teardown.test.ts index 30a0ceb3d6..5c68b32452 100644 --- a/src/cli/commands/connection-runtime-metro-teardown.test.ts +++ b/src/cli/commands/connection-runtime-metro-teardown.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -60,6 +60,7 @@ test('deferred materialization stops the new Metro companion if state persistenc await assert.rejects( async () => await materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'open', flags: { json: true, diff --git a/src/cli/commands/connection-runtime-plugin-capabilities.test.ts b/src/cli/commands/connection-runtime-plugin-capabilities.test.ts index 9eca35ee0c..74e0092545 100644 --- a/src/cli/commands/connection-runtime-plugin-capabilities.test.ts +++ b/src/cli/commands/connection-runtime-plugin-capabilities.test.ts @@ -1,6 +1,11 @@ import { expect, test, vi } from 'vitest'; import fs from 'node:fs'; -import { pluginHome, selectV2Plugin, v2RegistrationSource } from '@agent-device/test-kit'; +import { + pluginHome, + selectV2Plugin, + v2RegistrationSource, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import { connectionWorkspace, createTestClient, @@ -43,6 +48,7 @@ test('a stored profile for a v2 plugin provider needs no remote daemon URL', asy // refused for lacking a daemonBaseUrl. await expect( materializeRemoteConnectionForCommand({ + host: createTestHostEnvironment(), command: 'snapshot', client: createTestClient(), flags: { diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index a99feeb2e6..28a8a36e32 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import crypto from 'node:crypto'; import type { MetroBridgeScope } from '@agent-device/contracts/remote'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; @@ -61,6 +61,7 @@ export const PROXY_REMOTE_LEASE_TTL_MS = 5 * 60 * 1000; export const CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS = 10 * 60 * 1000; export async function materializeRemoteConnectionForCommand(options: { + host: HostEnvironment; command: string; flags: CliFlags; client: AgentDeviceClient; @@ -73,19 +74,19 @@ export async function materializeRemoteConnectionForCommand(options: { runtime?: SessionRuntimeHints; connection?: RemoteConnectionRequestMetadata; }> { - const { command, flags, client } = options; + const { command, flags, client, host } = options; if (!flags.remoteConfig) { return { flags, runtime: options.runtime }; } const stateDir = resolveDaemonPaths(flags.stateDir, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).baseDir; const remoteConfig = resolveRemoteConfigProfile({ configPath: flags.remoteConfig, - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }); const profileFlags = profileToCliFlags(remoteConfig.profile); const mergedFlags = { diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 7a5083d445..0f50ded8e0 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { CloudArtifact, CloudProviderSessionResult, @@ -54,10 +54,15 @@ import { type RuntimePreparationNotice, } from './connection-presentation.ts'; -export const connectCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { +export const connectCommand: ClientCommandHandler = async ({ + positionals, + flags, + client, + host, +}) => { const stateDir = resolveDaemonPaths(flags.stateDir, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).baseDir; await primeProviderConnection(positionals[0]); const provider = readConnectProvider(positionals); @@ -66,11 +71,11 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, provider, flags, stateDir, - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }); const connectFlags = resolved.flags; - const connectionMetadata = readRemoteConfigConnectionMetadata(resolved.remoteConfigPath); + const connectionMetadata = readRemoteConfigConnectionMetadata(resolved.remoteConfigPath, host); const scope = readRequiredConnectScope(connectFlags, connectionMetadata); const context = resolveConnectContext({ stateDir, @@ -86,7 +91,7 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, connection: connectionMetadata, daemon: context.daemon, }); - const verification = await verifyResolvedConnectProvider(resolved); + const verification = await verifyResolvedConnectProvider(resolved, host.env); const state = buildConnectedState({ flags: connectFlags, scope, @@ -100,8 +105,9 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, connectFlags, context.previous, state, + host, ); - const runtimePreparation = buildRuntimePreparationNotice(connectFlags, state); + const runtimePreparation = buildRuntimePreparationNotice(connectFlags, state, host); const readiness = presentConnectReadiness(state, verification); await writeCommandOutput( @@ -280,6 +286,7 @@ async function cleanupForcedPreviousConnection( flags: CliFlags, previous: RemoteConnectionState | null, next: RemoteConnectionState, + host: HostEnvironment, ): Promise { if (!previous || !flags.force) return undefined; await stopMetroCleanup(previous.metro); @@ -287,24 +294,25 @@ async function cleanupForcedPreviousConnection( return await releasePreviousLease(client, previous, { nextDaemonBaseUrl: next.daemon?.baseUrl, ambientDaemonAuthToken: flags.daemonAuthToken, - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }); } function readRemoteConfigConnectionMetadata( remoteConfigPath: string, + host: HostEnvironment, ): RemoteConnectionRequestMetadata | undefined { const profile = resolveRemoteConfigProfile({ configPath: remoteConfigPath, - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).profile; return buildRemoteConnectionRequestMetadata(profile); } -export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) => { - const { session, stateDir, state } = readRequestedConnectionState(flags); +export const disconnectCommand: ClientCommandHandler = async ({ flags, client, host }) => { + const { session, stateDir, state } = readRequestedConnectionState(flags, host); if (!state) { await writeNoRemoteConnectionOutput(flags, session); return true; @@ -346,18 +354,18 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) return true; }; -export const connectionCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const connectionCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { if (positionals[0] !== 'status') { throw new AppError('INVALID_ARGS', 'connection accepts only: status'); } - const { session, state } = readRequestedConnectionState(flags); + const { session, state } = readRequestedConnectionState(flags, host); if (!state) { await writeNoRemoteConnectionOutput(flags, session); return true; } await primeProviderConnection(state.leaseProvider); const leasePreparation = buildLeasePreparationNotice(state); - const runtimePreparation = buildRuntimePreparationNoticeFromState(state); + const runtimePreparation = buildRuntimePreparationNoticeFromState(state, host); await writeCommandOutput(flags, serializeConnectionState({ state, runtimePreparation }), () => [ `Configured remote session "${state.session}".`, @@ -424,15 +432,18 @@ function readConnectProvider(positionals: string[]): ConnectProvider | undefined ); } -function readRequestedConnectionState(flags: CliFlags): { +function readRequestedConnectionState( + flags: CliFlags, + host: HostEnvironment, +): { session: string; stateDir: string; state: RemoteConnectionState | null; } { const session = flags.session ?? 'default'; const stateDir = resolveDaemonPaths(flags.stateDir, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).baseDir; return { session, @@ -518,9 +529,13 @@ function isSameDaemonState( function buildRuntimePreparationNotice( flags: CliFlags, state: RemoteConnectionState, + host: HostEnvironment, ): RuntimePreparationNotice | undefined { if (state.runtime) return undefined; - if (!hasDeferredMetroConfig(flags) && !remoteConfigHasMetroSettings(state.remoteConfigPath)) { + if ( + !hasDeferredMetroConfig(flags) && + !remoteConfigHasMetroSettings(state.remoteConfigPath, host) + ) { return undefined; } return buildDeferredRuntimeNotice(state); @@ -528,8 +543,10 @@ function buildRuntimePreparationNotice( function buildRuntimePreparationNoticeFromState( state: RemoteConnectionState, + host: HostEnvironment, ): RuntimePreparationNotice | undefined { - if (state.runtime || !remoteConfigHasMetroSettings(state.remoteConfigPath)) return undefined; + if (state.runtime || !remoteConfigHasMetroSettings(state.remoteConfigPath, host)) + return undefined; return buildDeferredRuntimeNotice(state); } @@ -547,12 +564,12 @@ function buildDeferredRuntimeNotice(state: RemoteConnectionState): RuntimePrepar }; } -function remoteConfigHasMetroSettings(remoteConfigPath: string): boolean { +function remoteConfigHasMetroSettings(remoteConfigPath: string, host: HostEnvironment): boolean { try { const remoteConfig = resolveRemoteConfigProfile({ configPath: remoteConfigPath, - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }); const profile = remoteConfig.profile; return Boolean( diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 6ec23edaa1..8d3499452f 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { readPluginInventory, type PluginInventoryEntry, @@ -22,14 +21,14 @@ import { createDaemonOwnerCleanup } from '../../platform-runtime-daemon-owner-cl const daemonOwnerCleanup = createDaemonOwnerCleanup(); -export const daemonCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const daemonCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { const subcommand = positionals[0]; if ((subcommand !== 'stop' && subcommand !== 'status') || positionals.length !== 1) { throw new AppError('INVALID_ARGS', 'daemon accepts only: stop, status'); } const paths = resolveDaemonPaths(flags.stateDir, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }); if (subcommand === 'status') { const status = readDaemonStatus(paths); diff --git a/src/cli/commands/device.ts b/src/cli/commands/device.ts index 113f556398..2e2a40ea95 100644 --- a/src/cli/commands/device.ts +++ b/src/cli/commands/device.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { AppError } from '@agent-device/kernel/errors'; import { publicPlatformString } from '@agent-device/kernel/device'; import { @@ -14,11 +14,11 @@ import type { CliFlags } from '@agent-device/contracts/command'; import type { ClientCommandHandler } from './router-types.ts'; // Loaded dynamically by dedicatedCliCommandHandlerLoaders in router.ts. -export const deviceCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const deviceCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { if (positionals.length !== 1 || (positionals[0] !== 'status' && positionals[0] !== 'release')) { throw new AppError('INVALID_ARGS', 'device accepts only: status, release'); } - if (positionals[0] === 'release') return await runDeviceRelease(flags); + if (positionals[0] === 'release') return await runDeviceRelease(flags, host); const inspectedClaims = inspectDeviceClaims( { platform: flags.platform, @@ -26,7 +26,7 @@ export const deviceCommand: ClientCommandHandler = async ({ positionals, flags } udid: flags.udid, serial: flags.serial, }, - commandHostEnvironment().env, + host.env, ); const staleClaims = inspectedClaims.filter((claim) => deviceClaimRequiresStaleInspection(claim.classification), @@ -59,7 +59,7 @@ export const deviceCommand: ClientCommandHandler = async ({ positionals, flags } * provably cannot release them. Everything else fails closed and is reported * with the reason, so a live or uncertain owner is never interrupted. */ -async function runDeviceRelease(flags: CliFlags): Promise { +async function runDeviceRelease(flags: CliFlags, host: HostEnvironment): Promise { if (flags.stale !== true) { throw new AppError( 'INVALID_ARGS', @@ -77,7 +77,7 @@ async function runDeviceRelease(flags: CliFlags): Promise { udid: flags.udid, serial: flags.serial, }, - commandHostEnvironment().env, + host.env, ); const data = { released: outcomes.filter((outcome) => outcome.status === 'released').map(serializeOutcome), diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index 5358ec58d4..4cfb6fe3c8 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { randomBytes } from 'node:crypto'; import { createDaemonProxyServer } from '@agent-device/proxy'; import { buildDaemonHttpBaseUrl } from '@agent-device/contracts/daemon-http'; @@ -20,17 +20,21 @@ type ProxyStartup = { stateDir: string; }; -export const proxyCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const proxyCommand: ClientCommandHandler = async ({ + positionals, + flags, + host: clientHost, +}) => { if (positionals.length > 0) { throw new AppError('INVALID_ARGS', 'proxy does not accept positional arguments.'); } - const startup = await startProxy(flags); + const startup = await startProxy(flags, clientHost); await writeCommandOutput(flags, startup, () => renderProxyStartup(startup)); await waitForever(); return true; }; -async function startProxy(flags: CliFlags): Promise { +async function startProxy(flags: CliFlags, clientHost: HostEnvironment): Promise { const settings = resolveClientSettings( { session: 'default', @@ -44,7 +48,7 @@ async function startProxy(flags: CliFlags): Promise { }, }, undefined, - commandHostEnvironment(), + clientHost, ); const daemon = await ensureManagedDaemon(settings); const upstreamBaseUrl = resolveLocalDaemonBaseUrl(daemon.info.httpPort); diff --git a/src/cli/commands/recording.ts b/src/cli/commands/recording.ts index 8be1aa533f..868ce38b24 100644 --- a/src/cli/commands/recording.ts +++ b/src/cli/commands/recording.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { AppError } from '@agent-device/kernel/errors'; import { localCommandPolicy } from '@agent-device/contracts/command-policy'; import type { RecordingContactSheetCommandResult } from '../../commands/recording/runtime/contact-sheet.ts'; @@ -9,7 +8,7 @@ import type { ClientCommandHandler } from './router-types.ts'; /** The `record` action that reads a finished file locally instead of asking a device for anything. */ const CONTACT_SHEET_ACTION = 'contact-sheet'; -export const recordingCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const recordingCommand: ClientCommandHandler = async ({ positionals, flags, host }) => { if (positionals[0] !== CONTACT_SHEET_ACTION) return false; if (positionals.length > 2) { throw new AppError( @@ -19,12 +18,10 @@ export const recordingCommand: ClientCommandHandler = async ({ positionals, flag } const recordingPath = resolveUserPath(readRequiredPositional(positionals[1]), { - cwd: commandHostEnvironment().cwd, + cwd: host.cwd, }); const outputPath = - typeof flags.out === 'string' - ? resolveUserPath(flags.out, { cwd: commandHostEnvironment().cwd }) - : undefined; + typeof flags.out === 'string' ? resolveUserPath(flags.out, { cwd: host.cwd }) : undefined; // Lazy: createAgentDevice pulls in the client-side command runtime, which only this action needs. const [{ createAgentDevice }, { createLocalArtifactAdapter }] = await Promise.all([ @@ -33,7 +30,7 @@ export const recordingCommand: ClientCommandHandler = async ({ positionals, flag ]); const runtime = createAgentDevice({ backend: { platform: 'ios' }, - artifacts: createLocalArtifactAdapter(), + artifacts: createLocalArtifactAdapter({ cwd: host.cwd }), sessions: { get: (name) => ({ name }), set: () => {}, diff --git a/src/cli/commands/replay.ts b/src/cli/commands/replay.ts index c979b63587..96d8caf968 100644 --- a/src/cli/commands/replay.ts +++ b/src/cli/commands/replay.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; @@ -31,6 +30,7 @@ function handleReplayRunCommand({ positionals, flags }: ReplayCommandParams): fa async function handleReplayExportCommand({ positionals, flags, + host, }: ReplayCommandParams): Promise { validateReplayExportOptions(positionals, flags); const inputPath = positionals[1]; @@ -38,7 +38,7 @@ async function handleReplayExportCommand({ throw new AppError('INVALID_ARGS', 'replay export requires an input path.'); } - const sourcePath = resolveUserPath(inputPath, { cwd: commandHostEnvironment().cwd }); + const sourcePath = resolveUserPath(inputPath, { cwd: host.cwd }); const script = fs.readFileSync(sourcePath, 'utf8'); const parsed = parseReplayScriptDetailed(script); // `replay export` exports to Maestro, so the conversion goes through that backend's registry @@ -52,9 +52,7 @@ async function handleReplayExportCommand({ metadata: readReplayScriptMetadata(script), }); const outputPath = - typeof flags.out === 'string' - ? resolveUserPath(flags.out, { cwd: commandHostEnvironment().cwd }) - : undefined; + typeof flags.out === 'string' ? resolveUserPath(flags.out, { cwd: host.cwd }) : undefined; if (outputPath) { fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, result.yaml); diff --git a/src/cli/commands/router-types.ts b/src/cli/commands/router-types.ts index 2d0c9e38f0..609b223ef8 100644 --- a/src/cli/commands/router-types.ts +++ b/src/cli/commands/router-types.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { CliFlags } from '@agent-device/contracts/command'; import type { AgentDeviceClient } from '../../agent-device-client.ts'; import type { CliCommandName } from '@agent-device/command-registry/catalog'; @@ -5,6 +6,8 @@ import type { CommandProgressState } from '../../commands/command-progress.ts'; import type { ReplayTestReporterRuntime } from '../replay-test/reporting.ts'; export type ClientCommandParams = { + /** The host this run reads its environment and working directory from. */ + host: HostEnvironment; positionals: string[]; flags: CliFlags; client: AgentDeviceClient; diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index 6774e2a6d4..14220317d1 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -26,6 +26,7 @@ const dedicatedCliCommandHandlerLoaders = { } satisfies ClientCommandHandlerMap; export async function tryRunClientBackedCommand(params: { + host: ClientCommandParams['host']; command: string; positionals: string[]; flags: CliFlags; diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts index f3ee104fb7..f12fe61f20 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { localCommandPolicy } from '@agent-device/contracts/command-policy'; import { formatScreenshotDiffText, formatSnapshotDiffText } from '../../commands/output/diff.ts'; import type { ScreenshotDiffResult } from '@agent-device/capture-kit/screenshot-diff'; @@ -38,7 +37,7 @@ export const screenshotCommand: ClientCommandHandler = async ({ positionals, fla return true; }; -export const diffCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { +export const diffCommand: ClientCommandHandler = async ({ positionals, flags, client, host }) => { if (positionals[0] === 'snapshot') { const result = await runCliCommand({ client, command: 'diff', positionals, flags }); await writeCommandOutput(flags, result, () => formatSnapshotDiffText(result)); @@ -52,11 +51,9 @@ export const diffCommand: ClientCommandHandler = async ({ positionals, flags, cl throw new AppError('INVALID_ARGS', 'diff screenshot requires --baseline '); } - const baselinePath = resolveUserPath(baselineRaw, { cwd: commandHostEnvironment().cwd }); + const baselinePath = resolveUserPath(baselineRaw, { cwd: host.cwd }); const outputPath = - typeof flags.out === 'string' - ? resolveUserPath(flags.out, { cwd: commandHostEnvironment().cwd }) - : undefined; + typeof flags.out === 'string' ? resolveUserPath(flags.out, { cwd: host.cwd }) : undefined; const currentRaw = positionals[1]; if (positionals.length > 2) { throw new AppError( @@ -73,7 +70,7 @@ export const diffCommand: ClientCommandHandler = async ({ positionals, flags, cl ]); const runtime = createAgentDevice({ backend: createClientScreenshotBackend(client, flags), - artifacts: createLocalArtifactAdapter(), + artifacts: createLocalArtifactAdapter({ cwd: host.cwd }), sessions: { get: (name) => ({ name }), set: () => {}, @@ -85,7 +82,7 @@ export const diffCommand: ClientCommandHandler = async ({ positionals, flags, cl session: flags.session, baseline: { kind: 'path', path: baselinePath }, current: currentRaw - ? { kind: 'path', path: resolveUserPath(currentRaw, { cwd: commandHostEnvironment().cwd }) } + ? { kind: 'path', path: resolveUserPath(currentRaw, { cwd: host.cwd }) } : { kind: 'live' }, ...(outputPath ? { out: { kind: 'path', path: outputPath } } : {}), threshold: parseCliThreshold(flags.threshold), diff --git a/src/cli/connection/cloud-profile.ts b/src/cli/connection/cloud-profile.ts index 5af7d3fad5..fe58324e96 100644 --- a/src/cli/connection/cloud-profile.ts +++ b/src/cli/connection/cloud-profile.ts @@ -21,7 +21,7 @@ export async function resolveCloudConnectProfile(options: { flags: CliFlags; stateDir: string; cwd: string; - env?: EnvMap; + env: EnvMap; // di-seam-approved: injects the fetch global, which has no module boundary vi.mock can // intercept. Exercised at the CLI layer by cloud-connect-profile.test.ts via // vi.stubGlobal('fetch', ...), a layer where this parameter is not reachable. @@ -32,7 +32,6 @@ export async function resolveCloudConnectProfile(options: { flags: options.flags, env: options.env, io: { - env: options.env, fetch: options.fetchImpl, }, }); diff --git a/src/cli/connection/connect-provider-adapters.ts b/src/cli/connection/connect-provider-adapters.ts index 0b078c9cda..8ca1de5d20 100644 --- a/src/cli/connection/connect-provider-adapters.ts +++ b/src/cli/connection/connect-provider-adapters.ts @@ -102,11 +102,10 @@ export async function resolveConnectProviderProfile(options: { provider?: ConnectProvider; flags: CliFlags; stateDir: string; - cwd?: string; - env?: EnvMap; + cwd: string; + env: EnvMap; }): Promise { - const cwd = options.cwd ?? process.cwd(); - const env = options.env ?? process.env; + const { cwd, env } = options; if (options.flags.remoteConfig) { const resolved = resolveRemoteConfigProfile({ configPath: options.flags.remoteConfig, @@ -187,6 +186,7 @@ async function resolveProviderConnectionProfile( export async function verifyResolvedConnectProvider( resolved: ResolvedConnectProfile, + env: EnvMap, ): Promise { if (!resolved.provider) { return { @@ -198,7 +198,7 @@ export async function verifyResolvedConnectProvider( } const context = { flags: resolved.flags, - env: process.env, + env, }; const adapter = builtinConnectAdapter(resolved.provider); return adapter diff --git a/src/cli/connection/ended-lease.test.ts b/src/cli/connection/ended-lease.test.ts index 453b7ee489..d73f4b4eeb 100644 --- a/src/cli/connection/ended-lease.test.ts +++ b/src/cli/connection/ended-lease.test.ts @@ -1,4 +1,4 @@ -import { bindTestCommandHost } from '@agent-device/test-kit'; +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { test } from 'vitest'; @@ -46,6 +46,7 @@ function storedLeaseIdAfter( session: 'adc-proxy', }, response, + createTestHostEnvironment(), ); return readRemoteConnectionState({ stateDir, session: 'adc-proxy' })?.leaseId; } diff --git a/src/cli/connection/ended-lease.ts b/src/cli/connection/ended-lease.ts index d1c84ae002..0415d5a12c 100644 --- a/src/cli/connection/ended-lease.ts +++ b/src/cli/connection/ended-lease.ts @@ -1,4 +1,4 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { CliFlags } from '@agent-device/contracts/command'; import { readEndedLeases } from '@agent-device/contracts/lease-scope'; @@ -14,13 +14,17 @@ import { connectionProviderCapabilities } from './provider-policy.ts'; * Forgets the stored proxy lease once a daemon reply says its request ended that lease, so the next * `open` allocates instead of heartbeating a lease that no longer exists. */ -export function forgetEndedProxyLease(flags: CliFlags, response: DaemonResponse): void { +export function forgetEndedProxyLease( + flags: CliFlags, + response: DaemonResponse, + host: HostEnvironment, +): void { if (!flags.remoteConfig) return; const endedLeaseIds = readEndedLeases(response).map((lease) => lease.leaseId); if (endedLeaseIds.length === 0) return; const stateDir = resolveDaemonPaths(flags.stateDir, { - cwd: commandHostEnvironment().cwd, - env: commandHostEnvironment().env, + cwd: host.cwd, + env: host.env, }).baseDir; const state = readRemoteConnectionState({ stateDir, session: flags.session ?? 'default' }); if (!state?.leaseId || !endedLeaseIds.includes(state.leaseId)) return; diff --git a/src/cli/connection/generated-config.ts b/src/cli/connection/generated-config.ts index 48168ecb71..e82ad7544e 100644 --- a/src/cli/connection/generated-config.ts +++ b/src/cli/connection/generated-config.ts @@ -37,7 +37,7 @@ export function writeGeneratedRemoteConfig(options: { function resolveGeneratedRemoteConfigProfile(options: { configPath: string; cwd: string; - env?: EnvMap; + env: EnvMap; provider: string; }): ResolvedRemoteConfigProfile { try { @@ -60,7 +60,7 @@ function resolveGeneratedRemoteConfigProfile(options: { export function persistAndResolveGeneratedProfile(options: { stateDir: string; cwd: string; - env?: EnvMap; + env: EnvMap; provider: string; profile: RemoteConfigProfile; flags: CliFlags; diff --git a/src/cli/connection/limrun-profile.ts b/src/cli/connection/limrun-profile.ts index 552fba7b14..2586e6b383 100644 --- a/src/cli/connection/limrun-profile.ts +++ b/src/cli/connection/limrun-profile.ts @@ -1,4 +1,3 @@ -import { commandHostEnvironment } from '@agent-device/host-kit/command'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import type { RemoteConfigProfile } from '@agent-device/remote/remote-config-schema'; import { AppError } from '@agent-device/kernel/errors'; @@ -20,9 +19,9 @@ export function resolveLimrunConnectProfile(options: { flags: CliFlags; stateDir: string; cwd: string; - env?: EnvMap; + env: EnvMap; }): { flags: CliFlags; remoteConfigPath: string } { - const env = options.env ?? commandHostEnvironment().env; + const { env } = options; const profile = buildLimrunRemoteProfile({ flags: options.flags, cwd: options.cwd, env }); const credentials = readLimrunCredentials(env); const platform = limrunPlatformForLeaseBackend(profile.leaseBackend) ?? 'android'; diff --git a/src/cli/connection/proxy-profile.ts b/src/cli/connection/proxy-profile.ts index 768bbfe7a6..fc3f90b5c6 100644 --- a/src/cli/connection/proxy-profile.ts +++ b/src/cli/connection/proxy-profile.ts @@ -11,9 +11,9 @@ export function resolveProxyConnectProfile(options: { flags: CliFlags; stateDir: string; cwd: string; - env?: EnvMap; + env: EnvMap; }): { flags: CliFlags; remoteConfigPath: string } { - const daemonBaseUrl = options.flags.daemonBaseUrl ?? options.env?.AGENT_DEVICE_DAEMON_BASE_URL; + const daemonBaseUrl = options.flags.daemonBaseUrl ?? options.env.AGENT_DEVICE_DAEMON_BASE_URL; if (!daemonBaseUrl) { throw new AppError( 'INVALID_ARGS', diff --git a/src/cli/resolve-cli-options.ts b/src/cli/resolve-cli-options.ts index 96ff93bb51..69eb8d3737 100644 --- a/src/cli/resolve-cli-options.ts +++ b/src/cli/resolve-cli-options.ts @@ -7,15 +7,14 @@ import { type EnvMap } from '@agent-device/kernel/source-value'; export function resolveCliOptions( argv: string[], - options?: { - cwd?: string; - env?: EnvMap; + options: { + cwd: string; + env: EnvMap; strictFlags?: boolean; }, ) { const rawParsed = parseRawArgs(argv); - const env = options?.env ?? process.env; - const cwd = options?.cwd ?? process.cwd(); + const { env, cwd } = options; const remoteConfigDefaults = shouldApplyRemoteConfigDefaults(rawParsed.command) ? resolveRemoteConfigDefaults({ remoteConfig: rawParsed.flags.remoteConfig, diff --git a/src/cli/session-binding.ts b/src/cli/session-binding.ts index 89757bcdd5..2e2f85b38f 100644 --- a/src/cli/session-binding.ts +++ b/src/cli/session-binding.ts @@ -21,7 +21,7 @@ type LockableFlags = Pick< >; type BindingOptions = { - env?: NodeJS.ProcessEnv; + env: NodeJS.ProcessEnv; policyOverrides?: BindingPolicyOverrides; configuredPlatform?: CliFlags['platform']; configuredSession?: string; @@ -30,7 +30,7 @@ type BindingOptions = { export function applyDefaultPlatformBinding( flags: T, - options: BindingOptions = {}, + options: BindingOptions, ): T { const settings = resolveBindingSettings(options); const nextFlags = { ...flags }; @@ -43,7 +43,7 @@ export function applyDefaultPlatformBinding( } export function resolveBindingSettings(options: BindingOptions): BindingSettings { - const env = options.env ?? process.env; + const { env } = options; const defaultPlatform = options.inheritedPlatform ?? options.configuredPlatform ?? diff --git a/src/daemon-client/__tests__/boundary-fault-acceptance.test.ts b/src/daemon-client/__tests__/boundary-fault-acceptance.test.ts index 7b0e8b5ef5..2fff6d745b 100644 --- a/src/daemon-client/__tests__/boundary-fault-acceptance.test.ts +++ b/src/daemon-client/__tests__/boundary-fault-acceptance.test.ts @@ -4,7 +4,11 @@ import http from 'node:http'; import path from 'node:path'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { assertRejectsAppError, mkdtempForTestSync } from '@agent-device/test-kit'; +import { + assertRejectsAppError, + mkdtempForTestSync, + createTestHostEnvironment, +} from '@agent-device/test-kit'; import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { sendRequest } from '../daemon-client-transport.ts'; @@ -44,6 +48,7 @@ for (const row of Object.values(PROCESS_DEATH_ROWS)) { 'http', statePaths, 1_000, + { host: createTestHostEnvironment() }, ); } catch (error) { observed = error; diff --git a/src/daemon-client/__tests__/boundary-fault-transport.test.ts b/src/daemon-client/__tests__/boundary-fault-transport.test.ts index 3025d1fba0..0c59f48ffe 100644 --- a/src/daemon-client/__tests__/boundary-fault-transport.test.ts +++ b/src/daemon-client/__tests__/boundary-fault-transport.test.ts @@ -11,6 +11,7 @@ import { listenOnLoopback, type LoopbackServer, mkdtempForTestSync, + createTestHostEnvironment, } from '@agent-device/test-kit'; import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { sendRequest } from '../daemon-client-transport.ts'; @@ -60,6 +61,7 @@ for (const [commandClass, row] of Object.entries(COMMAND_ROWS)) { 'http', daemonPaths(), 60, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -95,6 +97,7 @@ for (const [commandClass, row] of Object.entries(COMMAND_ROWS)) { 'http', daemonPaths(), 1_000, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -136,6 +139,7 @@ test('HTTP RPC errors preserve typed identity through the client transport', asy 'http', daemonPaths(), 1_000, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -165,6 +169,7 @@ test('socket partial response fails at end-of-stream instead of waiting for the 'socket', daemonPaths(), 1_000, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -194,6 +199,7 @@ test('socket empty response fails at end-of-stream with request context', async 'socket', daemonPaths(), 1_000, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); @@ -222,6 +228,7 @@ test('socket malformed response stays typed at the daemon client seam', async () 'socket', daemonPaths(), 1_000, + { host: createTestHostEnvironment() }, ), (error: unknown) => { assert.ok(error instanceof AppError); diff --git a/src/daemon-client/__tests__/daemon-client-abort-timeout.test.ts b/src/daemon-client/__tests__/daemon-client-abort-timeout.test.ts index 02d6997787..dcae9a7162 100644 --- a/src/daemon-client/__tests__/daemon-client-abort-timeout.test.ts +++ b/src/daemon-client/__tests__/daemon-client-abort-timeout.test.ts @@ -25,6 +25,7 @@ import { type SkippableTestContext, skipWhenLoopbackUnavailable, trackLoopbackSockets, + createTestHostEnvironment, } from '@agent-device/test-kit'; const { handleRequestTimeoutCalls } = vi.hoisted(() => ({ @@ -78,7 +79,9 @@ async function runTimerDiscipline( transport, STATE_PATHS, TIMEOUT_MS, - aborted ? { signal: controller.signal } : {}, + aborted + ? { host: createTestHostEnvironment(), signal: controller.signal } + : { host: createTestHostEnvironment() }, ); if (aborted) { const armedTimeoutIndex = setTimeoutSpy.mock.calls.findIndex( @@ -162,7 +165,7 @@ test('an abort during a restart health probe never retires the daemon or redispa 'auto', STATE_PATHS, 1_000, - { signal: controller.signal }, + { host: createTestHostEnvironment(), signal: controller.signal }, ); const rejection = assert.rejects(request, (error: unknown) => isRequestCanceledError(error)); await healthStarted; diff --git a/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts b/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts index a266293a47..1bd7e64a9f 100644 --- a/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts +++ b/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts @@ -48,6 +48,7 @@ import { type LoopbackServer, mkdtempForTestSync, skipWhenLoopbackUnavailable, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { @@ -362,6 +363,7 @@ for (const row of ROUTE_ROWS) { row.transport === 'remote' ? 'http' : row.transport, statePaths, TIMEOUT_MS, + { host: createTestHostEnvironment() }, ), row.hintPattern, ); @@ -406,6 +408,7 @@ test('request-timeout route: a reset never deletes a registration a replacement 'socket', statePaths, TIMEOUT_MS, + { host: createTestHostEnvironment() }, ), /The daemon could not be safely retired/, ); @@ -439,7 +442,14 @@ test('a refused timeout fallback preserves the timeout without an unhandled reje }); try { await assert.rejects( - sendRequest(info, buildRequest(RESET_POLICY_COMMAND, undefined), 'socket', paths, TIMEOUT_MS), + sendRequest( + info, + buildRequest(RESET_POLICY_COMMAND, undefined), + 'socket', + paths, + TIMEOUT_MS, + { host: createTestHostEnvironment() }, + ), (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.details?.reason, 'daemon_transport_timeout'); @@ -470,7 +480,7 @@ test('request-timeout route: an attach client leaves a daemon that answers no pr 'socket', paths, TIMEOUT_MS, - { timeoutRecovery: 'none' }, + { host: createTestHostEnvironment(), timeoutRecovery: 'none' }, ), /did not answer the liveness probe.*does not retire a daemon it did not start.*agent-device daemon stop/s, ); @@ -510,6 +520,7 @@ test('request-timeout route: the reset acts on the resolved state paths, not the 'http', resolvedPaths, TIMEOUT_MS, + { host: createTestHostEnvironment() }, ), /The daemon did not answer the liveness probe and was reset after the timeout/, ); diff --git a/src/daemon-client/__tests__/daemon-client-transport.test.ts b/src/daemon-client/__tests__/daemon-client-transport.test.ts index 54e4b5e6c5..a1319a5676 100644 --- a/src/daemon-client/__tests__/daemon-client-transport.test.ts +++ b/src/daemon-client/__tests__/daemon-client-transport.test.ts @@ -9,6 +9,7 @@ import { listenOnLoopback, mkdtempForTestSync, skipWhenLoopbackUnavailable, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { test, vi } from 'vitest'; import * as hostTransport from '@agent-device/host-kit/transport'; @@ -40,6 +41,7 @@ function sendWithStaleInstance(port: number, timeoutMs: number) { 'auto', resolveDaemonPaths('/tmp/agent-device-instance-retry-test'), timeoutMs, + { host: createTestHostEnvironment() }, ); } @@ -497,6 +499,7 @@ test.for(['timeout', 'reset'] as const)( 'http', paths, mode === 'timeout' ? 40 : 1_000, + { host: createTestHostEnvironment() }, ), ), (error: unknown) => { diff --git a/src/daemon-client/daemon-client-lease-beat.ts b/src/daemon-client/daemon-client-lease-beat.ts index 35ea71afbb..3ef65e2cd5 100644 --- a/src/daemon-client/daemon-client-lease-beat.ts +++ b/src/daemon-client/daemon-client-lease-beat.ts @@ -325,6 +325,6 @@ export function buildUploadLeaseHeartbeat( // The beat's own budget governs; the command's heartbeat policy only ever caps it, and an // unbounded policy leaves the budget standing on its own. policyTimeoutMs === undefined ? budgetMs : Math.min(policyTimeoutMs, budgetMs), - { signal }, + { host: settings.host, signal }, ); } diff --git a/src/daemon-client/daemon-client-quiesce.ts b/src/daemon-client/daemon-client-quiesce.ts index 7f35e3eb33..6a65992624 100644 --- a/src/daemon-client/daemon-client-quiesce.ts +++ b/src/daemon-client/daemon-client-quiesce.ts @@ -49,7 +49,7 @@ export async function requestDaemonQuiesce( 'auto', settings.paths, QUIESCE_TIMEOUT_MS, - { timeoutRecovery: 'none' }, + { host: settings.host, timeoutRecovery: 'none' }, ); } catch (error) { if (isDaemonTransportUnavailableError(error) || isConnectionFailure(error)) return 'gone'; diff --git a/src/daemon-client/daemon-client-rpc.test.ts b/src/daemon-client/daemon-client-rpc.test.ts index 7989e9daf8..2a8ec92267 100644 --- a/src/daemon-client/daemon-client-rpc.test.ts +++ b/src/daemon-client/daemon-client-rpc.test.ts @@ -1,3 +1,4 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; @@ -161,6 +162,7 @@ test('HTTP RPC errors sanitize an untrusted cause before rehydration', () => { }, }), { + host: createTestHostEnvironment(), info: { token: 'daemon-token', pid: 1 }, req: { command: 'apps_install_source', diff --git a/src/daemon-client/daemon-client-rpc.ts b/src/daemon-client/daemon-client-rpc.ts index faa004b4cd..afe33b6201 100644 --- a/src/daemon-client/daemon-client-rpc.ts +++ b/src/daemon-client/daemon-client-rpc.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { @@ -24,11 +25,12 @@ export function handleDaemonHttpResponseBody( info: DaemonInfo; req: DaemonWireRequest; stateDir: string; + host: HostEnvironment; resolve: (response: DaemonResponse | PromiseLike) => void; reject: (error: unknown) => void; }, ): void { - const { info, req, stateDir, resolve, reject } = options; + const { info, req, stateDir, host, resolve, reject } = options; try { const parsed = parseDaemonHttpResponseBody(body); if (parsed.error) { @@ -43,7 +45,7 @@ export function handleDaemonHttpResponseBody( ); return; } - void resolveDaemonHttpResult(info, req, parsed.result, resolve, reject); + void resolveDaemonHttpResult(info, req, parsed.result, host, resolve, reject); } catch (err) { reject( new AppError( @@ -154,12 +156,15 @@ async function resolveDaemonHttpResult( info: DaemonInfo, req: DaemonWireRequest, result: DaemonResponse, + host: HostEnvironment, resolve: (response: DaemonResponse | PromiseLike) => void, reject: (error: unknown) => void, ): Promise { try { resolve( - info.baseUrl && result.ok ? await materializeRemoteArtifacts(info, req, result) : result, + info.baseUrl && result.ok + ? await materializeRemoteArtifacts(info, req, result, host) + : result, ); } catch (error) { reject(error); diff --git a/src/daemon-client/daemon-client-transport.ts b/src/daemon-client/daemon-client-transport.ts index a9a879287f..490647a700 100644 --- a/src/daemon-client/daemon-client-transport.ts +++ b/src/daemon-client/daemon-client-transport.ts @@ -25,10 +25,13 @@ import { import { buildHttpRpcPayload, handleDaemonHttpResponseBody } from './daemon-client-rpc.ts'; import { handleRequestTimeout } from './daemon-client-timeout.ts'; import { isRemoteDaemon, type DaemonInfo } from './daemon-client-metadata.ts'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { readVersion } from '@agent-device/host-kit/version'; type ResolvedDaemonTransport = 'socket' | 'http'; type SendRequestOptions = { + /** The host whose working directory and environment this request's client-side work reads. */ + host: HostEnvironment; onProgress?: RequestProgressSink; /** * The caller's per-request cancellation (#3178). Aborted at or before a send attempt, nothing @@ -378,7 +381,7 @@ export async function sendRequest( preference: DaemonTransportPreference, statePaths: DaemonPaths, timeoutMs: number | undefined, - options: SendRequestOptions = {}, + options: SendRequestOptions, ): Promise { try { return await sendRequestWithFallback(info, req, preference, statePaths, timeoutMs, options); @@ -402,7 +405,7 @@ async function sendRequestWithFallback( preference: DaemonTransportPreference, statePaths: DaemonPaths, timeoutMs: number | undefined, - options: SendRequestOptions = {}, + options: SendRequestOptions, ): Promise { const transport = chooseTransport(info, preference); const deadline = typeof timeoutMs === 'number' ? performance.now() + timeoutMs : undefined; @@ -777,6 +780,7 @@ async function sendHttpRequest( info, req, stateDir: statePaths.baseDir, + host: options.host, resolve: resolveOnce, reject: rejectOnce, }); diff --git a/src/daemon-client/daemon-client.ts b/src/daemon-client/daemon-client.ts index 49257ab3b0..22d4a5b7a7 100644 --- a/src/daemon-client/daemon-client.ts +++ b/src/daemon-client/daemon-client.ts @@ -146,6 +146,7 @@ async function sendToDaemonOnHost( settings.paths, requestTimeoutMs, { + host, onProgress: options.onProgress, signal: options.signal, timeoutRecovery: @@ -373,6 +374,7 @@ async function protectArtifactUploadWithLeaseBeats( request, info, callerSignal ?? new AbortController().signal, + settings.host, ); } const { buildUploadLeaseHeartbeat, runProtectedLeaseWork } = @@ -386,6 +388,7 @@ async function protectArtifactUploadWithLeaseBeats( request, info, callerSignal ? AbortSignal.any([leaseSignal, callerSignal]) : leaseSignal, + settings.host, ), callerSignal, }); diff --git a/src/daemon-composition.ts b/src/daemon-composition.ts index 60d81b5594..13cc987ce0 100644 --- a/src/daemon-composition.ts +++ b/src/daemon-composition.ts @@ -85,13 +85,14 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( sessionsDir, resolveSessionDir, }); - const hostDiagnostics = createHostDiagnostics(); + const hostDiagnostics = createHostDiagnostics(env); const providerComposition = await createDaemonProviderRuntimeComposition(env); // The registry has set up every plugin. Until this function returns, nothing else owns its // cleanup, so any later failure closes it here; success transfers ownership to the daemon. try { const providerDeviceRuntimes = [...providerComposition.runtimes]; const deviceRuntimeGateway = createPlatformRuntimeGateway({ + env, assertShutdownAllowed, providerRuntimes: providerDeviceRuntimes, providerModules: providerComposition.platformModules, @@ -114,6 +115,7 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( }, }); const deviceInventoryGateways = await createPlatformDeviceInventoryGateways( + env, providerRuntimeProviders.deviceInventorySource, ); // Root composition of the platform-services port and the owner-scoped claim-recovery gateway. @@ -121,7 +123,7 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( // composition modules (and the readiness/open-target adapters only the services module pulls) // off `src/daemon.ts`'s eager closure; what each adapter itself loads stays as lazy as it was. const { createDaemonPlatformServices } = await import('./platform-runtime-daemon-services.ts'); - const { createClaimRecoveryGateway } = await import('./platform-runtime-claim-recovery.ts'); + const { createClaimRecoveryGatewayFor } = await import('./platform-runtime-claim-recovery.ts'); return Object.freeze({ ownedProcessRecords, @@ -135,7 +137,7 @@ export const createDaemonRuntimeDeps: ComposeDaemonRuntimeDeps = async ( recoverableProviderIds: providerRuntimeProviders.recoverableProviderIds, deviceRuntimeGateway, platformResourceCleanup, - claimRecoveryGateway: createClaimRecoveryGateway, + claimRecoveryGateway: createClaimRecoveryGatewayFor(env), interactorResolution: Object.freeze({ resolve: getInteractor }), providerDeviceAdmission: Object.freeze({ isActive: (device: DeviceInfo) => isActiveProviderDevice(device), diff --git a/src/platform-runtime-android-adb-binding.test.ts b/src/platform-runtime-android-adb-binding.test.ts index eb7190993f..6f1fd2c787 100644 --- a/src/platform-runtime-android-adb-binding.test.ts +++ b/src/platform-runtime-android-adb-binding.test.ts @@ -51,7 +51,7 @@ const device: DeviceInfo = { }; test('the composed gateway lists Android apps from inside the platform package', async () => { - const gateway = createPlatformRuntimeGateway(sessionArtifacts); + const gateway = createPlatformRuntimeGateway({ env: process.env, ...sessionArtifacts }); const binding = await gateway.bind({ device, intent: { kind: 'ordinary' }, scope }); await expect(binding.operations.listApps?.({ device, filter: 'all' })).resolves.toEqual([ @@ -76,7 +76,7 @@ test('the composed gateway lists Android apps from inside the platform package', }); test('the composed gateway reads Android app state from inside the platform package', async () => { - const gateway = createPlatformRuntimeGateway(sessionArtifacts); + const gateway = createPlatformRuntimeGateway({ env: process.env, ...sessionArtifacts }); const binding = await gateway.bind({ device, intent: { kind: 'ordinary' }, scope }); await expect(binding.operations.appState?.()).resolves.toEqual({ diff --git a/src/platform-runtime-android-deployment-executor.test.ts b/src/platform-runtime-android-deployment-executor.test.ts index ce97987b7f..221a2d5923 100644 --- a/src/platform-runtime-android-deployment-executor.test.ts +++ b/src/platform-runtime-android-deployment-executor.test.ts @@ -23,7 +23,7 @@ const device: DeviceInfo = { test('keeps root composition limited to artifact preparation and app identity resolution', async () => { resolveAndroidApp.mockResolvedValueOnce({ type: 'package', value: 'com.example.app' }); - const executor = createAndroidAppDeploymentExecutor(); + const executor = createAndroidAppDeploymentExecutor({}); await expect(executor.resolveAppPackage(device, 'Example')).resolves.toBe('com.example.app'); expect(resolveAndroidApp).toHaveBeenCalledWith(device, 'Example', undefined); @@ -42,6 +42,6 @@ test('rejects an intent where uninstall requires an exact Android package', asyn resolveAndroidApp.mockResolvedValueOnce({ type: 'intent', value: 'example.intent' }); await expect( - createAndroidAppDeploymentExecutor().resolveAppPackage(device, 'example.intent'), + createAndroidAppDeploymentExecutor({}).resolveAppPackage(device, 'example.intent'), ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); }); diff --git a/src/platform-runtime-android-deployment-executor.ts b/src/platform-runtime-android-deployment-executor.ts index 45420cd01a..87720ebe90 100644 --- a/src/platform-runtime-android-deployment-executor.ts +++ b/src/platform-runtime-android-deployment-executor.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { AndroidAppDeploymentExecutor, MaterializeAppSourceInput, @@ -5,9 +6,11 @@ import type { import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; /** Android deployment adapter; native command construction remains package-owned. */ -export function createAndroidAppDeploymentExecutor(): AndroidAppDeploymentExecutor { +export function createAndroidAppDeploymentExecutor( + env: Readonly, +): AndroidAppDeploymentExecutor { return Object.freeze({ - bundletoolJar: process.env.AGENT_DEVICE_BUNDLETOOL_JAR?.trim() || undefined, + bundletoolJar: env.AGENT_DEVICE_BUNDLETOOL_JAR?.trim() || undefined, withInvalidatedAppResolutionCache: async (device, operation) => { const { withAndroidAppResolutionCacheInvalidated } = await loadAndroidMechanics(); return await withAndroidAppResolutionCacheInvalidated(device, operation); diff --git a/src/platform-runtime-claim-recovery.ts b/src/platform-runtime-claim-recovery.ts index 82104cd75f..6be124a168 100644 --- a/src/platform-runtime-claim-recovery.ts +++ b/src/platform-runtime-claim-recovery.ts @@ -20,12 +20,15 @@ import { createPlatformRuntimeGateway } from './platform-runtime.ts'; * claims exist only for local devices — and instance-scopes every stateful piece the gateway owns, * so disposal shuts down only this transaction's handles. */ -export const createClaimRecoveryGateway: ClaimRecoveryGatewayFactory = (input) => - createPlatformRuntimeGateway({ - sessionsDir: input.sessionsDir, - ownedProcesses: input.ownedProcesses, - resolveSessionArtifacts: input.resolveSessionArtifacts, - }); +export const createClaimRecoveryGatewayFor = + (env: EnvMap): ClaimRecoveryGatewayFactory => + (input) => + createPlatformRuntimeGateway({ + env, + sessionsDir: input.sessionsDir, + ownedProcesses: input.ownedProcesses, + resolveSessionArtifacts: input.resolveSessionArtifacts, + }); /** * Daemonless `device release --stale`: settles each provably dead owner @@ -44,7 +47,7 @@ export async function runStaleDeviceClaimRelease( env, reconcile: createOwnerScopedDeviceClaimReconciler({ scope: createRecoveryPlatformScope(), - composeGateway: createClaimRecoveryGateway, + composeGateway: createClaimRecoveryGatewayFor(env), composeRecovery, }), }); diff --git a/src/platform-runtime-gateway-gesture-admission.test.ts b/src/platform-runtime-gateway-gesture-admission.test.ts index d51ff59765..2ae0d5212c 100644 --- a/src/platform-runtime-gateway-gesture-admission.test.ts +++ b/src/platform-runtime-gateway-gesture-admission.test.ts @@ -6,6 +6,7 @@ import { createPlatformRuntimeGateway } from './platform-runtime.ts'; bindTestCommandHost(); const gateway = createPlatformRuntimeGateway({ + env: process.env, resolveSessionArtifacts: () => ({ outputPath: '/sessions/gesture-admission/app.log', pidPath: '/sessions/gesture-admission/app-log.pid', diff --git a/src/platform-runtime-gateway-recording.test.ts b/src/platform-runtime-gateway-recording.test.ts index 3e9387c8e4..b36c4a0bf9 100644 --- a/src/platform-runtime-gateway-recording.test.ts +++ b/src/platform-runtime-gateway-recording.test.ts @@ -20,6 +20,7 @@ test.each(unsupportedRecordingDevices)( async (device) => { const sessionsDir = mkdtempForTestSync('platform-runtime-recording-'); const gateway = createPlatformRuntimeGateway({ + env: process.env, sessionsDir, resolveSessionArtifacts: (sessionId) => ({ outputPath: path.join(sessionsDir, sessionId, 'app.log'), diff --git a/src/platform-runtime-host-diagnostics.test.ts b/src/platform-runtime-host-diagnostics.test.ts index 74f5e5b89b..5bd882e3c8 100644 --- a/src/platform-runtime-host-diagnostics.test.ts +++ b/src/platform-runtime-host-diagnostics.test.ts @@ -35,13 +35,15 @@ test('warmup hands an Apple device to the Apple runner warmup and returns its ch }; mockAppleRunnerWarmupCheck.mockResolvedValue(check); - const result = await createHostDiagnostics().warmupCheck(IOS_SIMULATOR, context); + const result = await createHostDiagnostics(process.env).warmupCheck(IOS_SIMULATOR, context); expect(result).toEqual(check); expect(mockAppleRunnerWarmupCheck).toHaveBeenCalledWith(IOS_SIMULATOR, context); }); test('warmup runs no probe for a non-Apple device', async () => { - expect(await createHostDiagnostics().warmupCheck(ANDROID_EMULATOR, context)).toBeUndefined(); + expect( + await createHostDiagnostics(process.env).warmupCheck(ANDROID_EMULATOR, context), + ).toBeUndefined(); expect(mockAppleRunnerWarmupCheck).not.toHaveBeenCalled(); }); diff --git a/src/platform-runtime-host-diagnostics.ts b/src/platform-runtime-host-diagnostics.ts index 35893c5874..799676cecb 100644 --- a/src/platform-runtime-host-diagnostics.ts +++ b/src/platform-runtime-host-diagnostics.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { DeviceInfo, PlatformSelector } from '@agent-device/kernel/device'; import type { DoctorCheck } from '@agent-device/contracts/observability'; import type { @@ -11,7 +12,7 @@ import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; * execution). Families load lazily: a probe module is imported only when its family's method * actually runs, matching the platform registry's implementation-laziness. */ -export function createHostDiagnostics(): HostDiagnostics { +export function createHostDiagnostics(env: Readonly): HostDiagnostics { return Object.freeze({ toolchainCheck: async ( platform: PlatformSelector | undefined, @@ -19,7 +20,7 @@ export function createHostDiagnostics(): HostDiagnostics { ): Promise => { if (platform === 'android') { const { androidToolchainCheck } = await loadAndroidMechanics(); - return await androidToolchainCheck(process.env, process.platform); + return await androidToolchainCheck(env, process.platform); } if (platform === 'vega') { const { vegaToolchainCheck } = await import('@agent-device/platform-vega'); diff --git a/src/platform-runtime-host.test.ts b/src/platform-runtime-host.test.ts index 5b92b9af6e..693e78f07c 100644 --- a/src/platform-runtime-host.test.ts +++ b/src/platform-runtime-host.test.ts @@ -25,7 +25,7 @@ vi.mock('@agent-device/platform-apple/tool-provider', () => { import { createDeviceInventoryHost } from './platform-runtime-host.ts'; test('inventory host loads scoped Apple mechanics only on first Apple tool use', async () => { - const host = createDeviceInventoryHost(); + const host = createDeviceInventoryHost(process.env); const signal = new AbortController().signal; expect(toolchains.appleEvaluations).toBe(0); diff --git a/src/platform-runtime-host.ts b/src/platform-runtime-host.ts index d82922b9b9..2df43a2d2b 100644 --- a/src/platform-runtime-host.ts +++ b/src/platform-runtime-host.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { DeviceInventoryHost, HostOperatingSystem, @@ -14,7 +15,7 @@ import { createHostToolchainPreparer } from './platform-runtime-toolchain-host.t import { runCmd, whichCmd, commandHostEnvironment } from '@agent-device/host-kit/command'; import { guardedHostCommandArgv } from './platform-runtime-host-device-shell.ts'; -export function createDeviceInventoryHost(): DeviceInventoryHost { +export function createDeviceInventoryHost(env: Readonly): DeviceInventoryHost { return Object.freeze({ commands: Object.freeze({ which: async (executable: string) => @@ -35,7 +36,7 @@ export function createDeviceInventoryHost(): DeviceInventoryHost { }, }), appleTools: createAppleToolHost(), - toolchains: createHostToolchainPreparer(), + toolchains: createHostToolchainPreparer(env), files: Object.freeze({ isExecutable: async (candidate: string) => await isExecutable(candidate), createTemporaryTextFile: async (options: { prefix: string; suffix: string }) => diff --git a/src/platform-runtime-operation-host.test.ts b/src/platform-runtime-operation-host.test.ts index 71b67aa164..79169e37a6 100644 --- a/src/platform-runtime-operation-host.test.ts +++ b/src/platform-runtime-operation-host.test.ts @@ -62,6 +62,7 @@ const shutdownLoaders = { test('operation host composes the shared lazy Apple-tool and toolchain capabilities', () => { const host = createPlatformRuntimeHost({ + env: process.env, sessionsDir: '/tmp/sessions', resolveSessionArtifacts: () => ({ outputPath: '/tmp/sessions/one/app.log', diff --git a/src/platform-runtime-operation-host.ts b/src/platform-runtime-operation-host.ts index b03fe1c7ce..59138275c0 100644 --- a/src/platform-runtime-operation-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -1,3 +1,4 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { AppLogSessionArtifacts } from '@agent-device/contracts/app-log-runtime'; import type { DeviceShutdownRuntimeLoaders } from '@agent-device/contracts/device-shutdown-runtime'; import type { @@ -53,6 +54,7 @@ export async function loadMacOsSurfaceSnapshot( } export function createPlatformRuntimeHost(options: { + env: Readonly; sessionsDir: string; resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; shutdownLoaders: DeviceShutdownRuntimeLoaders; @@ -92,7 +94,7 @@ export function createPlatformRuntimeHost(options: { return Object.freeze({ commands, appleTools, - toolchains: createHostToolchainPreparer(), + toolchains: createHostToolchainPreparer(options.env), artifacts: Object.freeze({ resolveSession: options.resolveSessionArtifacts }), outputs: Object.freeze({ openAppend: async (pathname: string) => await openAppLogOutput(options.sessionsDir, pathname), @@ -114,7 +116,7 @@ export function createPlatformRuntimeHost(options: { }), ...network, appleDeployment: createAppleAppDeploymentExecutor(), - androidDeployment: createAndroidAppDeploymentExecutor(), + androidDeployment: createAndroidAppDeploymentExecutor(options.env), androidTools: createAndroidToolHost(), temporaryFiles: Object.freeze({ create: async (temporaryFileOptions: { prefix: string; suffix: string }) => diff --git a/src/platform-runtime-perf-host.test.ts b/src/platform-runtime-perf-host.test.ts index 0415ba765b..7f75339e7e 100644 --- a/src/platform-runtime-perf-host.test.ts +++ b/src/platform-runtime-perf-host.test.ts @@ -47,6 +47,7 @@ test('the composed Android perf operation reports an unavailable ADB memory samp adb.calls.length = 0; const sessionsDir = mkdtempForTestSync('platform-runtime-perf-host-'); const gateway = createPlatformRuntimeGateway({ + env: process.env, sessionsDir, resolveSessionArtifacts: (sessionId) => ({ outputPath: `${sessionsDir}/${sessionId}/app.log`, diff --git a/src/platform-runtime-toolchain-host.test.ts b/src/platform-runtime-toolchain-host.test.ts index bf2d9f77c1..a6b2ec4e1c 100644 --- a/src/platform-runtime-toolchain-host.test.ts +++ b/src/platform-runtime-toolchain-host.test.ts @@ -28,7 +28,7 @@ vi.mock('@agent-device/platform-harmonyos', () => { import { createHostToolchainPreparer } from './platform-runtime-toolchain-host.ts'; test('toolchain host loads and prepares only the selected family', async () => { - const host = createHostToolchainPreparer(); + const host = createHostToolchainPreparer(process.env); expect(toolchains).toEqual({ androidEvaluations: 0, diff --git a/src/platform-runtime-toolchain-host.ts b/src/platform-runtime-toolchain-host.ts index 85c4d70520..011b44a646 100644 --- a/src/platform-runtime-toolchain-host.ts +++ b/src/platform-runtime-toolchain-host.ts @@ -1,12 +1,13 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import type { HostToolchainPreparer } from '@agent-device/contracts/platform-runtime-host'; import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; -export function createHostToolchainPreparer(): HostToolchainPreparer { +export function createHostToolchainPreparer(env: Readonly): HostToolchainPreparer { return Object.freeze({ prepare: async (family) => { if (family === 'android') { const { ensureAndroidSdkPathConfigured } = await loadAndroidMechanics(); - await ensureAndroidSdkPathConfigured(process.env); + await ensureAndroidSdkPathConfigured(env); return; } if (family === 'harmonyos') { diff --git a/src/platform-runtime.test.ts b/src/platform-runtime.test.ts index 373be8b3a8..bbeb6075d3 100644 --- a/src/platform-runtime.test.ts +++ b/src/platform-runtime.test.ts @@ -1,3 +1,4 @@ +import { bindTestCommandHost } from '@agent-device/test-kit'; import { PLATFORMS } from '@agent-device/kernel/device'; import { describe, expect, test, vi } from 'vitest'; @@ -32,9 +33,13 @@ vi.mock('./platform-runtime-host.ts', () => { import { createPlatformDeviceInventoryGateways, - platformModuleRegistry, + createPlatformModuleRegistryForHost, } from './platform-runtime.ts'; +bindTestCommandHost(); + +const platformModuleRegistry = createPlatformModuleRegistryForHost({}); + describe('platform module metadata composition', () => { test('registers each canonical family exactly once in canonical order', () => { expect(platformModuleRegistry.families).toEqual(PLATFORMS); @@ -61,7 +66,7 @@ describe('platform module metadata composition', () => { diagnostics: { emit: () => {} }, progress: { report: () => {} }, } as const; - const providerGateways = await createPlatformDeviceInventoryGateways({ + const providerGateways = await createPlatformDeviceInventoryGateways(process.env, { discover: async () => ({ kind: 'inventory', devices: [ @@ -81,7 +86,7 @@ describe('platform module metadata composition', () => { ); expect(hostAdapter.evaluations).toBe(0); - const localGateways = await createPlatformDeviceInventoryGateways(); + const localGateways = await createPlatformDeviceInventoryGateways(process.env); expect(await localGateways.localOnly.discover({ platform: 'web' }, scope)).toHaveLength(1); expect(hostAdapter.evaluations).toBe(0); diff --git a/src/platform-runtime.ts b/src/platform-runtime.ts index d58deb05a0..7eb2d03ed0 100644 --- a/src/platform-runtime.ts +++ b/src/platform-runtime.ts @@ -70,10 +70,6 @@ export async function getAndroidAppStateWithAdb( return await readAndroidAppStateWithExecutor(adb, signal); } -const androidInventoryModule = createAndroidInventoryModule({ - sdkRoots: configuredValues(process.env.ANDROID_SDK_ROOT, process.env.ANDROID_HOME), -}); - /** One root-composed Android observer shared by every daemon request. */ export const androidObservation = createPackageAndroidObservationAdapter( createAndroidObservationHost(), @@ -86,29 +82,34 @@ const shutdownLoaders = Object.freeze({ await loadAndroidShutdownRuntime(dependencies), }); -const harmonyosInventoryModule = createHarmonyInventoryModule({ - hdcSdkPath: process.env.HDC_SDK_PATH, - devecoSdkHome: process.env.DEVECO_SDK_HOME, - commandLineToolsHome: process.env.HARMONYOS_COMMAND_LINE_TOOLS, -}); - -export const platformModuleRegistry = createPlatformModuleRegistry([ - appleInventoryModule, - androidInventoryModule, - harmonyosInventoryModule, - vegaInventoryModule, - linuxInventoryModule, - webInventoryModule, -]); +export function createPlatformModuleRegistryForHost( + env: Readonly>, +) { + return createPlatformModuleRegistry([ + appleInventoryModule, + createAndroidInventoryModule({ + sdkRoots: configuredValues(env.ANDROID_SDK_ROOT, env.ANDROID_HOME), + }), + createHarmonyInventoryModule({ + hdcSdkPath: env.HDC_SDK_PATH, + devecoSdkHome: env.DEVECO_SDK_HOME, + commandLineToolsHome: env.HARMONYOS_COMMAND_LINE_TOOLS, + }), + vegaInventoryModule, + linuxInventoryModule, + webInventoryModule, + ]); +} export async function createPlatformDeviceInventoryGateways( + env: Readonly>, provider?: ProviderDeviceInventorySource, ): Promise { return await attachAppleInstalledAppProbe({ - registry: platformModuleRegistry, + registry: createPlatformModuleRegistryForHost(env), loadHost: async () => { const { createDeviceInventoryHost } = await import('./platform-runtime-host.ts'); - return createDeviceInventoryHost(); + return createDeviceInventoryHost(env); }, provider, }); @@ -121,8 +122,8 @@ const androidRuntimeModule = createAndroidRuntimeModule({ }, }); -/** The root composition registry shared by the gateway and bounded host-contract fixtures. */ -export const platformRuntimeModules: ReadonlyMap = new Map< +/** The root composition registry the platform runtime gateway reads. */ +const platformRuntimeModules: ReadonlyMap = new Map< Platform, PlatformRuntimeModule >([ @@ -138,6 +139,7 @@ type Platform = PlatformRuntimeModule['family']; export function createPlatformRuntimeGateway( options: Readonly<{ + env: Readonly>; providerRuntimes?: readonly ProviderDeviceRuntime[]; providerModules?: readonly PlatformRuntimeProviderRegistration[]; resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; @@ -152,6 +154,7 @@ export function createPlatformRuntimeGateway( const { createPlatformRuntimeHost, createSnapshotRuntimeHost, loadMacOsSurfaceSnapshot } = await import('./platform-runtime-operation-host.ts'); return createPlatformRuntimeHost({ + env: options.env, sessionsDir: options.sessionsDir, resolveSessionArtifacts: options.resolveSessionArtifacts, shutdownLoaders, diff --git a/src/sdk/remote-config.test.ts b/src/sdk/remote-config.test.ts new file mode 100644 index 0000000000..0d10d35e3c --- /dev/null +++ b/src/sdk/remote-config.test.ts @@ -0,0 +1,24 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { resolveRemoteConfigProfile } from './remote-config.ts'; + +test('the released remote-config entry reads the process environment when none is given', () => { + const root = mkdtempForTestSync('agent-device-sdk-remote-config-'); + const previous = process.env.AGENT_DEVICE_METRO_PREPARE_PORT; + try { + const configPath = path.join(root, 'demo.remote.json'); + fs.writeFileSync(configPath, JSON.stringify({ platform: 'ios' }), 'utf8'); + process.env.AGENT_DEVICE_METRO_PREPARE_PORT = '9191'; + + const resolved = resolveRemoteConfigProfile({ configPath, cwd: root }); + + assert.equal(resolved.profile.metroPreparePort, 9191); + } finally { + if (previous === undefined) delete process.env.AGENT_DEVICE_METRO_PREPARE_PORT; + else process.env.AGENT_DEVICE_METRO_PREPARE_PORT = previous; + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/sdk/remote-config.ts b/src/sdk/remote-config.ts index 315e44a2a2..29097df6c6 100644 --- a/src/sdk/remote-config.ts +++ b/src/sdk/remote-config.ts @@ -1,2 +1,14 @@ +import { resolveRemoteConfigProfile as resolveRemoteConfigProfileOnHost } from '@agent-device/remote/remote-config'; +import type { + RemoteConfigProfileOptions, + ResolvedRemoteConfigProfile, +} from '@agent-device/remote/remote-config-schema'; + export type { RemoteConfigProfile } from '@agent-device/remote/remote-config-schema'; -export { resolveRemoteConfigProfile } from '@agent-device/remote/remote-config'; + +/** The released entry: without `env`, the profile resolves against the process's environment. */ +export function resolveRemoteConfigProfile( + options: RemoteConfigProfileOptions, +): ResolvedRemoteConfigProfile { + return resolveRemoteConfigProfileOnHost({ ...options, env: options.env ?? process.env }); +} diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 7d2505f5a3..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, @@ -240,6 +241,7 @@ export async function createProviderScenarioHarness( }); return await new Promise((resolve, reject) => { handleDaemonHttpResponseBody(body, { + host: createTestHostEnvironment(), info: { transport: 'http', httpPort, diff --git a/test/integration/provider-scenarios/linux-world.ts b/test/integration/provider-scenarios/linux-world.ts index 9c9d40c91c..f6ccd15ee3 100644 --- a/test/integration/provider-scenarios/linux-world.ts +++ b/test/integration/provider-scenarios/linux-world.ts @@ -161,7 +161,7 @@ const inventoryScope: PlatformRequestScope = Object.freeze({ }); async function discoverLocalLinuxInventory(): Promise { - const source = await linuxInventoryModule.loadInventory(createDeviceInventoryHost()); + const source = await linuxInventoryModule.loadInventory(createDeviceInventoryHost(process.env)); return [...(await source.discover({ platform: 'linux' }, inventoryScope))]; } diff --git a/test/integration/provider-scenarios/remote-daemon-client.test.ts b/test/integration/provider-scenarios/remote-daemon-client.test.ts index 77b7ccd1b6..c6153e8f74 100644 --- a/test/integration/provider-scenarios/remote-daemon-client.test.ts +++ b/test/integration/provider-scenarios/remote-daemon-client.test.ts @@ -12,6 +12,7 @@ import { closeLoopbackServer, listenOnLoopback, skipWhenLoopbackUnavailable, + createTestHostEnvironment, } from '@agent-device/test-kit'; const NO_CANCELLATION = new AbortController().signal; @@ -681,6 +682,7 @@ test('remote web recording defaults client and daemon artifact paths to WebM', a }, { baseUrl: 'http://127.0.0.1:1', token: 'remote-token' }, NO_CANCELLATION, + createTestHostEnvironment(), ); assert.equal(prepared.positionals[0], 'start'); @@ -702,6 +704,7 @@ test('remote web recording appends WebM extension to extensionless client paths' }, { baseUrl: 'http://127.0.0.1:1', token: 'remote-token' }, NO_CANCELLATION, + createTestHostEnvironment(), ); assert.equal(prepared.positionals[0], 'start'); @@ -720,6 +723,7 @@ test('remote recording without platform or requested path lets daemon name the c }, { baseUrl: 'http://127.0.0.1:1', token: 'remote-token' }, NO_CANCELLATION, + createTestHostEnvironment(), ); assert.deepEqual(prepared.positionals, ['start']); diff --git a/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts b/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts index 6cdb73743d..afcd3dc2ac 100644 --- a/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts +++ b/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts @@ -9,7 +9,7 @@ import { downloadRemoteArtifact, materializeRemoteArtifacts, } from '@agent-device/remote/daemon-artifacts'; -import { skipWhenLoopbackUnavailable } from '@agent-device/test-kit'; +import { skipWhenLoopbackUnavailable, createTestHostEnvironment } from '@agent-device/test-kit'; import { withProviderScenarioResource, withProviderScenarioTempDir } from './harness.ts'; import { createProviderScenarioProxy } from './harness-proxy.ts'; import { FAKE_PROVIDER, createFakeProviderWorld } from './provider-device-runtime.fixtures.ts'; @@ -151,6 +151,7 @@ test('Provider-backed integration proxy materializes tenant-owned test artifacts { baseUrl: proxy.baseUrl, token: TENANT_TOKEN }, request, response, + createTestHostEnvironment(), ); assert.equal(materialized.ok, true); if (!materialized.ok) return; diff --git a/test/integration/provider-scenarios/remote-test-artifacts-materialization.test.ts b/test/integration/provider-scenarios/remote-test-artifacts-materialization.test.ts index a54d6eeed0..fef49e562f 100644 --- a/test/integration/provider-scenarios/remote-test-artifacts-materialization.test.ts +++ b/test/integration/provider-scenarios/remote-test-artifacts-materialization.test.ts @@ -14,6 +14,7 @@ import { listenOnLoopback, mkdtempForTestSync, skipWhenLoopbackUnavailable, + createTestHostEnvironment, } from '@agent-device/test-kit'; import { createProviderScenarioHarness, @@ -106,6 +107,7 @@ test('the composed daemon and client path materializes the directory produced by { baseUrl: proxy.baseUrl, token: PROXY_TOKEN }, request, response, + createTestHostEnvironment(), ); assert.equal(materialized.ok, true); if (!materialized.ok) return; diff --git a/test/integration/smoke-tvos-platform-coverage.test.ts b/test/integration/smoke-tvos-platform-coverage.test.ts index f3dfc59592..c268c20cad 100644 --- a/test/integration/smoke-tvos-platform-coverage.test.ts +++ b/test/integration/smoke-tvos-platform-coverage.test.ts @@ -83,6 +83,7 @@ test('tvOS gesture contract preserves the typed multi-touch denial', async () => // R42: the denial is now the Apple owner's own fact for the tvOS leaf, and the refusal a // gesture reports is that fact's hint under the shared wording. const facts = await createPlatformRuntimeGateway({ + env: process.env, resolveSessionArtifacts: () => ({ outputPath: '/sessions/tvos/app.log', pidPath: '/sessions/tvos/app-log.pid', diff --git a/test/wire-compat/closure-policy.ts b/test/wire-compat/closure-policy.ts index c818236106..16e892b2b9 100644 --- a/test/wire-compat/closure-policy.ts +++ b/test/wire-compat/closure-policy.ts @@ -35,6 +35,8 @@ const WIRE_EXTERNAL_MODULES: Readonly> = { * cannot change what a skewed peer parses. */ export const WIRE_CLOSURE_WAIVERS: Readonly> = { + 'packages/kernel/src/source-value.ts#HostEnvironment': + "The client process's own environment and working directory, read to resolve local paths. It never crosses the wire; only the paths it helps resolve do, and those are digested through the declarations that use them.", 'packages/kernel/src/errors.ts#AppError': 'The local error CLASS. Only its projection onto the wire is contractual, and that projection is DaemonError, which is listed.', 'packages/kernel/src/errors.ts#AppErrorCode': diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 57c6410b8d..152888f953 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -59,12 +59,12 @@ "src/daemon-client/daemon-client-rpc.ts#appErrorFromDaemonError": "sha256:ac4761006c71d93ccba9f8e37cbd4cf48283dbb22737c163dc9c1a878e154eea", "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload": "sha256:bbcf00e71d3975caba4add0e85537396b5831796f1af52cc8c62a4688882ebd7", "src/daemon-client/daemon-client-rpc.ts#buildLeaseRpcParams": "sha256:365490a0fe1833d7ce8b0d3f5425462608a85119b12c1a2c4f16bb83b5d28e06", - "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody": "sha256:8d5849a4e573d6bb8fbd4ee0259755b76116a65f4abe41705cf84bfab5834ce4", + "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody": "sha256:7f2913988208ce42c2f03b4c3124df25e3f887a4f987fbe448d7a6298f815c23", "src/daemon-client/daemon-client-rpc.ts#isLeaseRpcCommand": "sha256:955dc1b73462d40593e5728dbcbaeec1307944b08262a22627e84cc5dc507e8f", "src/daemon-client/daemon-client-rpc.ts#leaseRpcMethodForCommand": "sha256:3613555c6002c23ab5bec4b98bc1b4d6947014b52fa100176177f955cf762881", "src/daemon-client/daemon-client-rpc.ts#parseDaemonHttpResponseBody": "sha256:0cbe39dbd09e321ec00854a1626a7d89b6344e59c6faabe2fe02504268b2d886", "src/daemon-client/daemon-client-rpc.ts#rejectDaemonHttpRpcError": "sha256:a5cf6fd4a9abea34ec051f03d6e45e5aa248f480371fdf22f93de06622a57b47", - "src/daemon-client/daemon-client-rpc.ts#resolveDaemonHttpResult": "sha256:9432fffc59c11ff7ccf8c9ce7076559e5c043264045737f24cc4b8e67d76c3d7", + "src/daemon-client/daemon-client-rpc.ts#resolveDaemonHttpResult": "sha256:64dff5e16136cbc69f4692c64ea0ea55465155909c4da112ea1356da4fecf5e4", "src/daemon-client/daemon-client-rpc.ts#toDaemonHttpRpcError": "sha256:888246763c48670e7da893054d025744654f8715c3b4906312617a2b5028316b", "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealth": "sha256:3bac36fa97090b273afe1128103e1bf476d05441fd9de41317f1b78775b88304", "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealthLink": "sha256:7702598468b4c82b4ffa93064cd6bdfec938c1c527f7e6007c0584f3884a6eea", @@ -151,8 +151,8 @@ "packages/remote/src/daemon-artifacts.ts#buildDaemonArtifactUrl": "sha256:06dcde057809b507f70f7bb5cdef9bde6515be84b67e45865cf19e70b8636208", "packages/remote/src/daemon-artifacts.ts#downloadRemoteArtifact": "sha256:f3ab09ce387577073ecc5bcd2528963e45f91b2c1e4cc57847c3e2732ccbb300", "packages/remote/src/daemon-artifacts.ts#isRemoteDaemon": "sha256:0f6a714c5f60ce4e8548d811b516d8b6241eca9f7e5414733732da395b8a00e7", - "packages/remote/src/daemon-artifacts.ts#materializeRemoteArtifacts": "sha256:c3ea70cc9a218acfaa30f6c4ba55b80224c96aba10e4e9c7a17828fc851de77e", - "packages/remote/src/daemon-artifacts.ts#resolveMaterializedArtifactPath": "sha256:cad37beddeb03ba70bc92118a5e45f552edfc00feec789758e390d1ea4c7d879", + "packages/remote/src/daemon-artifacts.ts#materializeRemoteArtifacts": "sha256:fffe8ce578e8bcdea175d6a200bea364ffce0956c2cba65bea48d72c0aaea375", + "packages/remote/src/daemon-artifacts.ts#resolveMaterializedArtifactPath": "sha256:0cb422301edef0545615b0f3acb3d80280213921cab2eb529715260ff0fa3647", "packages/remote/src/remote-request-diagnostics.ts#RemoteDaemonErrorPayload": "sha256:8fbcbcdf4bae7b66a08e465a72593794f36364759f18f1a814cd39dfdb76f309", "packages/remote/src/remote-request-diagnostics.ts#RemoteDiagnosticsEndpoint": "sha256:c1d5fe2fe90c425502cf8b7c87d6606374d6af65b4e9faf942a416f683930bdc", "packages/remote/src/remote-request-diagnostics.ts#buildRemoteRequestDiagnosticsUrl": "sha256:30a1a9bf0df24d3564a616699916a3bb4360c2b4d21c4ad0562fb0c1e738d0a9", @@ -216,8 +216,8 @@ }, { "declaration": "packages/remote/src/daemon-artifacts.ts#materializeRemoteArtifacts", - "digest": "sha256:c3ea70cc9a218acfaa30f6c4ba55b80224c96aba10e4e9c7a17828fc851de77e", - "rationale": "#2246 passes isDirectory only for the new test-artifacts type and uses the exact path the client materializer published. Existing screenshot and recording artifacts still report their requested file path; only the new directory response gains its suite-invocation segment. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved." + "digest": "sha256:fffe8ce578e8bcdea175d6a200bea364ffce0956c2cba65bea48d72c0aaea375", + "rationale": "#2246 passes isDirectory only for the new test-artifacts type and uses the exact path the client materializer published. Existing screenshot and recording artifacts still report their requested file path; only the new directory response gains its suite-invocation segment. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved. The function now takes the client host as a parameter and resolves a relative local path against its working directory instead of the process's; the client binds the same directory, so nothing a peer parses moves." }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload", @@ -241,8 +241,8 @@ }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody", - "digest": "sha256:8d5849a4e573d6bb8fbd4ee0259755b76116a65f4abe41705cf84bfab5834ce4", - "rationale": "#1801 client-side only: the RPC error branch now runs through `rejectDaemonHttpRpcError`, which reads a superset of the fields it read before and, for a remote daemon, replaces the daemon-host `logPath` with a caller-local copy. Every field a released daemon sends is still accepted; nothing new is required of the daemon. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved." + "digest": "sha256:7f2913988208ce42c2f03b4c3124df25e3f887a4f987fbe448d7a6298f815c23", + "rationale": "#1801 client-side only: the RPC error branch now runs through `rejectDaemonHttpRpcError`, which reads a superset of the fields it read before and, for a remote daemon, replaces the daemon-host `logPath` with a caller-local copy. Every field a released daemon sends is still accepted; nothing new is required of the daemon. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved. The function now takes the client host as a parameter and resolves a relative local path against its working directory instead of the process's; the client binds the same directory, so nothing a peer parses moves." }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#toDaemonHttpRpcError", @@ -471,13 +471,13 @@ }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#resolveDaemonHttpResult", - "digest": "sha256:9432fffc59c11ff7ccf8c9ce7076559e5c043264045737f24cc4b8e67d76c3d7", - "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did." + "digest": "sha256:64dff5e16136cbc69f4692c64ea0ea55465155909c4da112ea1356da4fecf5e4", + "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did. The function now takes the client host as a parameter and resolves a relative local path against its working directory instead of the process's; the client binds the same directory, so nothing a peer parses moves." }, { "declaration": "packages/remote/src/daemon-artifacts.ts#resolveMaterializedArtifactPath", - "digest": "sha256:cad37beddeb03ba70bc92118a5e45f552edfc00feec789758e390d1ea4c7d879", - "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did." + "digest": "sha256:0cb422301edef0545615b0f3acb3d80280213921cab2eb529715260ff0fa3647", + "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did. The function now takes the client host as a parameter and resolves a relative local path against its working directory instead of the process's; the client binds the same directory, so nothing a peer parses moves." } ] }