diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d314f..6fa9a87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.7.1 - Unreleased - Fixed revalidation prompts to compact historical and feature metadata and hard-cap metadata lists even when configured file limits are high, preventing provider input overflows, thanks @pai-scaffolde. +- Added an opt-in Claude host auth context that preserves the default-deny environment, uses Claude Code safe mode, validates auth through doctor, and reports redacted OAuth failure signals, thanks @grantjayy. ## 0.7.0 - 2026-06-15 diff --git a/docs/configuration.md b/docs/configuration.md index b0ca262..3d37473 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -72,6 +72,7 @@ Environment overrides: - `CLAWPATCH_PROVIDER` - `CLAWPATCH_MODEL` - `CLAWPATCH_REASONING_EFFORT` +- `CLAWPATCH_CLAUDE_AUTH_CONTEXT` (`isolated` or `host`; default `isolated`) `provider.codexConfig` passes primitive values to Codex as `-c key=value`. Only config loaded by `--config` or `CLAWPATCH_CONFIG` may set non-empty diff --git a/docs/providers.md b/docs/providers.md index b58fa1a..5fcdbf6 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -144,13 +144,26 @@ The `claude` provider shells out to the local [Claude Code CLI](https://code.claude.com/docs/en/cli-usage) in non-interactive print mode. -Install Claude Code and authenticate with an Anthropic API key: +Install Claude Code. The default isolated mode accepts an Anthropic API key or +the supported cloud-provider auth variables: ```bash export ANTHROPIC_API_KEY=sk-ant-... claude --version ``` +To use the configured Claude Code binary's `/login` or setup-token auth state, +opt into host auth context: + +```bash +CLAWPATCH_CLAUDE_AUTH_CONTEXT=host clawpatch doctor --provider claude +CLAWPATCH_CLAUDE_AUTH_CONTEXT=host clawpatch review --provider claude +``` + +Host auth context requires Claude Code 2.1.169 or newer. +Set `CLAWPATCH_CLAUDE_BIN` to an executable path when Clawpatch should invoke a +specific Claude Code installation. + Provider selection: ```bash @@ -168,10 +181,11 @@ clawpatch review --provider claude --model claude-haiku-4-5-20251001 --limit 1 How the Claude provider works: -- Doctor: `clawpatch doctor --provider claude` only checks that the Claude Code +- Doctor: `clawpatch doctor --provider claude` checks that the Claude Code binary is available, reads `claude --version`, and blocks known vulnerable - versions. It does not validate auth or make a network call; auth failures are - reported on the first provider-backed command. + versions. In host auth context it also runs one minimal structured provider + query through the same auth/runtime boundary; this makes a network request + and may consume provider credits. - Auth/isolation: provider runs use `--bare` with a default-deny environment. Clawpatch forwards only minimal execution variables plus explicit Anthropic API key, Vertex AI, Google ADC, cloud-gateway, and Bedrock/AWS auth variables. @@ -181,6 +195,14 @@ How the Claude provider works: Claude tool subprocess env scrubbing is enabled. AWS profile names are forwarded only when `AWS_CONFIG_FILE` or `AWS_SHARED_CREDENTIALS_FILE` points at explicit profile files. +- Host auth context: `CLAWPATCH_CLAUDE_AUTH_CONTEXT=host` replaces `--bare` + with Claude Code `--safe-mode`, exposes only the host `HOME`/`USERPROFILE` + and optional `CLAUDE_CONFIG_DIR` auth locators, and permits + `CLAUDE_CODE_OAUTH_TOKEN`. It does not inherit the whole host environment; + the auth allowlist, temporary XDG/cache/data directories, subprocess env + scrubbing, tool limits, empty strict MCP config, disabled slash commands, + and disabled browser integration remain in force. `--safe-mode` disables + user and repository customizations while retaining normal authentication. - Structured output: provider runs use `--output-format json --json-schema` and parse the returned `structured_output` field. - Read-only operations (map, review, revalidate): use @@ -199,10 +221,12 @@ How the Claude provider works: - Timeout: 180 seconds by default, override with `CLAWPATCH_CLAUDE_TIMEOUT_MS` or `CLAWPATCH_PROVIDER_TIMEOUT_MS`. -Permission caveat: Claude tool restrictions are enforced by Claude Code. For -write operations during `fix`, Claude may edit the current worktree. For -untrusted code, run `clawpatch fix --provider claude` inside an isolated -checkout. +Permission caveat: Claude tool restrictions are enforced by Claude Code, and +safe mode is configuration isolation rather than an OS sandbox. Host auth +context makes the host auth locator visible to the Claude process; use it only +for trusted repositories. For write operations during `fix`, Claude may edit +the current worktree. For untrusted code, keep the default isolated auth +context and run `clawpatch fix --provider claude` inside an isolated checkout. ## Grok diff --git a/src/provider.test.ts b/src/provider.test.ts index 510ece5..c4cafd6 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -14,9 +14,11 @@ const { addCodexConfigArgs, addCodexModelArgs, addCodexSandboxArgs, + assertClaudeAuthContextSupported, assertClaudeVersionAllowed, buildAcpxJsonArgs, claudeArgs, + claudeAuthContext, claudeEffort, claudeEnv, claudeExitCode, @@ -711,6 +713,7 @@ describe("Claude provider helpers", () => { { type: "object" }, { model: null, reasoningEffort: null, skipGitRepoCheck: false }, true, + "isolated", ); expect(args).toEqual([ @@ -738,6 +741,7 @@ describe("Claude provider helpers", () => { { type: "object" }, { model: null, reasoningEffort: null, skipGitRepoCheck: false }, false, + "isolated", ); expect(args).toContain("default"); @@ -746,6 +750,20 @@ describe("Claude provider helpers", () => { expect(args).not.toContain("dontAsk"); }); + it("uses safe mode instead of bare mode for host auth context", () => { + const args = claudeArgs( + { type: "object" }, + { model: null, reasoningEffort: null, skipGitRepoCheck: false }, + true, + "host", + ); + + expect(args).toContain("--safe-mode"); + expect(args).not.toContain("--bare"); + expect(args).toContain("--strict-mcp-config"); + expect(args).toContain("--disable-slash-commands"); + }); + it("passes model and supported effort while ignoring skipGitRepoCheck", () => { const args = ["-p"]; @@ -776,7 +794,7 @@ describe("Claude provider helpers", () => { CLAUDE_CODE_OAUTH_TOKEN: "must-not-leak", }; - expect(claudeEnv(false, "/tmp/claude")).toEqual({ + expect(claudeEnv(false, "/tmp/claude", "isolated")).toEqual({ PATH: "/bin", HOME: "/tmp/claude/home", XDG_CONFIG_HOME: "/tmp/claude/xdg-config", @@ -787,7 +805,7 @@ describe("Claude provider helpers", () => { TMP: "/tmp/claude", CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1", }); - expect(claudeEnv(true, "/tmp/claude")).toEqual({ + expect(claudeEnv(true, "/tmp/claude", "isolated")).toEqual({ PATH: "/bin", HOME: "/tmp/claude/home", XDG_CONFIG_HOME: "/tmp/claude/xdg-config", @@ -801,6 +819,53 @@ describe("Claude provider helpers", () => { }); }); + it("exposes only Claude host auth locators in host auth context", () => { + process.env = { + PATH: "/bin", + HOME: "/host-home", + USERPROFILE: "C:\\Users\\operator", + CLAUDE_CONFIG_DIR: "/host-claude-config", + CLAUDE_CODE_OAUTH_TOKEN: "oauth-token", + ANTHROPIC_API_KEY: "api-key", + OPENAI_API_KEY: "must-not-leak", + DATABASE_URL: "must-not-leak", + }; + + expect(claudeEnv(true, "/tmp/claude", "host")).toEqual({ + PATH: "/bin", + HOME: "/host-home", + USERPROFILE: "C:\\Users\\operator", + CLAUDE_CONFIG_DIR: "/host-claude-config", + XDG_CONFIG_HOME: "/tmp/claude/xdg-config", + XDG_CACHE_HOME: "/tmp/claude/xdg-cache", + XDG_DATA_HOME: "/tmp/claude/xdg-data", + TMPDIR: "/tmp/claude", + TEMP: "/tmp/claude", + TMP: "/tmp/claude", + CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1", + CLAUDE_CODE_OAUTH_TOKEN: "oauth-token", + ANTHROPIC_API_KEY: "api-key", + }); + }); + + it("validates Claude auth context and safe-mode version", () => { + delete process.env["CLAWPATCH_CLAUDE_AUTH_CONTEXT"]; + expect(claudeAuthContext()).toBe("isolated"); + + process.env["CLAWPATCH_CLAUDE_AUTH_CONTEXT"] = "host"; + expect(claudeAuthContext()).toBe("host"); + expect(() => assertClaudeAuthContextSupported("2.1.169 (Claude Code)", "host")).not.toThrow(); + expect(() => assertClaudeAuthContextSupported("2.1.168 (Claude Code)", "host")).toThrow( + /2\.1\.169 or newer/u, + ); + expect(() => assertClaudeAuthContextSupported("unknown", "host")).toThrow( + /2\.1\.169 or newer/u, + ); + + process.env["CLAWPATCH_CLAUDE_AUTH_CONTEXT"] = "everything"; + expect(() => claudeAuthContext()).toThrow(/must be isolated or host/u); + }); + it("passes Vertex AI auth env vars only when auth is included", () => { process.env = { PATH: "/bin", @@ -819,7 +884,7 @@ describe("Claude provider helpers", () => { OPENAI_API_KEY: "must-not-leak", }; - expect(claudeEnv(false, "/tmp/claude")).toEqual({ + expect(claudeEnv(false, "/tmp/claude", "isolated")).toEqual({ PATH: "/bin", HOME: "/tmp/claude/home", XDG_CONFIG_HOME: "/tmp/claude/xdg-config", @@ -830,7 +895,7 @@ describe("Claude provider helpers", () => { TMP: "/tmp/claude", CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1", }); - expect(claudeEnv(true, "/tmp/claude")).toMatchObject({ + expect(claudeEnv(true, "/tmp/claude", "isolated")).toMatchObject({ CLAUDE_CODE_USE_VERTEX: "1", ANTHROPIC_BASE_URL: "https://llm-gateway.example.com", ANTHROPIC_AUTH_TOKEN: "gateway-token", @@ -844,7 +909,7 @@ describe("Claude provider helpers", () => { CLOUDSDK_CORE_PROJECT: "sdk-project", CLAUDE_CODE_SKIP_VERTEX_AUTH: "1", }); - expect(claudeEnv(true, "/tmp/claude")).not.toHaveProperty("OPENAI_API_KEY"); + expect(claudeEnv(true, "/tmp/claude", "isolated")).not.toHaveProperty("OPENAI_API_KEY"); }); it("passes Bedrock auth env vars only when auth is included", () => { @@ -867,8 +932,10 @@ describe("Claude provider helpers", () => { DATABASE_URL: "must-not-leak", }; - expect(claudeEnv(false, "/tmp/claude")).not.toHaveProperty("CLAUDE_CODE_USE_BEDROCK"); - expect(claudeEnv(true, "/tmp/claude")).toMatchObject({ + expect(claudeEnv(false, "/tmp/claude", "isolated")).not.toHaveProperty( + "CLAUDE_CODE_USE_BEDROCK", + ); + expect(claudeEnv(true, "/tmp/claude", "isolated")).toMatchObject({ CLAUDE_CODE_USE_BEDROCK: "1", CLAUDE_CODE_SKIP_BEDROCK_AUTH: "1", ANTHROPIC_BEDROCK_BASE_URL: "https://bedrock-runtime.us-east-1.amazonaws.com", @@ -884,7 +951,7 @@ describe("Claude provider helpers", () => { AWS_ROLE_ARN: "arn:aws:iam::123456789012:role/clawpatch", AWS_WEB_IDENTITY_TOKEN_FILE: "/var/aws/web-identity-token", }); - expect(claudeEnv(true, "/tmp/claude")).not.toHaveProperty("DATABASE_URL"); + expect(claudeEnv(true, "/tmp/claude", "isolated")).not.toHaveProperty("DATABASE_URL"); }); it("passes AWS_PROFILE only with explicit AWS config or credentials file paths", () => { @@ -895,17 +962,17 @@ describe("Claude provider helpers", () => { AWS_PROFILE: "clawpatch", }; - expect(claudeEnv(true, "/tmp/claude")).not.toHaveProperty("AWS_PROFILE"); + expect(claudeEnv(true, "/tmp/claude", "isolated")).not.toHaveProperty("AWS_PROFILE"); process.env["AWS_CONFIG_FILE"] = "/var/aws/config"; - expect(claudeEnv(true, "/tmp/claude")).toMatchObject({ + expect(claudeEnv(true, "/tmp/claude", "isolated")).toMatchObject({ AWS_CONFIG_FILE: "/var/aws/config", AWS_PROFILE: "clawpatch", }); delete process.env["AWS_CONFIG_FILE"]; process.env["AWS_SHARED_CREDENTIALS_FILE"] = "/var/aws/credentials"; - expect(claudeEnv(true, "/tmp/claude")).toMatchObject({ + expect(claudeEnv(true, "/tmp/claude", "isolated")).toMatchObject({ AWS_SHARED_CREDENTIALS_FILE: "/var/aws/credentials", AWS_PROFILE: "clawpatch", }); @@ -917,11 +984,11 @@ describe("Claude provider helpers", () => { ANTHROPIC_API_KEY: "secret", }; - expect(claudeEnv(true, "C:\\Temp\\claude")).toMatchObject({ + expect(claudeEnv(true, "C:\\Temp\\claude", "isolated")).toMatchObject({ Path: "C:\\Tools", ANTHROPIC_API_KEY: "secret", }); - expect(claudeEnv(true, "C:\\Temp\\claude")).not.toHaveProperty("PATH"); + expect(claudeEnv(true, "C:\\Temp\\claude", "isolated")).not.toHaveProperty("PATH"); }); it("extracts structured_output from Claude JSON envelopes", () => { @@ -985,6 +1052,32 @@ describe("Claude provider helpers", () => { throw new Error("expected Claude provider failure"); }); + it("classifies string Claude error envelopes", () => { + try { + extractClaudeStructuredOutput(JSON.stringify({ error: "authentication_failed" })); + } catch (err) { + expect(err).toBeInstanceOf(ClawpatchError); + expect((err as ClawpatchError).message).toContain("authentication_failed"); + expect((err as ClawpatchError).exitCode).toBe(4); + return; + } + throw new Error("expected Claude provider failure"); + }); + + it("does not preview arbitrary or token-shaped string Claude errors", () => { + for (const secret of ["SOURCE CONTEXT SECRET", "sk-ant-secret-shaped-value"]) { + try { + extractClaudeStructuredOutput(JSON.stringify({ error: secret })); + } catch (err) { + expect(err).toBeInstanceOf(ClawpatchError); + expect((err as ClawpatchError).message).toBe("claude provider error: provider-error"); + expect((err as ClawpatchError).message).not.toContain(secret); + continue; + } + throw new Error("expected Claude provider failure"); + } + }); + it("does not include stdout or prompt previews in Claude failure messages", () => { const message = claudeFailureMessage("SOURCE_CONTEXT_SECRET", "SOURCE_CONTEXT_SECRET", 1); @@ -1013,7 +1106,8 @@ describe("Claude provider helpers", () => { const message = claudeFailureMessage(stdout, "", 1); - expect(message).toBe("claude provider auth/config failed"); + expect(message).toContain("claude provider auth/config failed"); + expect(message).toContain("error=authentication_failed"); expect(message).not.toContain("SOURCE_CONTEXT_SECRET"); expect(claudeExitCode(stdout, "", 1)).toBe(4); }); @@ -1034,14 +1128,36 @@ describe("Claude provider helpers", () => { result: "SOURCE_CONTEXT_SECRET", }); - expect(claudeFailureMessage(auth, "", 1)).toBe("claude provider auth/config failed"); + expect(claudeFailureMessage(auth, "", 1)).toContain("claude provider auth/config failed"); expect(claudeExitCode(auth, "", 1)).toBe(4); - expect(claudeFailureMessage(quota, "", 1)).toBe("claude provider quota/rate-limit failed"); + expect(claudeFailureMessage(quota, "", 1)).toContain("claude provider quota/rate-limit failed"); expect(claudeExitCode(quota, "", 1)).toBe(5); expect(claudeFailureMessage(auth, "", 1)).not.toContain("SOURCE_CONTEXT_SECRET"); expect(claudeFailureMessage(quota, "", 1)).not.toContain("SOURCE_CONTEXT_SECRET"); }); + it("surfaces the reported OAuth failure shape without leaking result text", () => { + const stdout = [ + JSON.stringify({ type: "assistant", error: "authentication_failed" }), + JSON.stringify({ + type: "result", + subtype: "success", + is_error: true, + result: "Not logged in ยท Please run /login", + terminal_reason: "completed", + }), + ].join("\n"); + + const message = claudeFailureMessage(stdout, "", 1); + + expect(message).toContain("claude provider auth/config failed"); + expect(message).toContain("error=authentication_failed"); + expect(message).toContain("is_error=true"); + expect(message).toContain("reason=not-logged-in"); + expect(message).not.toContain("Please run /login"); + expect(claudeExitCode(stdout, "", 1)).toBe(4); + }); + it("omits Claude error.message from stdout failure signals", () => { const stdout = JSON.stringify({ type: "result", diff --git a/src/providers/claude.ts b/src/providers/claude.ts index 09d6e81..69f50b9 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -1,5 +1,5 @@ import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { runCommandArgs } from "../exec.js"; import { ClawpatchError } from "../errors.js"; @@ -27,6 +27,15 @@ import { const CLAUDE_DEFAULT_TIMEOUT_MS = 180_000; const CLAUDE_READ_ONLY_TOOLS = "Read,Grep,Glob"; const CLAUDE_WRITE_TOOLS = "default"; +const CLAUDE_SAFE_MODE_MIN_VERSION: [number, number, number] = [2, 1, 169]; +const CLAUDE_PUBLIC_STRING_ERROR_CODES = new Set(["authentication_failed"]); +const CLAUDE_AUTH_SMOKE_SCHEMA = { + type: "object", + properties: { ok: { type: "boolean", const: true } }, + required: ["ok"], + additionalProperties: false, +} as const; +type ClaudeAuthContext = "isolated" | "host"; const CLAUDE_AUTH_ENV_KEYS = [ "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", @@ -59,8 +68,10 @@ const CLAUDE_AUTH_ENV_KEYS = [ export const claudeProvider: Provider = { name: "claude", async check(root: string): Promise { + const authContext = claudeAuthContext(); const result = await runClaudeCommand(["--version"], root, undefined, { includeAuth: false, + authContext, timeoutMs: claudeTimeoutMs(), }); if (result.exitCode !== 0) { @@ -68,6 +79,27 @@ export const claudeProvider: Provider = { } const version = result.stdout.trim() || result.stderr.trim(); assertClaudeVersionAllowed(version); + assertClaudeAuthContextSupported(version, authContext); + if (authContext === "host") { + const output = await runClaudeJson( + root, + "Return ok=true to confirm this provider invocation is authenticated.", + { model: null, reasoningEffort: null, skipGitRepoCheck: false }, + CLAUDE_AUTH_SMOKE_SCHEMA, + true, + ); + if ( + typeof output !== "object" || + output === null || + (output as Record)["ok"] !== true + ) { + throw new ClawpatchError( + "claude provider auth smoke returned an invalid result", + 8, + "malformed-output", + ); + } + } return version; }, async map(root: string, prompt: string, options: ProviderOptions): Promise { @@ -103,11 +135,14 @@ async function runClaudeJson( schema: object, readOnly: boolean, ): Promise { - const version = await claudeVersion(root); + const authContext = claudeAuthContext(); + const version = await claudeVersion(root, authContext); assertClaudeVersionAllowed(version); - const args = claudeArgs(schema, options, readOnly); + assertClaudeAuthContextSupported(version, authContext); + const args = claudeArgs(schema, options, readOnly, authContext); const result = await runClaudeCommand(args, root, prompt, { includeAuth: true, + authContext, timeoutMs: claudeTimeoutMs(), }); if (result.exitCode !== 0) { @@ -120,9 +155,10 @@ async function runClaudeJson( return extractClaudeStructuredOutput(result.stdout); } -async function claudeVersion(root: string): Promise { +async function claudeVersion(root: string, authContext: ClaudeAuthContext): Promise { const result = await runClaudeCommand(["--version"], root, undefined, { includeAuth: false, + authContext, timeoutMs: claudeTimeoutMs(), }); if (result.exitCode !== 0) { @@ -131,7 +167,12 @@ async function claudeVersion(root: string): Promise { return result.stdout.trim() || result.stderr.trim(); } -function claudeArgs(schema: object, options: ProviderOptions, readOnly: boolean): string[] { +function claudeArgs( + schema: object, + options: ProviderOptions, + readOnly: boolean, + authContext: ClaudeAuthContext, +): string[] { const args = [ "-p", "--output-format", @@ -143,7 +184,7 @@ function claudeArgs(schema: object, options: ProviderOptions, readOnly: boolean) "--permission-mode", readOnly ? "dontAsk" : "acceptEdits", "--no-session-persistence", - "--bare", + authContext === "host" ? "--safe-mode" : "--bare", "--strict-mcp-config", "--mcp-config", JSON.stringify({ mcpServers: {} }), @@ -174,11 +215,11 @@ async function runClaudeCommand( args: string[], root: string, input: string | undefined, - options: { includeAuth: boolean; timeoutMs: number }, + options: { includeAuth: boolean; authContext: ClaudeAuthContext; timeoutMs: number }, ): Promise>> { const dir = await mkdtemp(join(tmpdir(), "clawpatch-claude-")); try { - const env = claudeEnv(options.includeAuth, dir); + const env = claudeEnv(options.includeAuth, dir, options.authContext); return await runCommandArgs(claudeExecutable(), args, root, input, { trimOutput: false, timeoutMs: options.timeoutMs, @@ -190,13 +231,23 @@ async function runClaudeCommand( } } -function claudeEnv(includeAuth: boolean, baseDir: string): NodeJS.ProcessEnv { +function claudeEnv( + includeAuth: boolean, + baseDir: string, + authContext: ClaudeAuthContext, +): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {}; copyPathEnv(env); copyEnv(env, "SystemRoot"); copyEnv(env, "ComSpec"); copyEnv(env, "PATHEXT"); - env["HOME"] = join(baseDir, "home"); + if (authContext === "host") { + env["HOME"] = process.env["HOME"]?.trim() || homedir(); + copyEnv(env, "USERPROFILE"); + copyEnv(env, "CLAUDE_CONFIG_DIR"); + } else { + env["HOME"] = join(baseDir, "home"); + } env["XDG_CONFIG_HOME"] = join(baseDir, "xdg-config"); env["XDG_CACHE_HOME"] = join(baseDir, "xdg-cache"); env["XDG_DATA_HOME"] = join(baseDir, "xdg-data"); @@ -214,10 +265,42 @@ function claudeEnv(includeAuth: boolean, baseDir: string): NodeJS.ProcessEnv { ) { copyEnv(env, "AWS_PROFILE"); } + if (authContext === "host") { + copyEnv(env, "CLAUDE_CODE_OAUTH_TOKEN"); + } } return env; } +function claudeAuthContext(): ClaudeAuthContext { + const configured = process.env["CLAWPATCH_CLAUDE_AUTH_CONTEXT"]?.trim().toLowerCase(); + if (configured === undefined || configured.length === 0 || configured === "isolated") { + return "isolated"; + } + if (configured === "host") { + return "host"; + } + throw new ClawpatchError( + "CLAWPATCH_CLAUDE_AUTH_CONTEXT must be isolated or host", + 4, + "provider-auth", + ); +} + +function assertClaudeAuthContextSupported(raw: string, authContext: ClaudeAuthContext): void { + if (authContext !== "host") { + return; + } + const parsed = parseClaudeVersion(raw); + if (parsed === null || compareSemanticVersions(parsed, CLAUDE_SAFE_MODE_MIN_VERSION) < 0) { + throw new ClawpatchError( + "Claude host auth context requires Claude Code 2.1.169 or newer for --safe-mode", + 4, + "provider-auth", + ); + } +} + function copyPathEnv(target: NodeJS.ProcessEnv): void { for (const key of Object.keys(process.env)) { if (key.toLowerCase() === "path") { @@ -290,7 +373,8 @@ function claudeStructuredOutput(value: unknown): { found: boolean; value: unknow function claudeEnvelopeErrorCode(error: unknown): string | null { if (typeof error === "string") { - return null; + const trimmed = error.trim(); + return CLAUDE_PUBLIC_STRING_ERROR_CODES.has(trimmed) ? trimmed : null; } if (typeof error !== "object" || error === null) { return null; @@ -374,25 +458,29 @@ function claudeFailureMessage(stdout: string, stderr: string, exitCode: number | if (exitCode === 124 || /timed out/iu.test(stderr)) { return "claude provider timed out"; } - const combined = `${stderr}\n${claudeFailureSignal(stdout)}`; + const signal = claudeFailureSignal(stdout); + const combined = `${stderr}\n${signal}`; if ( - /auth|login|api key|unauthorized|authentication|oauth|not authenticated|api_error_status=(?:401|403)\b/iu.test( + /auth|login|api key|unauthorized|authentication|oauth|not authenticated|not[- ]logged[- ]in|api_error_status=(?:401|403)\b/iu.test( combined, ) ) { - return "claude provider auth/config failed"; + return claudeFailureWithSignal("claude provider auth/config failed", signal); } if (/quota|rate.?limit|billing|credit|api_error_status=(?:402|429)\b/iu.test(combined)) { - return "claude provider quota/rate-limit failed"; + return claudeFailureWithSignal("claude provider quota/rate-limit failed", signal); } - const signal = claudeFailureSignal(stdout); return signal.length === 0 ? "claude provider failed" : `claude provider failed: ${signal}`; } +function claudeFailureWithSignal(message: string, signal: string): string { + return signal.length === 0 ? message : `${message}: ${signal}`; +} + function claudeExitCode(stdout: string, stderr: string, exitCode: number | null): number { const combined = `${stderr}\n${claudeFailureSignal(stdout)}`; if ( - /auth|login|api key|unauthorized|authentication|oauth|not authenticated|api_error_status=(?:401|403)\b/iu.test( + /auth|login|api key|unauthorized|authentication|oauth|not authenticated|not[- ]logged[- ]in|api_error_status=(?:401|403)\b/iu.test( combined, ) ) { @@ -418,6 +506,13 @@ function claudeFailureSignal(stdout: string): string { if (errorCode !== null) { parts.push(`error=${errorCode}`); } + if (record["is_error"] === true) { + parts.push("is_error=true"); + } + const result = record["result"]; + if (typeof result === "string" && /not logged in|please run \/login/iu.test(result)) { + parts.push("reason=not-logged-in"); + } for (const key of ["type", "subtype", "api_error_status", "terminal_reason"]) { const value = record[key]; if (typeof value === "string" && value.trim().length > 0) { @@ -427,7 +522,7 @@ function claudeFailureSignal(stdout: string): string { } } } - return parts.filter((part) => part.length > 0).join("; "); + return [...new Set(parts.filter((part) => part.length > 0))].slice(0, 12).join("; "); } function assertClaudeVersionAllowed(raw: string): void { @@ -462,8 +557,10 @@ function claudeTimeoutMs(): number { export const claudeTesting = { addClaudeModelArgs, + assertClaudeAuthContextSupported, assertClaudeVersionAllowed, claudeArgs, + claudeAuthContext, claudeEffort, claudeEnv, claudeExitCode,