diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 8c7df8b941..e4901d9479 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -162,7 +162,7 @@ export function createAgentDeviceClient( command: DaemonCommandName, options: InternalRequestOptions = {}, ): Promise => { - const request = await prepareDaemonCommandRequest(command, options); + const request = await prepareDaemonCommandRequest(command, options, host); return (await execute( request.command, request.positionals, diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index 4cfb6fe3c8..7d9923c3d7 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -106,7 +106,7 @@ export function renderProxyStartup( startup: ProxyStartup, options: { useColor?: boolean } = {}, ): string { - const useColor = options.useColor ?? supportsColor(); + const useColor = options.useColor ?? supportsColor(process.env); const checkmark = formatProxyOutputValue('✓', 'green', useColor); const proxyBaseUrl = formatProxyOutputValue(startup.proxyBaseUrl, 'cyan', useColor); const daemonBaseUrl = formatProxyOutputValue('', 'cyan', useColor); diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts index d7df8b9300..8b03b91941 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -52,7 +52,7 @@ export const diffCommand: ClientCommandHandler = async ({ positionals, flags, cl flags, env: host.env, }); - await writeCommandOutput(flags, result, () => formatSnapshotDiffText(result)); + await writeCommandOutput(flags, result, () => formatSnapshotDiffText(result, host.env)); return true; } @@ -104,7 +104,7 @@ export const diffCommand: ClientCommandHandler = async ({ positionals, flags, cl }); await writeCommandOutput(flags, result, () => - formatScreenshotDiffText(result as ScreenshotDiffResult), + formatScreenshotDiffText(result as ScreenshotDiffResult, host), ); return true; }; diff --git a/src/cli/replay-test/progress.ts b/src/cli/replay-test/progress.ts index 54b3a18e7a..5ce6c8cf3c 100644 --- a/src/cli/replay-test/progress.ts +++ b/src/cli/replay-test/progress.ts @@ -130,7 +130,7 @@ function formatReplayTestLiveProgressLine( ): string { const title = event.title?.trim(); const file = path.basename(event.file); - const useColor = supportsColor(process.stderr); + const useColor = supportsColor(process.env, process.stderr); const spinner = formatReplayTestProgressSpinner(spinnerFrame, { useColor }); const shardSuffix = formatReplayTestProgressShardSuffix(event, { useColor }); const stepSuffix = formatReplayTestLiveProgressStepSuffix(event, { useColor }); @@ -223,7 +223,7 @@ function replayTestProgressFailureContextLines(event: ReplayTestResult): string[ } function formatReplayTestCaseSummaryLine(event: ReplayTestResult): string { - const useColor = supportsColor(process.stderr); + const useColor = supportsColor(process.env, process.stderr); const statusLabel = formatReplayTestProgressStatusLabel(event); const name = formatReplayTestProgressName(event); const shardSuffix = formatReplayTestProgressShardSuffix(event, { useColor }); diff --git a/src/cli/replay-test/reporters/default.ts b/src/cli/replay-test/reporters/default.ts index a8e4395ded..3aa2278a5e 100644 --- a/src/cli/replay-test/reporters/default.ts +++ b/src/cli/replay-test/reporters/default.ts @@ -143,7 +143,7 @@ function renderWarningsSection( function formatReplayTestSummaryLine(data: ReplaySuiteResult, flakyCount: number): string { const durationMs = typeof data.durationMs === 'number' ? data.durationMs : undefined; - const useColor = supportsColor(); + const useColor = supportsColor(process.env); const passed = formatReplaySummaryPassed(data.passed, { useColor }); const total = formatReplaySummaryTotal(data.total, { useColor }); const failedSuffix = data.failed > 0 ? `, ${data.failed} failed` : ''; @@ -169,7 +169,7 @@ function formatReplayDuration(durationMs: number, options: { useColor?: boolean } function replayFlakyStatusIcon(): string { - const useColor = supportsColor(); + const useColor = supportsColor(process.env); return useColor ? colorize('✓', 'yellow') : '✓'; } diff --git a/src/commands/batch/projection.ts b/src/commands/batch/projection.ts index 5343eb52f1..0eb6f40381 100644 --- a/src/commands/batch/projection.ts +++ b/src/commands/batch/projection.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { STRUCTURED_BATCH_COMMAND_NAMES, @@ -27,15 +28,24 @@ type PrepareDaemonCommandRequest = ( command: string, input: CommandInput, stepNumber: number, + host: HostEnvironment, +) => Promise; + +type PrepareBatchStep = ( + command: string, + input: CommandInput, + stepNumber: number, ) => Promise; export function createBatchDaemonWriter( prepareDaemonCommandRequest: PrepareDaemonCommandRequest, ): AsyncDaemonWriter { - return async (input) => + return async (input, host) => request(PUBLIC_COMMANDS.batch, [], { ...input, - batchSteps: await readBatchDaemonSteps(input.steps, prepareDaemonCommandRequest), + batchSteps: await readBatchDaemonSteps(input.steps, (command, input, stepNumber) => + prepareDaemonCommandRequest(command, input, stepNumber, host), + ), batchOnError: input.onError, batchMaxSteps: input.maxSteps, }); @@ -43,7 +53,7 @@ export function createBatchDaemonWriter( async function readBatchDaemonSteps( steps: unknown, - prepareDaemonCommandRequest: PrepareDaemonCommandRequest, + prepareDaemonCommandRequest: PrepareBatchStep, ): Promise { if (!Array.isArray(steps) || steps.length === 0) { throw new AppError('INVALID_ARGS', 'batch requires a non-empty steps array.'); @@ -60,7 +70,7 @@ async function readBatchDaemonSteps( async function readBatchDaemonStep( step: unknown, stepNumber: number, - prepareDaemonCommandRequest: PrepareDaemonCommandRequest, + prepareDaemonCommandRequest: PrepareBatchStep, ): Promise { const record = readBatchStepRecord(step, stepNumber); const command = readBatchStepCommand(record, stepNumber); diff --git a/src/commands/cli-grammar/types.ts b/src/commands/cli-grammar/types.ts index fc2d3414ae..1c6bce599c 100644 --- a/src/commands/cli-grammar/types.ts +++ b/src/commands/cli-grammar/types.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { CliFlags, CommandFlags, @@ -97,6 +98,9 @@ export type DaemonWriter = (input: CommandInput) => DaemonCommandRequest; * `runFlow` includes on demand. The registry awaits whichever kind a command declares, so the * asynchrony stays inside the one writer that needs it. */ -export type AsyncDaemonWriter = (input: CommandInput) => Promise; +export type AsyncDaemonWriter = ( + input: CommandInput, + host: HostEnvironment, +) => Promise; export type AnyDaemonWriter = DaemonWriter | AsyncDaemonWriter; diff --git a/src/commands/command-projection.ts b/src/commands/command-projection.ts index 1e9aba520e..354f464c04 100644 --- a/src/commands/command-projection.ts +++ b/src/commands/command-projection.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { createBatchDaemonWriter } from './batch/index.ts'; import type { AnyDaemonWriter, CommandInput, DaemonCommandRequest } from './cli-grammar/types.ts'; import { findCommandMetadata } from './command-metadata.ts'; @@ -17,6 +18,7 @@ async function prepareBatchDaemonCommandRequest( command: string, input: CommandInput, stepNumber: number, + host: HostEnvironment, ): Promise { const writer = (daemonWriters as Readonly>)[command]; if (!writer) { @@ -31,6 +33,7 @@ async function prepareBatchDaemonCommandRequest( writer, metadata, metadata.readInput(input) as CommandInput, + host, ); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -46,6 +49,7 @@ async function prepareBatchDaemonCommandRequest( export async function prepareDaemonCommandRequest( command: DaemonCommandName, input: CommandInput, + host: HostEnvironment, ): Promise { const writer = daemonWriters[command]; if (!writer) { @@ -53,15 +57,16 @@ export async function prepareDaemonCommandRequest( } refuseRetiredCommonInput(input as Record); const metadata = findCommandMetadata(command); - return await prepareRequestWithMetadataFlags(writer, metadata, input); + return await prepareRequestWithMetadataFlags(writer, metadata, input, host); } async function prepareRequestWithMetadataFlags( writer: AnyDaemonWriter, metadata: ReturnType, input: CommandInput, + host: HostEnvironment, ): Promise { - const request = await writer(input); + const request = await writer(input, host); return { ...request, ...(metadata ? { metadataFlags: readMetadataCommandFlags(metadata, request.options) } : {}), diff --git a/src/commands/output/color.test.ts b/src/commands/output/color.test.ts index d38815bc1f..c1f0daf5a6 100644 --- a/src/commands/output/color.test.ts +++ b/src/commands/output/color.test.ts @@ -9,8 +9,8 @@ test('supportsColor uses the stream TTY state by default', () => { delete process.env.FORCE_COLOR; delete process.env.NO_COLOR; try { - assert.equal(supportsColor({ isTTY: true }), true); - assert.equal(supportsColor({ isTTY: false }), false); + assert.equal(supportsColor(process.env, { isTTY: true }), true); + assert.equal(supportsColor(process.env, { isTTY: false }), false); } finally { if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor; else delete process.env.FORCE_COLOR; @@ -25,14 +25,14 @@ test('supportsColor honors explicit environment overrides', () => { try { process.env.FORCE_COLOR = '0'; delete process.env.NO_COLOR; - assert.equal(supportsColor({ isTTY: true }), false); + assert.equal(supportsColor(process.env, { isTTY: true }), false); process.env.FORCE_COLOR = '1'; - assert.equal(supportsColor({ isTTY: false }), true); + assert.equal(supportsColor(process.env, { isTTY: false }), true); delete process.env.FORCE_COLOR; process.env.NO_COLOR = '1'; - assert.equal(supportsColor({ isTTY: true }), false); + assert.equal(supportsColor(process.env, { isTTY: true }), false); } finally { if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor; else delete process.env.FORCE_COLOR; diff --git a/src/commands/output/color.ts b/src/commands/output/color.ts index eca1eefec2..a222038bca 100644 --- a/src/commands/output/color.ts +++ b/src/commands/output/color.ts @@ -1,11 +1,12 @@ +import type { EnvMap } from '@agent-device/kernel/source-value'; import { styleText } from 'node:util'; -export function supportsColor(stream: { isTTY?: boolean } = process.stdout): boolean { - const forceColor = process.env.FORCE_COLOR; +export function supportsColor(env: EnvMap, stream: { isTTY?: boolean } = process.stdout): boolean { + const forceColor = env.FORCE_COLOR; if (typeof forceColor === 'string') { return forceColor !== '0'; } - if (typeof process.env.NO_COLOR === 'string') { + if (typeof env.NO_COLOR === 'string') { return false; } return Boolean(stream.isTTY); diff --git a/src/commands/output/diff.test.ts b/src/commands/output/diff.test.ts index d26a7584ad..e2097fbc1a 100644 --- a/src/commands/output/diff.test.ts +++ b/src/commands/output/diff.test.ts @@ -23,7 +23,7 @@ test('formatSnapshotDiffText renders plain text when color is disabled', () => { process.env.FORCE_COLOR = '0'; delete process.env.NO_COLOR; try { - const text = formatSnapshotDiffText({ ...DIFF_DATA }); + const text = formatSnapshotDiffText({ ...DIFF_DATA }, process.env); assert.match(text, /^@e2 \[window\]/m); assert.match(text, /^- {2}@e3 \[text\] "67"$/m); assert.match(text, /^\+ {2}@e3 \[text\] "134"$/m); @@ -38,7 +38,7 @@ test('formatSnapshotDiffText renders plain text when color is disabled', () => { }); test('formatSnapshotDiffText renders ANSI colors when forced', () => { - const text = withColor(() => formatSnapshotDiffText({ ...DIFF_DATA })); + const text = withColor(() => formatSnapshotDiffText({ ...DIFF_DATA }, process.env)); const plainText = stripVTControlCharacters(text); assert.notEqual(text, plainText); assert.match(plainText, /^@e2 \[window\]/m); @@ -49,10 +49,13 @@ test('formatSnapshotDiffText renders ANSI colors when forced', () => { test('formatSnapshotDiffText prints warnings before the diff body', () => { const text = withNoColor(() => - formatSnapshotDiffText({ - ...DIFF_DATA, - warnings: ['Recent press was followed by a nearly identical snapshot.'], - }), + formatSnapshotDiffText( + { + ...DIFF_DATA, + warnings: ['Recent press was followed by a nearly identical snapshot.'], + }, + process.env, + ), ); assert.match(text, /^Recent press was followed by a nearly identical snapshot\.$/m); assert.match(text, /^@e2 \[window\]$/m); @@ -61,12 +64,15 @@ test('formatSnapshotDiffText prints warnings before the diff body', () => { test('formatScreenshotDiffText renders match success without color', () => { const text = withNoColor(() => - formatScreenshotDiffText({ - match: true, - differentPixels: 0, - totalPixels: 100, - mismatchPercentage: 0, - }), + formatScreenshotDiffText( + { + match: true, + differentPixels: 0, + totalPixels: 100, + mismatchPercentage: 0, + }, + { env: process.env, cwd: process.cwd() }, + ), ); assert.match(text, /✓ Screenshots match\./); assert.equal(text.includes('\u001b['), false); @@ -74,41 +80,44 @@ test('formatScreenshotDiffText renders match success without color', () => { test('formatScreenshotDiffText renders mismatch with pixel counts without color', () => { const text = withNoColor(() => - formatScreenshotDiffText({ - match: false, - differentPixels: 500, - totalPixels: 10000, - mismatchPercentage: 5, - diffPath: '/tmp/test/diff.png', - currentOverlayPath: '/tmp/test/diff.current-overlay.png', - currentOverlayRefCount: 1, - regions: [ - { - index: 1, - rect: { x: 10, y: 20, width: 100, height: 40 }, - normalizedRect: normalizedRect({ x: 10, y: 20, width: 100, height: 40 }), - differentPixels: 350, - shareOfDiffPercentage: 70, - densityPercentage: 87.5, - shape: 'horizontal-band', - size: 'small', - location: 'top-left', - averageBaselineColorHex: '#000000', - averageCurrentColorHex: '#ffffff', - baselineLuminance: 0, - currentLuminance: 255, - dominantChange: 'brighter', - currentOverlayMatches: [ - { - ref: 'e1', - label: 'Continue', - rect: { x: 1, y: 2, width: 3, height: 4 }, - regionCoveragePercentage: 12, - }, - ], - }, - ], - }), + formatScreenshotDiffText( + { + match: false, + differentPixels: 500, + totalPixels: 10000, + mismatchPercentage: 5, + diffPath: '/tmp/test/diff.png', + currentOverlayPath: '/tmp/test/diff.current-overlay.png', + currentOverlayRefCount: 1, + regions: [ + { + index: 1, + rect: { x: 10, y: 20, width: 100, height: 40 }, + normalizedRect: normalizedRect({ x: 10, y: 20, width: 100, height: 40 }), + differentPixels: 350, + shareOfDiffPercentage: 70, + densityPercentage: 87.5, + shape: 'horizontal-band', + size: 'small', + location: 'top-left', + averageBaselineColorHex: '#000000', + averageCurrentColorHex: '#ffffff', + baselineLuminance: 0, + currentLuminance: 255, + dominantChange: 'brighter', + currentOverlayMatches: [ + { + ref: 'e1', + label: 'Continue', + rect: { x: 1, y: 2, width: 3, height: 4 }, + regionCoveragePercentage: 12, + }, + ], + }, + ], + }, + { env: process.env, cwd: process.cwd() }, + ), ); assert.match(text, /✗ 5% pixels differ/); assert.match(text, /Diff image:/); @@ -123,16 +132,19 @@ test('formatScreenshotDiffText renders mismatch with pixel counts without color' test('formatScreenshotDiffText renders dimension mismatch', () => { const text = withNoColor(() => - formatScreenshotDiffText({ - match: false, - differentPixels: 100, - totalPixels: 100, - mismatchPercentage: 100, - dimensionMismatch: { - expected: { width: 1170, height: 2532 }, - actual: { width: 1080, height: 1920 }, + formatScreenshotDiffText( + { + match: false, + differentPixels: 100, + totalPixels: 100, + mismatchPercentage: 100, + dimensionMismatch: { + expected: { width: 1170, height: 2532 }, + actual: { width: 1080, height: 1920 }, + }, }, - }), + { env: process.env, cwd: process.cwd() }, + ), ); assert.match(text, /✗ Screenshots have different dimensions/); assert.match(text, /expected 1170x2532/); @@ -143,13 +155,16 @@ test('formatScreenshotDiffText renders dimension mismatch', () => { test('formatScreenshotDiffText renders diff path relative to cwd', () => { const cwd = process.cwd(); const text = withNoColor(() => - formatScreenshotDiffText({ - match: false, - differentPixels: 10, - totalPixels: 100, - mismatchPercentage: 10, - diffPath: `${cwd}/diff.png`, - }), + formatScreenshotDiffText( + { + match: false, + differentPixels: 10, + totalPixels: 100, + mismatchPercentage: 10, + diffPath: `${cwd}/diff.png`, + }, + { env: process.env, cwd: process.cwd() }, + ), ); assert.match(text, /\.\/diff\.png/); assert.equal(text.includes(cwd), false); @@ -161,13 +176,16 @@ test('formatScreenshotDiffText keeps absolute diff path outside cwd', () => { const siblingDir = path.join(parentDir, `${path.basename(cwd)}-sibling`); const diffPath = path.join(siblingDir, 'diff.png'); const text = withNoColor(() => - formatScreenshotDiffText({ - match: false, - differentPixels: 10, - totalPixels: 100, - mismatchPercentage: 10, - diffPath, - }), + formatScreenshotDiffText( + { + match: false, + differentPixels: 10, + totalPixels: 100, + mismatchPercentage: 10, + diffPath, + }, + { env: process.env, cwd: process.cwd() }, + ), ); assert.match(text, new RegExp(diffPath.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))); assert.equal(text.includes('./'), false); @@ -175,13 +193,16 @@ test('formatScreenshotDiffText keeps absolute diff path outside cwd', () => { test('formatScreenshotDiffText uses ANSI colors when enabled', () => { const text = withColor(() => - formatScreenshotDiffText({ - match: false, - differentPixels: 10, - totalPixels: 100, - mismatchPercentage: 10, - diffPath: '/tmp/diff.png', - }), + formatScreenshotDiffText( + { + match: false, + differentPixels: 10, + totalPixels: 100, + mismatchPercentage: 10, + diffPath: '/tmp/diff.png', + }, + { env: process.env, cwd: process.cwd() }, + ), ); assert.equal(text.includes('\u001b[31m'), true); assert.equal(text.includes('\u001b[32m'), true); @@ -190,13 +211,16 @@ test('formatScreenshotDiffText uses ANSI colors when enabled', () => { test('formatScreenshotDiffText does not show diff path when images match', () => { const text = withNoColor(() => - formatScreenshotDiffText({ - match: true, - differentPixels: 0, - totalPixels: 100, - mismatchPercentage: 0, - diffPath: '/tmp/diff.png', - }), + formatScreenshotDiffText( + { + match: true, + differentPixels: 0, + totalPixels: 100, + mismatchPercentage: 0, + diffPath: '/tmp/diff.png', + }, + { env: process.env, cwd: process.cwd() }, + ), ); assert.equal(text.includes('Diff image'), false); assert.equal(text.includes('diff.png'), false); diff --git a/src/commands/output/diff.ts b/src/commands/output/diff.ts index c16b73a7ba..4553aa49af 100644 --- a/src/commands/output/diff.ts +++ b/src/commands/output/diff.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment, EnvMap } from '@agent-device/kernel/source-value'; import path from 'node:path'; import { colorize, supportsColor } from './color.ts'; import { readSnapshotWarnings } from './snapshot.ts'; @@ -8,13 +9,13 @@ type SnapshotDiffLine = { text?: string; }; -export function formatSnapshotDiffText(data: Record): string { +export function formatSnapshotDiffText(data: Record, env: EnvMap): string { const baselineInitialized = data.baselineInitialized === true; const summaryRaw = (data.summary ?? {}) as Record; const additions = toNumber(summaryRaw.additions); const removals = toNumber(summaryRaw.removals); const unchanged = toNumber(summaryRaw.unchanged); - const useColor = supportsColor(); + const useColor = supportsColor(env); const notices = readSnapshotWarnings(data); const noticesBlock = notices.length > 0 ? `${notices.join('\n')}\n` : ''; if (baselineInitialized) { @@ -46,14 +47,17 @@ export function formatSnapshotDiffText(data: Record): string { return `${noticesBlock}${body}${summary}\n`; } -export function formatScreenshotDiffText(data: ScreenshotDiffResult): string { - const useColor = supportsColor(); +export function formatScreenshotDiffText( + data: ScreenshotDiffResult, + host: HostEnvironment, +): string { + const useColor = supportsColor(host.env); const match = data.match === true; const dimensionMismatch = data.dimensionMismatch; const lines: string[] = []; lines.push(...formatScreenshotDiffStatusLines(data, useColor)); - lines.push(...formatScreenshotDiffArtifactLines(data, match, useColor)); + lines.push(...formatScreenshotDiffArtifactLines(data, match, useColor, host.cwd)); if (!match && !dimensionMismatch) { lines.push(...formatScreenshotDiffPixelCountLines(data, useColor)); @@ -92,17 +96,18 @@ function formatScreenshotDiffArtifactLines( data: ScreenshotDiffResult, match: boolean, useColor: boolean, + cwd: string, ): string[] { if (match) return []; const lines: string[] = []; if (data.diffPath) { - const relativePath = toRelativePath(data.diffPath); + const relativePath = toRelativePath(data.diffPath, cwd); const label = useColor ? colorize('Diff image:', 'dim') : 'Diff image:'; const displayPath = useColor ? colorize(relativePath, 'green') : relativePath; lines.push(` ${label} ${displayPath}`); } if (data.currentOverlayPath) { - const relativePath = toRelativePath(data.currentOverlayPath); + const relativePath = toRelativePath(data.currentOverlayPath, cwd); const label = useColor ? colorize('Current overlay:', 'dim') : 'Current overlay:'; const displayPath = useColor ? colorize(relativePath, 'green') : relativePath; const refCount = toNumber(data.currentOverlayRefCount); @@ -156,8 +161,7 @@ function formatScreenshotDiffRegionEntryLines(region: ScreenshotDiffRegion): str return lines; } -function toRelativePath(filePath: string): string { - const cwd = process.cwd(); +function toRelativePath(filePath: string, cwd: string): string { const relativePath = path.relative(cwd, filePath); if (relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))) { return relativePath === '' ? '.' : `.${path.sep}${relativePath}`; diff --git a/src/commands/replay/index.test.ts b/src/commands/replay/index.test.ts index 9f62bef8c5..7582d5dcfe 100644 --- a/src/commands/replay/index.test.ts +++ b/src/commands/replay/index.test.ts @@ -148,16 +148,30 @@ describe('replay command interface', () => { }); }); + test('collects replay files and shell variables from the explicit host', async () => { + process.env.AD_VAR_REPLAY_TEST = 'process'; + const host = { env: { AD_VAR_REPLAY_TEST: 'host' }, cwd: SCRIPT_ROOT }; + for (const result of [ + await replayDaemonWriter({ path: './checkout.ad' }, host), + await testDaemonWriter({ paths: ['./suite.ad'] }, host), + ]) { + expect(result.options.replayShellEnv).toEqual({ AD_VAR_REPLAY_TEST: 'host' }); + } + }); + test('writes daemon replay and test requests with replay flags', async () => { process.env.AD_VAR_REPLAY_TEST = 'enabled'; expect( - await replayDaemonWriter({ - path: './checkout.ad', - cwd: SCRIPT_ROOT, - update: true, - backend: 'maestro', - env: ['FOO=bar'], - }), + await replayDaemonWriter( + { + path: './checkout.ad', + cwd: SCRIPT_ROOT, + update: true, + backend: 'maestro', + env: ['FOO=bar'], + }, + { env: process.env, cwd: process.cwd() }, + ), ).toMatchObject({ command: 'replay', positionals: ['./checkout.ad'], @@ -169,14 +183,17 @@ describe('replay command interface', () => { }, }); - const testRequest = await testDaemonWriter({ - paths: ['./suite.ad'], - cwd: SCRIPT_ROOT, - maestro: true, - metroHost: '127.0.0.1', - metroPort: 8083, - reportJunit: './junit.xml', - }); + const testRequest = await testDaemonWriter( + { + paths: ['./suite.ad'], + cwd: SCRIPT_ROOT, + maestro: true, + metroHost: '127.0.0.1', + metroPort: 8083, + reportJunit: './junit.xml', + }, + { env: process.env, cwd: process.cwd() }, + ); expect(testRequest).toMatchObject({ command: 'test', positionals: ['./suite.ad'], @@ -193,7 +210,12 @@ describe('replay command interface', () => { const input = replayCliReader(['./probe-hang.ad'], flags({ timeoutMs: 1_000 })); expect(input).toMatchObject({ path: './probe-hang.ad', timeoutMs: 1_000 }); - expect(await replayDaemonWriter({ ...input, cwd: SCRIPT_ROOT })).toMatchObject({ + expect( + await replayDaemonWriter( + { ...input, cwd: SCRIPT_ROOT }, + { env: process.env, cwd: process.cwd() }, + ), + ).toMatchObject({ command: 'replay', positionals: ['./probe-hang.ad'], options: { timeoutMs: 1_000 }, @@ -262,12 +284,15 @@ describe('replay resume (ADR 0012 decision 4 / migration step 5)', () => { test('writes resumeFrom/resumePlanDigest onto the daemon request as replayFrom/replayPlanDigest', async () => { expect( - await replayDaemonWriter({ - path: './checkout.ad', - cwd: SCRIPT_ROOT, - resumeFrom: 3, - resumePlanDigest: 'deadbeef', - }), + await replayDaemonWriter( + { + path: './checkout.ad', + cwd: SCRIPT_ROOT, + resumeFrom: 3, + resumePlanDigest: 'deadbeef', + }, + { env: process.env, cwd: process.cwd() }, + ), ).toMatchObject({ command: 'replay', positionals: ['./checkout.ad'], @@ -279,7 +304,10 @@ describe('replay resume (ADR 0012 decision 4 / migration step 5)', () => { }); test('test daemon writer never emits replayFrom/replayPlanDigest', async () => { - const request = await testDaemonWriter({ paths: ['./suite.ad'], cwd: SCRIPT_ROOT }); + const request = await testDaemonWriter( + { paths: ['./suite.ad'], cwd: SCRIPT_ROOT }, + { env: process.env, cwd: process.cwd() }, + ); expect(request.options).not.toHaveProperty('replayFrom'); expect(request.options).not.toHaveProperty('replayPlanDigest'); }); @@ -289,7 +317,12 @@ describe('replay --keep-session', () => { test('projects the CLI flag through structured replay input and the daemon request', async () => { const input = replayCliReader(['./checkout.ad'], flags({ replayKeepSession: true })); expect(input).toMatchObject({ path: './checkout.ad', keepSession: true }); - expect(await replayDaemonWriter({ ...input, cwd: SCRIPT_ROOT })).toMatchObject({ + expect( + await replayDaemonWriter( + { ...input, cwd: SCRIPT_ROOT }, + { env: process.env, cwd: process.cwd() }, + ), + ).toMatchObject({ command: 'replay', positionals: ['./checkout.ad'], options: { replayKeepSession: true }, @@ -301,9 +334,14 @@ describe('replay --keep-session', () => { const input = testCliReader(['./suite.ad'], flags({ replayKeepSession: true } as never)); expect(input).not.toHaveProperty('keepSession'); expect(testCommandMetadata.inputSchema.properties).not.toHaveProperty('keepSession'); - expect((await testDaemonWriter({ ...input, cwd: SCRIPT_ROOT })).options).not.toHaveProperty( - 'replayKeepSession', - ); + expect( + ( + await testDaemonWriter( + { ...input, cwd: SCRIPT_ROOT }, + { env: process.env, cwd: process.cwd() }, + ) + ).options, + ).not.toHaveProperty('replayKeepSession'); }); }); @@ -326,14 +364,20 @@ describe('replay --save-script arming (ADR 0012 decision 6, R1/R6)', () => { test('writes saveScript onto the daemon request unchanged', async () => { expect( - await replayDaemonWriter({ path: './checkout.ad', cwd: SCRIPT_ROOT, saveScript: true }), + await replayDaemonWriter( + { path: './checkout.ad', cwd: SCRIPT_ROOT, saveScript: true }, + { env: process.env, cwd: process.cwd() }, + ), ).toMatchObject({ command: 'replay', positionals: ['./checkout.ad'], options: { saveScript: true }, }); expect( - await replayDaemonWriter({ path: './checkout.ad', cwd: SCRIPT_ROOT, saveScript: './out.ad' }), + await replayDaemonWriter( + { path: './checkout.ad', cwd: SCRIPT_ROOT, saveScript: './out.ad' }, + { env: process.env, cwd: process.cwd() }, + ), ).toMatchObject({ command: 'replay', positionals: ['./checkout.ad'], diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index 28e13047f8..0706832a7e 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -242,10 +242,10 @@ export const testCliReader: CliReader = (positionals, flags) => ({ testIme: flags.testIme, }); -export const replayDaemonWriter: AsyncDaemonWriter = async (input) => { +export const replayDaemonWriter: AsyncDaemonWriter = async (input, host) => { const inputPath = requiredDaemonString(input.path, 'replay requires path'); const replayBackend = readReplayBackend(input); - const replayShellEnv = collectReplayClientShellEnv(process.env); + const replayShellEnv = collectReplayClientShellEnv(host.env); return request(REPLAY_COMMAND_NAME, [inputPath], { ...input, replayUpdate: input.update, @@ -257,7 +257,7 @@ export const replayDaemonWriter: AsyncDaemonWriter = async (input) => { // the daemon executes only what arrives in this bundle. replayScriptSource: await loadReplayScriptSourceBundle({ inputPath, - cwd: readReplayClientCwd(input), + cwd: readReplayClientCwd(input, host.cwd), replayBackend, env: readReplayScriptSourceEnv(input, replayShellEnv), }), @@ -268,11 +268,11 @@ export const replayDaemonWriter: AsyncDaemonWriter = async (input) => { }); }; -export const testDaemonWriter: AsyncDaemonWriter = async (input) => { +export const testDaemonWriter: AsyncDaemonWriter = async (input, host) => { const inputs = input.paths ?? []; const replayBackend = readReplayBackend(input); - const cwd = readReplayClientCwd(input); - const replayShellEnv = collectReplayClientShellEnv(process.env); + const cwd = readReplayClientCwd(input, host.cwd); + const replayShellEnv = collectReplayClientShellEnv(host.env); const env = readReplayScriptSourceEnv(input, replayShellEnv); return request(TEST_COMMAND_NAME, inputs, { ...stripReplayTestPresentationInput(input), @@ -344,8 +344,8 @@ function readReplayScriptSourceEnv( }; } -function readReplayClientCwd(input: CommandInput): string { - return typeof input.cwd === 'string' && input.cwd.length > 0 ? input.cwd : process.cwd(); +function readReplayClientCwd(input: CommandInput, hostCwd: string): string { + return typeof input.cwd === 'string' && input.cwd.length > 0 ? input.cwd : hostCwd; } function readReplayBackend(input: CommandInput): string | undefined { diff --git a/src/commands/schema/cli-config.ts b/src/commands/schema/cli-config.ts index 1e3aadb96e..ebcd971a37 100644 --- a/src/commands/schema/cli-config.ts +++ b/src/commands/schema/cli-config.ts @@ -20,9 +20,9 @@ export function resolveConfigBackedFlagDefaults(options: { command: string | null; cwd: string; cliFlags: CliFlags; - env?: EnvMap; + env: EnvMap; }): Partial { - const env = options.env ?? process.env; + const env = options.env; const defaults = mergeDefinedFlags( {} as Partial, loadConfigFileDefaults(resolveConfigPaths(options.cwd, options.cliFlags.config, env)), diff --git a/src/commands/system/index.test.ts b/src/commands/system/index.test.ts index 96b568d555..478346eb8f 100644 --- a/src/commands/system/index.test.ts +++ b/src/commands/system/index.test.ts @@ -83,7 +83,12 @@ describe('system command interface', () => { test('parameterless daemon writers emit command names with no positionals', () => { for (const command of parameterless) { - expect(systemCommandFamily.daemonWriters?.[command]?.({})).toMatchObject({ + expect( + systemCommandFamily.daemonWriters?.[command]?.( + {}, + { env: process.env, cwd: process.cwd() }, + ), + ).toMatchObject({ command, positionals: [], }); diff --git a/src/io.ts b/src/io.ts index 12c2d8ff06..e445c12624 100644 --- a/src/io.ts +++ b/src/io.ts @@ -1,3 +1,4 @@ +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -10,7 +11,7 @@ import type { export function createLocalArtifactAdapter( options: LocalArtifactAdapterOptions = {}, ): ArtifactAdapter { - const cwd = options.cwd ?? process.cwd(); + const cwd = options.cwd ?? captureHostEnvironment(process).cwd; const tempDir = options.tempDir ?? os.tmpdir(); const rootDir = options.rootDir ? resolveLocalPath(options.rootDir, cwd) : undefined; diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts index c2beb39749..e0960a8bf8 100644 --- a/src/mcp/command-tools.ts +++ b/src/mcp/command-tools.ts @@ -181,15 +181,15 @@ export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): C input.session, ); } + const { captureHostEnvironment } = await import('@agent-device/host-kit/process'); + const host = captureHostEnvironment(process); const supportedProperties = withMcpConfigSchema(name, metadata.inputSchema).properties; - const resolvedInput = resolveMcpConfigDefaults(name, input, supportedProperties); + const resolvedInput = resolveMcpConfigDefaults(name, input, supportedProperties, host); const config = readMcpToolConfig(resolvedInput); const commandInput = stripMcpConfigFields(resolvedInput); const pinnedInput = refPins.pinInput(name, commandInput, config.client.stateDir); // One host per tool call: the client's ensuring transport and the output formatter resolve // the daemon and the styling from the same host the executor captured (#3794). - const { captureHostEnvironment } = await import('@agent-device/host-kit/process'); - const host = captureHostEnvironment(process); const client = await createClient(deps, config.client, host); try { const result = await (deps.runCommand ?? runCommand)(client, name, pinnedInput); diff --git a/src/mcp/tool-input-config.ts b/src/mcp/tool-input-config.ts index 9c907eb2f5..70f5b83cd5 100644 --- a/src/mcp/tool-input-config.ts +++ b/src/mcp/tool-input-config.ts @@ -1,3 +1,4 @@ +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { isFlagSupportedForCommand } from '../commands/schema/option-schema.ts'; import type { FlagKey } from '@agent-device/command-registry/flag-types'; import type { CliFlags } from '@agent-device/contracts/command'; @@ -10,10 +11,12 @@ export function resolveMcpConfigDefaults( name: CommandName, explicitInput: Record, supportedProperties: Readonly>, + host: HostEnvironment, ): Record { const defaults = resolveConfigBackedFlagDefaults({ command: name, - cwd: process.cwd(), + cwd: host.cwd, + env: host.env, cliFlags: explicitInput as CliFlags, }); const applicableDefaults: Record = {}; diff --git a/src/sdk/remote-config.ts b/src/sdk/remote-config.ts index 29097df6c6..f5c1286957 100644 --- a/src/sdk/remote-config.ts +++ b/src/sdk/remote-config.ts @@ -1,3 +1,4 @@ +import { captureHostEnvironment } from '@agent-device/host-kit/process'; import { resolveRemoteConfigProfile as resolveRemoteConfigProfileOnHost } from '@agent-device/remote/remote-config'; import type { RemoteConfigProfileOptions, @@ -10,5 +11,8 @@ export type { RemoteConfigProfile } from '@agent-device/remote/remote-config-sch export function resolveRemoteConfigProfile( options: RemoteConfigProfileOptions, ): ResolvedRemoteConfigProfile { - return resolveRemoteConfigProfileOnHost({ ...options, env: options.env ?? process.env }); + return resolveRemoteConfigProfileOnHost({ + ...options, + env: options.env ?? captureHostEnvironment(process).env, + }); }