diff --git a/packages/sdk/src/authored-worker-step.ts b/packages/sdk/src/authored-worker-step.ts index 85923446c..897f4ba95 100644 --- a/packages/sdk/src/authored-worker-step.ts +++ b/packages/sdk/src/authored-worker-step.ts @@ -112,12 +112,19 @@ export function authoredWorkerRunner( `f.agent options.model must be a string when set (got ${typeof options.model}).`, ); } + if (options.cwd !== undefined && typeof options.cwd !== 'string') { + throw new AuthoredFlowExecutionError( + 'agent_cli_unresolved', + `f.agent options.cwd must be a string when set (got ${typeof options.cwd}).`, + ); + } const output = await run({ id, type: 'agent', instruction: options.task, ...(localAgentStream === undefined ? {} : { surfaces: { streams: [{ stream: localAgentStream }] } }), ...(options.workspace === undefined ? {} : { surfaces: { workspace: [{ surface: options.workspace }] } }), ...(options.cli === undefined ? {} : { cli: options.cli }), ...(options.model === undefined ? {} : { model: options.model }), + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), }); if (typeof output !== 'object' || output === null || Array.isArray(output)) { throw new AuthoredFlowExecutionError('journal_protocol_violation', `step "${id}" produced a non-object output`); diff --git a/packages/sdk/src/compile.ts b/packages/sdk/src/compile.ts index 9c311ae19..a93d9b2f0 100644 --- a/packages/sdk/src/compile.ts +++ b/packages/sdk/src/compile.ts @@ -232,6 +232,7 @@ function compileStep(step: StepSpec): StepSpec { ...(s.agent !== undefined ? { agent: s.agent } : {}), ...(s.cli !== undefined ? { cli: s.cli } : {}), ...(s.model !== undefined ? { model: s.model } : {}), + ...(s.cwd !== undefined ? { cwd: s.cwd } : {}), recoveryMode, ...(s.surfaces !== undefined ? { surfaces: s.surfaces } : {}), ...(s.permissions !== undefined ? { permissions: s.permissions } : {}), @@ -622,6 +623,7 @@ function toKernelStep(step: StepSpec): KernelStepSpec { instruction: step.instruction, ...(step.cli !== undefined ? { cli: step.cli } : {}), ...(step.model !== undefined ? { model: step.model } : {}), + ...(step.cwd !== undefined ? { cwd: step.cwd } : {}), recovery_mode: step.recoveryMode ?? 'reset', }; const surfaces = { diff --git a/packages/sdk/src/spec.ts b/packages/sdk/src/spec.ts index e75bc1410..b57f61711 100644 --- a/packages/sdk/src/spec.ts +++ b/packages/sdk/src/spec.ts @@ -247,6 +247,8 @@ export interface AgentStepSpec extends BaseStepSpec { surfaces?: AgentSurfaces; recoveryMode?: RecoveryMode; permissions?: PermissionsSpec; + /** Working directory for the CLI subprocess; defaults to the flow-runner's cwd. */ + cwd?: string; /** * Structured-output authoring sugar. A successful CLI JSON object is the parsed * value; the kernel persists it only after `json_schema` verification. @@ -432,6 +434,8 @@ export interface KernelAgentStep extends KernelStepCommon { recovery_mode: RecoveryMode; surfaces?: KernelAgentSurfaces; permissions?: KernelPermissionsSpec; + /** Working directory for the CLI subprocess; kernel passes through untouched. */ + cwd?: string; } export type KernelStepSpec = KernelDeterministicStep | KernelLlmStep | KernelAgentStep; diff --git a/packages/sdk/src/step-fields.ts b/packages/sdk/src/step-fields.ts index 00bcf5ce6..3599aa8c4 100644 --- a/packages/sdk/src/step-fields.ts +++ b/packages/sdk/src/step-fields.ts @@ -37,5 +37,5 @@ export const STEP_COMMON_FIELDS = [ export const STEP_FIELDS_BY_TYPE = { deterministic: ['command', 'timeoutMs', 'lease_ms'], llm: ['prompt', 'model', 'cli', 'output'], - agent: ['instruction', 'agent', 'cli', 'model', 'surfaces', 'recoveryMode', 'permissions', 'output'], + agent: ['instruction', 'agent', 'cli', 'model', 'cwd', 'surfaces', 'recoveryMode', 'permissions', 'output'], } as const satisfies Record; diff --git a/packages/sdk/src/worker-cli.ts b/packages/sdk/src/worker-cli.ts index d9481b541..2211e25d4 100644 --- a/packages/sdk/src/worker-cli.ts +++ b/packages/sdk/src/worker-cli.ts @@ -41,6 +41,7 @@ export async function runAgentCli( signal?: AbortSignal, mode: 'agent' | 'llm' = 'agent', sidechannel?: SidechannelContext, + cwd?: string, ): Promise { signal?.throwIfAborted(); if (signal !== undefined && process.platform === 'win32') { @@ -81,7 +82,7 @@ export async function runAgentCli( // Structured provider output carries the authoritative token counts. const args = [...invocation.args]; args.splice(args.length - 1, 0, ...(kind === 'claude' ? ['--output-format', 'json'] : ['--json'])); - return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel), kind), model); + return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel, cwd), kind), model); } async function spawnInvocation( @@ -90,6 +91,7 @@ async function spawnInvocation( env: NodeJS.ProcessEnv, signal?: AbortSignal, sidechannel?: SidechannelContext, + cwd?: string, ): Promise { let writeInput: (bytes: Buffer) => Promise = async () => false; let canDrive = () => false; @@ -104,6 +106,7 @@ async function spawnInvocation( const child = spawn(cli, invocation.args, { stdio: ['pipe', 'pipe', 'pipe'], env, detached: ownsGroup, + ...(cwd === undefined ? {} : { cwd }), }); child.stdin.on('error', () => {}); if (channel === undefined) child.stdin.end(); diff --git a/packages/sdk/src/worker.ts b/packages/sdk/src/worker.ts index 8583ef36e..4da321253 100644 --- a/packages/sdk/src/worker.ts +++ b/packages/sdk/src/worker.ts @@ -110,7 +110,7 @@ export class AgentWorker extends EventEmitter { ? runAgentCli(spec.cli, workerInstruction(spec.instruction, dispatch), dispatch.wake_context, spec.model, undefined, signal, 'agent', this.options.dataDir === undefined ? undefined : { dataDir: this.options.dataDir, runId: dispatch.run_id, stepId: dispatch.step_id, onReady: this.options.onPtyReady, onDrive: () => { humanIntervention = true; }, - }) + }, typeof spec.cwd === 'string' ? spec.cwd : undefined) : Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'agent step has no declared CLI' })); const { result, usage } = workerSpend(completed, spec.model); const completionReason = result.exit_code === 0 ? 'success' : 'worker_error'; diff --git a/packages/sdk/tests/verb-field-lint.test.ts b/packages/sdk/tests/verb-field-lint.test.ts index c3544c369..dac325c7f 100644 --- a/packages/sdk/tests/verb-field-lint.test.ts +++ b/packages/sdk/tests/verb-field-lint.test.ts @@ -80,6 +80,7 @@ const VERB_FIELD_VALUES: Record = { recoveryMode: 'reset', permissions: { accessPreset: 'readonly' }, output: { type: 'object' }, + cwd: '/tmp/foreign-cwd', }; /** @@ -194,7 +195,7 @@ describe('closed per-verb step fields', () => { expect(STEP_FIELDS_BY_TYPE).toEqual({ deterministic: ['command', 'timeoutMs', 'lease_ms'], llm: ['prompt', 'model', 'cli', 'output'], - agent: ['instruction', 'agent', 'cli', 'model', 'surfaces', 'recoveryMode', 'permissions', 'output'], + agent: ['instruction', 'agent', 'cli', 'model', 'cwd', 'surfaces', 'recoveryMode', 'permissions', 'output'], }); expect(CROSS_VERB_STEP_FIELDS.map(({ label }) => label).sort()).toEqual([ 'agent foreign command', @@ -203,6 +204,7 @@ describe('closed per-verb step fields', () => { 'agent foreign timeoutMs', 'deterministic foreign agent', 'deterministic foreign cli', + 'deterministic foreign cwd', 'deterministic foreign instruction', 'deterministic foreign model', 'deterministic foreign output', @@ -212,6 +214,7 @@ describe('closed per-verb step fields', () => { 'deterministic foreign surfaces', 'llm foreign agent', 'llm foreign command', + 'llm foreign cwd', 'llm foreign instruction', 'llm foreign lease_ms', 'llm foreign permissions', diff --git a/packages/sdk/tests/worker-cli-cwd.test.ts b/packages/sdk/tests/worker-cli-cwd.test.ts new file mode 100644 index 000000000..07018eaa0 --- /dev/null +++ b/packages/sdk/tests/worker-cli-cwd.test.ts @@ -0,0 +1,50 @@ +import { EventEmitter } from 'node:events'; +import { describe, expect, it, vi } from 'vitest'; + +const spawnCalls: Array<{ cli: string; args: string[]; options: Record }> = []; + +vi.mock('node:child_process', () => ({ + spawn: (cli: string, args: string[], options: Record) => { + spawnCalls.push({ cli, args, options }); + const child = new EventEmitter() as EventEmitter & Record; + const stdin = new EventEmitter() as EventEmitter & Record; + stdin.end = () => {}; + stdin.write = (_: unknown, cb: (e?: Error) => void) => { cb(); return true; }; + stdin.destroyed = false; + stdin.writableEnded = false; + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + child.stdin = stdin; + child.stdout = stdout; + child.stderr = stderr; + child.kill = () => true; + setImmediate(() => { + const payload = cli === 'claude' + ? JSON.stringify({ result: '', usage: { input_tokens: 1, output_tokens: 1 }, total_cost_usd: 0 }) + : JSON.stringify({ type: 'usage', usage: { input_tokens: 1, output_tokens: 1, total_cost_usd: 0 } }); + stdout.emit('data', Buffer.from(payload)); + child.emit('close', 0); + }); + return child as unknown as ReturnType; + }, +})); + +// Import after the mock is registered so the module picks up the mocked spawn. +import { runAgentCli } from '../src/worker-cli.js'; + +describe('runAgentCli — cwd propagation (flows#357)', () => { + it('threads explicit cwd into spawn options', async () => { + spawnCalls.length = 0; + await runAgentCli('claude', 'hello', undefined, 'claude-sonnet-4-6', + undefined, undefined, 'agent', undefined, '/tmp/probe-worktree'); + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0]?.options.cwd).toBe('/tmp/probe-worktree'); + }); + + it('omits cwd when not provided (inherits parent cwd)', async () => { + spawnCalls.length = 0; + await runAgentCli('claude', 'hello', undefined, 'claude-sonnet-4-6'); + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0]?.options.cwd).toBeUndefined(); + }); +}); diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index 00a96d540..52dd48991 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -33,7 +33,8 @@ "tests/fixtures/needs-human.flow.ts", "tests/named-gates.test.ts", "tests/build-gate.test.ts", - "tests/scope-preflight.test.ts" + "tests/scope-preflight.test.ts", + "tests/worker-cli-cwd.test.ts" ], "exclude": ["node_modules", "dist"] } diff --git a/packages/surface/src/context.ts b/packages/surface/src/context.ts index d445d6886..0167eb3d2 100644 --- a/packages/surface/src/context.ts +++ b/packages/surface/src/context.ts @@ -14,6 +14,8 @@ export interface AgentOptions { workspace?: string; cli?: string; model?: string; + /** Working directory for the CLI subprocess; defaults to the flow-runner's cwd. */ + cwd?: string; } export interface LlmOptions {