diff --git a/CHANGELOG.md b/CHANGELOG.md index e778bf9e..f9d311b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`claude-sdk` env-var passthrough** — The `claude-sdk` harness now honors + `PROSE_CLAUDE_PERMISSION_MODE` (`default`, `acceptEdits`, `bypassPermissions`, + or `plan`) and forwards it to the Claude Agent SDK as `permissionMode`. This + mirrors the existing `PROSE_CODEX_*` pattern and unblocks non-interactive + `prose run --harness claude-sdk` invocations (CI, conformance runs, scheduled + jobs) that previously stalled on per-write permission prompts because + `permissionMode` is a query-level SDK option that `settingSources` does not + flow into. See `tools/cli/README.md` and + `tools/cli/src/harnesses/claude-options.ts`. + ## [0.14.0] - 2026-05-19 ### Added diff --git a/tools/cli/README.md b/tools/cli/README.md index 04e2e58a..72a9f244 100644 --- a/tools/cli/README.md +++ b/tools/cli/README.md @@ -106,6 +106,13 @@ Codex harnesses also honor `PROSE_CODEX_ADD_DIR` as a comma-separated list of additional writable directories and `PROSE_CODEX_NETWORK` (`true` or `false`) for outbound network access. +For externally sandboxed or non-interactive runs, the `claude-sdk` harness +honors `PROSE_CLAUDE_PERMISSION_MODE` (`default`, `acceptEdits`, +`bypassPermissions`, or `plan`) and forwards it to the Claude Agent SDK as +`permissionMode`. The SDK defaults to `default` (prompt for tool use) when the +variable is unset, which is appropriate for interactive sessions but blocks +automated runs that cannot answer permission prompts. + ## Skill Setup OpenProse execution depends on the `open-prose` agent skill. Before running a diff --git a/tools/cli/src/harnesses/claude-options.ts b/tools/cli/src/harnesses/claude-options.ts new file mode 100644 index 00000000..6c509a24 --- /dev/null +++ b/tools/cli/src/harnesses/claude-options.ts @@ -0,0 +1,32 @@ +import type { ClaudeSdkQuery } from "./claude-sdk.js"; + +const CLAUDE_PERMISSION_MODES = ["default", "acceptEdits", "bypassPermissions", "plan"] as const; + +type ClaudeQueryOptions = NonNullable[0]["options"]>; + +export function claudeRuntimeOptions( + env: Record | undefined, +): Pick { + const permissionMode = claudeEnvOption("PROSE_CLAUDE_PERMISSION_MODE", CLAUDE_PERMISSION_MODES, env); + + return { + ...(permissionMode === undefined ? {} : { permissionMode }), + }; +} + +function claudeEnvOption( + name: string, + allowedValues: T, + env: Record | undefined, +): T[number] | undefined { + const value = env?.[name] ?? process.env[name]; + if (value === undefined || value === "") { + return undefined; + } + + if (allowedValues.includes(value)) { + return value; + } + + throw new Error(`${name} must be one of: ${allowedValues.join(", ")}`); +} diff --git a/tools/cli/src/harnesses/claude-sdk.ts b/tools/cli/src/harnesses/claude-sdk.ts index 15ad76d2..59f1718a 100644 --- a/tools/cli/src/harnesses/claude-sdk.ts +++ b/tools/cli/src/harnesses/claude-sdk.ts @@ -1,3 +1,4 @@ +import { claudeRuntimeOptions } from "./claude-options.js"; import { writeLine } from "./streams.js"; import type { Harness, HarnessRunOptions } from "./types.js"; @@ -22,6 +23,7 @@ export function createClaudeSdkHarness(options: ClaudeSdkHarnessOptions = {}): H name: "claude-sdk", async run(prompt, runOptions) { const abortController = bridgeAbortController(runOptions.signal); + const runtimeOptions = claudeRuntimeOptions(runOptions.env); const stream = await Promise.resolve( query({ prompt, @@ -33,6 +35,7 @@ export function createClaudeSdkHarness(options: ClaudeSdkHarnessOptions = {}): H ...(runOptions.cwd === undefined ? {} : { cwd: runOptions.cwd }), ...(runOptions.env === undefined ? {} : { env: runOptions.env }), includePartialMessages: true, + ...runtimeOptions, settingSources: ["user", "project"], stderr: (chunk: string) => runOptions.stderr.write(chunk), ...(runOptions.systemPromptAppend === undefined diff --git a/tools/cli/tests/harnesses/harnesses.test.ts b/tools/cli/tests/harnesses/harnesses.test.ts index d34e1100..e57b2685 100644 --- a/tools/cli/tests/harnesses/harnesses.test.ts +++ b/tools/cli/tests/harnesses/harnesses.test.ts @@ -305,6 +305,78 @@ describe("claude-sdk harness", () => { expect(io.stderr).toBe("bad\n"); }); + test("forwards PROSE_CLAUDE_PERMISSION_MODE to the SDK as permissionMode", async () => { + const io = memoryStreams(); + const calls: unknown[] = []; + const harness = createClaudeSdkHarness({ + query: async (args) => { + calls.push(args); + return { + async *[Symbol.asyncIterator]() { + yield { type: "result", subtype: "success", result: "ok", is_error: false }; + }, + close() {}, + } as never; + }, + }); + + const exitCode = await harness.run("prose run inspector.prose.md", { + ...io.options, + env: { PROSE_CLAUDE_PERMISSION_MODE: "bypassPermissions" }, + }); + + expect(exitCode).toBe(0); + expect(calls).toEqual([ + expect.objectContaining({ + options: expect.objectContaining({ + permissionMode: "bypassPermissions", + }), + }), + ]); + }); + + test("omits permissionMode when PROSE_CLAUDE_PERMISSION_MODE is unset", async () => { + const io = memoryStreams(); + const calls: unknown[] = []; + const harness = createClaudeSdkHarness({ + query: async (args) => { + calls.push(args); + return { + async *[Symbol.asyncIterator]() { + yield { type: "result", subtype: "success", result: "ok", is_error: false }; + }, + close() {}, + } as never; + }, + }); + + // Pass an explicit empty env so the test does not depend on the + // host shell's PROSE_CLAUDE_PERMISSION_MODE. + await harness.run("prose status", { ...io.options, env: {} }); + + expect(calls).toHaveLength(1); + const call = calls[0] as { options: Record }; + expect(call.options).not.toHaveProperty("permissionMode"); + }); + + test("rejects invalid PROSE_CLAUDE_PERMISSION_MODE", async () => { + const io = memoryStreams(); + const harness = createClaudeSdkHarness({ + query: async () => { + throw new Error("unexpected query"); + }, + }); + + await expect( + harness.run("prose run inspector.prose.md", { + ...io.options, + env: { PROSE_CLAUDE_PERMISSION_MODE: "yolo" }, + }), + ).rejects.toThrow( + "PROSE_CLAUDE_PERMISSION_MODE must be one of: default, acceptEdits, bypassPermissions, plan", + ); + }); + test("always forwards settingSources: ['user', 'project']", async () => { const io = memoryStreams(); const calls: unknown[] = [];