From 548344d433457ccba5c281accd0659160bc9f798 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 12 Sep 2026 11:14:51 +0200 Subject: [PATCH 1/4] =?UTF-8?q?fix(sdk,surface):=20thread=20cwd=20through?= =?UTF-8?q?=20f.agent=20=E2=86=92=20worker-cli.spawn=20(#357)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent CLI subprocess inherited the flow-runner's cwd instead of the worktree the flow author intended, silently no-op'ing agents that expect to see a specific project. Add optional cwd on AgentOptions → AgentStepSpec → KernelAgentStep → runAgentCli → spawn. Co-Authored-By: Claude Opus 4.7 (1M context) Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- packages/sdk/src/authored-worker-step.ts | 7 ++++ packages/sdk/src/compile.ts | 2 + packages/sdk/src/spec.ts | 4 ++ packages/sdk/src/step-fields.ts | 2 +- packages/sdk/src/worker-cli.ts | 5 ++- packages/sdk/src/worker.ts | 2 +- packages/sdk/tests/worker-cli-cwd.test.ts | 50 +++++++++++++++++++++++ packages/sdk/tsconfig.tests.json | 3 +- packages/surface/src/context.ts | 2 + 9 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 packages/sdk/tests/worker-cli-cwd.test.ts 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/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 { From 89e19666ae998cfa63d3c48280f98099d3367d38 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 12 Sep 2026 12:48:43 +0200 Subject: [PATCH 2/4] test(sdk): declare verb-field-lint sample for cwd foreign field The scope-compiler + worker-cli-cwd thread makes cwd an agent-only field; verb-field-lint generates cross-verb foreign-field cases and refuses to run without a sample value for every declared field. Co-Authored-By: Claude Opus 4.7 (1M context) Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- packages/sdk/tests/verb-field-lint.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sdk/tests/verb-field-lint.test.ts b/packages/sdk/tests/verb-field-lint.test.ts index c3544c369..425237eb8 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', }; /** From cc29244f055f540b590274062893c94349583fbd Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 12 Sep 2026 13:18:17 +0200 Subject: [PATCH 3/4] test(sdk): extend verb-field-lint pins for agent cwd field STEP_FIELDS_BY_TYPE.agent gains 'cwd'; CROSS_VERB pairs gain deterministic and llm foreign-cwd cases. Co-Authored-By: Claude Opus 4.7 (1M context) Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- packages/sdk/tests/verb-field-lint.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sdk/tests/verb-field-lint.test.ts b/packages/sdk/tests/verb-field-lint.test.ts index 425237eb8..fe10fc80d 100644 --- a/packages/sdk/tests/verb-field-lint.test.ts +++ b/packages/sdk/tests/verb-field-lint.test.ts @@ -195,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', 'surfaces', 'recoveryMode', 'permissions', 'output', 'cwd'], }); expect(CROSS_VERB_STEP_FIELDS.map(({ label }) => label).sort()).toEqual([ 'agent foreign command', @@ -204,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', @@ -213,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', From 3b4bb36793397ec58b14e6b332b342a2a14a5809 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 12 Sep 2026 13:48:10 +0200 Subject: [PATCH 4/4] test(sdk): fix verb-field-lint agent field order for cwd position Source declares cwd at index 4 (between model and surfaces); align the pin. Co-Authored-By: Claude Opus 4.7 (1M context) Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- packages/sdk/tests/verb-field-lint.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/tests/verb-field-lint.test.ts b/packages/sdk/tests/verb-field-lint.test.ts index fe10fc80d..dac325c7f 100644 --- a/packages/sdk/tests/verb-field-lint.test.ts +++ b/packages/sdk/tests/verb-field-lint.test.ts @@ -195,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', 'cwd'], + agent: ['instruction', 'agent', 'cli', 'model', 'cwd', 'surfaces', 'recoveryMode', 'permissions', 'output'], }); expect(CROSS_VERB_STEP_FIELDS.map(({ label }) => label).sort()).toEqual([ 'agent foreign command',