diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 9219a3cf217f..8855f2af0e57 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -319,6 +319,11 @@ If no MCP servers are enabled, OpenClaw still injects a strict config when a bac Session-scoped bundled MCP runtimes are cached for reuse within a session, then reaped after 10 minutes of idle time. One-shot embedded runs such as auth probes, slug generation, and active-memory recall request cleanup at run end so stdio children and Streamable HTTP/SSE streams do not outlive the run. +For `claude-cli`, a compatible selected or ordered OpenClaw OAuth/token profile +is forwarded to that Claude child. This makes per-agent profiles authoritative +for the turn while preserving Claude's native host login when no compatible +profile exists. + ## Reseed history cap When a fresh CLI session is seeded from a prior OpenClaw transcript (for example after a `session_expired` retry), the rendered `` block is capped to keep reseed prompts from exploding. The default is 12,288 characters (about 3,000 tokens). diff --git a/extensions/anthropic/cli-backend.ts b/extensions/anthropic/cli-backend.ts index 34390f22bbcb..ebe94aaa11fc 100644 --- a/extensions/anthropic/cli-backend.ts +++ b/extensions/anthropic/cli-backend.ts @@ -1,8 +1,12 @@ /** * Claude CLI backend descriptor. It configures Claude Code process arguments, - * MCP bundling, session handling, environment scrubbing, and watchdog defaults. + * MCP bundling, session handling, credential transport, and watchdog defaults. */ -import type { CliBackendPlugin } from "openclaw/plugin-sdk/cli-backend"; +import { createHmac, randomBytes } from "node:crypto"; +import type { + CliBackendPlugin, + CliBackendPreparedExecution, +} from "openclaw/plugin-sdk/cli-backend"; import { CLI_FRESH_WATCHDOG_DEFAULTS, CLI_RESUME_WATCHDOG_DEFAULTS, @@ -19,6 +23,86 @@ import { resolveClaudeCliRuntimeToolAvailability, } from "./cli-shared.js"; +type ClaudeCliAuthCredential = + | { type: "oauth"; access: string } + | { type: "token"; token: string } + | { type: "api_key"; key: string } + | { type: string }; + +type ClaudeCliPreparedExecution = CliBackendPreparedExecution & { + secretInput: { + fd: 3; + fingerprint: string; + createData: () => Buffer; + }; +}; + +const CLAUDE_CLI_CREDENTIAL_FINGERPRINT_KEY = randomBytes(32); + +function createClaudeCliAuthInput(params: { + envName: "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR" | "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR"; + value: string; +}): ClaudeCliPreparedExecution | undefined { + const trimmed = params.value.trim(); + if (!trimmed) { + return undefined; + } + const source = Buffer.from(trimmed, "utf8"); + let destroyed = false; + return { + env: { [params.envName]: "3" }, + clearEnv: [...CLAUDE_CLI_CLEAR_ENV], + secretInput: { + fd: 3, + fingerprint: createHmac("sha256", CLAUDE_CLI_CREDENTIAL_FINGERPRINT_KEY) + .update(source) + .digest("hex"), + createData: () => { + if (destroyed) { + throw new Error("Claude CLI credential input is no longer available"); + } + return Buffer.from(source); + }, + }, + cleanup: async () => { + destroyed = true; + source.fill(0); + }, + }; +} + +function resolveClaudeCliAuthInput( + credential: ClaudeCliAuthCredential | undefined, +): ClaudeCliPreparedExecution | undefined { + if ( + credential?.type === "oauth" && + "access" in credential && + typeof credential.access === "string" + ) { + return createClaudeCliAuthInput({ + envName: "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", + value: credential.access, + }); + } + if ( + credential?.type === "token" && + "token" in credential && + typeof credential.token === "string" + ) { + return createClaudeCliAuthInput({ + envName: "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", + value: credential.token, + }); + } + if (credential?.type === "api_key" && "key" in credential && typeof credential.key === "string") { + return createClaudeCliAuthInput({ + envName: "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", + value: credential.key, + }); + } + return undefined; +} + /** Build the Claude CLI backend plugin descriptor. */ export function buildAnthropicCliBackend(): CliBackendPlugin { return { @@ -106,10 +190,24 @@ export function buildAnthropicCliBackend(): CliBackendPlugin { serialize: true, }, normalizeConfig: normalizeClaudeBackendConfig, - autoSelectAuthProfile: false, - prepareExecution: ({ contextTokenBudget }) => { - const env = resolveClaudeCliAutoCompactEnv(contextTokenBudget); - return env ? { env } : undefined; + authEpochMode: "profile-only", + prepareExecution: (context) => { + const credentialContext = context as typeof context & { + authCredential?: ClaudeCliAuthCredential; + }; + const authInput = resolveClaudeCliAuthInput(credentialContext.authCredential); + const env = { + ...resolveClaudeCliAutoCompactEnv(context.contextTokenBudget), + ...authInput?.env, + }; + return Object.keys(env).length > 0 + ? { + env, + ...(authInput?.clearEnv ? { clearEnv: authInput.clearEnv } : {}), + ...(authInput?.secretInput ? { secretInput: authInput.secretInput } : {}), + ...(authInput?.cleanup ? { cleanup: authInput.cleanup } : {}), + } + : undefined; }, resolveExecutionArgs: resolveClaudeCliExecutionArgs, resolveRuntimeToolAvailability: resolveClaudeCliRuntimeToolAvailability, diff --git a/extensions/anthropic/cli-shared.test.ts b/extensions/anthropic/cli-shared.test.ts index e20344c011e2..46aa04bbc534 100644 --- a/extensions/anthropic/cli-shared.test.ts +++ b/extensions/anthropic/cli-shared.test.ts @@ -9,6 +9,17 @@ import { resolveClaudeCliRuntimeToolAvailability, } from "./cli-shared.js"; +type ClaudePreparedExecutionWithSecret = { + env?: Record; + clearEnv?: string[]; + cleanup?: () => Promise; + secretInput: { + fd: number; + fingerprint: string; + createData: () => Buffer; + }; +}; + const CLAUDE_CLI_DISALLOWED_TOOLS = "ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor"; @@ -702,6 +713,123 @@ describe("normalizeClaudeBackendConfig", () => { }); }); + it("forwards the selected OAuth profile through Claude's private descriptor", async () => { + const backend = buildAnthropicCliBackend(); + + const prepared = backend.prepareExecution?.({ + workspaceDir: "/tmp/openclaw-claude-cli", + provider: "claude-cli", + modelId: "claude-opus-4-7", + authProfileId: "anthropic:claude-cli", + authCredential: { + type: "oauth", + provider: "claude-cli", + access: "selected-access-token", + refresh: "selected-refresh-token", + expires: Date.now() + 60_000, + }, + } as Parameters>[0] & { + authCredential: { + type: "oauth"; + provider: string; + access: string; + refresh: string; + expires: number; + }; + }) as ClaudePreparedExecutionWithSecret; + + expect(prepared.env).toEqual({ + CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3", + }); + expect(prepared.env).not.toHaveProperty("CLAUDE_CODE_OAUTH_TOKEN"); + expect(prepared.env).not.toHaveProperty("CLAUDE_CODE_SUBPROCESS_ENV_SCRUB"); + expect(prepared.clearEnv).toEqual([...CLAUDE_CLI_CLEAR_ENV]); + expect(prepared.secretInput.fd).toBe(3); + expect(prepared.secretInput.fingerprint).not.toContain("selected-access-token"); + expect(prepared.secretInput.createData().toString("utf8")).toBe("selected-access-token"); + + const sameToken = backend.prepareExecution?.({ + workspaceDir: "/tmp/openclaw-claude-cli", + provider: "claude-cli", + modelId: "claude-opus-4-7", + authCredential: { + type: "token", + provider: "claude-cli", + token: "selected-access-token", + }, + } as Parameters>[0] & { + authCredential: { type: "token"; provider: string; token: string }; + }) as ClaudePreparedExecutionWithSecret; + const rotatedToken = backend.prepareExecution?.({ + workspaceDir: "/tmp/openclaw-claude-cli", + provider: "claude-cli", + modelId: "claude-opus-4-7", + authCredential: { + type: "token", + provider: "claude-cli", + token: "rotated-access-token", + }, + } as Parameters>[0] & { + authCredential: { type: "token"; provider: string; token: string }; + }) as ClaudePreparedExecutionWithSecret; + expect(sameToken.secretInput.fingerprint).toBe(prepared.secretInput.fingerprint); + expect(rotatedToken.secretInput.fingerprint).not.toBe(prepared.secretInput.fingerprint); + + await prepared.cleanup?.(); + await sameToken.cleanup?.(); + await rotatedToken.cleanup?.(); + expect(() => prepared.secretInput.createData()).toThrow( + "Claude CLI credential input is no longer available", + ); + }); + + it("keeps native Claude login when no compatible profile is selected", () => { + const backend = buildAnthropicCliBackend(); + + expect( + backend.prepareExecution?.({ + workspaceDir: "/tmp/openclaw-claude-cli", + provider: "claude-cli", + modelId: "claude-opus-4-7", + }), + ).toBeUndefined(); + }); + + it("forwards a selected API-key profile through Claude's private descriptor", async () => { + const backend = buildAnthropicCliBackend(); + + const prepared = backend.prepareExecution?.({ + workspaceDir: "/tmp/openclaw-claude-cli", + provider: "claude-cli", + modelId: "claude-opus-4-7", + authProfileId: "claude-cli:api", + authCredential: { + type: "api_key", + provider: "claude-cli", + key: "selected-api-key", + }, + } as Parameters>[0] & { + authCredential: { + type: "api_key"; + provider: string; + key: string; + }; + }) as ClaudePreparedExecutionWithSecret; + + expect(prepared.env).toEqual({ + CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR: "3", + }); + expect(prepared.env).not.toHaveProperty("ANTHROPIC_API_KEY"); + expect(prepared.env).not.toHaveProperty("CLAUDE_CODE_SUBPROCESS_ENV_SCRUB"); + expect(prepared.secretInput.fingerprint).not.toContain("selected-api-key"); + expect(prepared.secretInput.createData().toString("utf8")).toBe("selected-api-key"); + + await prepared.cleanup?.(); + expect(() => prepared.secretInput.createData()).toThrow( + "Claude CLI credential input is no longer available", + ); + }); + it("disables native background Bash and Monitor tools in args and resumeArgs", () => { const backend = buildAnthropicCliBackend(); diff --git a/src/agents/cli-execution-auth.ts b/src/agents/cli-execution-auth.ts index 0aac537f2ee6..7ac78f8762b7 100644 --- a/src/agents/cli-execution-auth.ts +++ b/src/agents/cli-execution-auth.ts @@ -8,12 +8,17 @@ import { resolveCliBackendConfig } from "./cli-backends.js"; const GOOGLE_GEMINI_CLI_PROVIDER_ID = "google-gemini-cli"; const GOOGLE_PROVIDER_ID = "google"; +const CLAUDE_CLI_PROVIDER_ID = "claude-cli"; type CliExecutionAuthProfileSelection = { authProfileId?: string; authProfileIdSource?: "auto" | "user"; }; +export class CliExecutionAuthProfileError extends Error { + override name = "CliExecutionAuthProfileError"; +} + export function cliBackendAcceptsAuthProfileForwarding(params: { provider: string; config: OpenClawConfig; @@ -22,14 +27,14 @@ export function cliBackendAcceptsAuthProfileForwarding(params: { const backend = resolveCliBackendConfig(params.provider, params.config, { agentId: params.agentId, }); - return backend?.id === GOOGLE_GEMINI_CLI_PROVIDER_ID; + return backend?.id === GOOGLE_GEMINI_CLI_PROVIDER_ID || backend?.id === CLAUDE_CLI_PROVIDER_ID; } /** - * Resolve the profile a CLI backend may consume. Gemini CLI prefers its own - * OAuth identity, then bridges a canonical Google API key when that model is - * explicitly routed through the CLI runtime. A user-locked profile must fail - * closed here; falling through would silently run the request as another user. + * Resolve the profile a CLI backend may consume. Claude and Gemini use their + * native profile identities; Gemini may additionally bridge a canonical + * Google API key. A user-locked profile must fail closed here because falling + * through would silently run the request as another user. */ export function resolveCliExecutionAuthProfileId(params: { cliExecutionProvider: string; @@ -37,8 +42,10 @@ export function resolveCliExecutionAuthProfileId(params: { config: OpenClawConfig; agentDir: string; selected?: CliExecutionAuthProfileSelection; + loadAuthProfileStoreForRuntime?: typeof loadAuthProfileStoreForRuntime; }): string | undefined { - const store = loadAuthProfileStoreForRuntime(params.agentDir, { + const loadStore = params.loadAuthProfileStoreForRuntime ?? loadAuthProfileStoreForRuntime; + const store = loadStore(params.agentDir, { readOnly: true, allowKeychainPrompt: false, externalCliProviderIds: [params.cliExecutionProvider], @@ -59,9 +66,11 @@ export function resolveCliExecutionAuthProfileId(params: { } if (params.selected?.authProfileIdSource !== "auto") { if (!credential) { - throw new Error(`No credentials found for profile "${selectedAuthProfileId}".`); + throw new CliExecutionAuthProfileError( + `No credentials found for profile "${selectedAuthProfileId}".`, + ); } - throw new Error( + throw new CliExecutionAuthProfileError( `CLI backend "${params.cliExecutionProvider}" cannot use auth profile "${selectedAuthProfileId}" owned by "${credential.provider}".`, ); } diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 37783d2c9f6c..87e1a87e9d06 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -517,14 +517,22 @@ describe("runCliAgent spawn path", () => { ], forkArg: "--fork-session", liveSession: "claude-stdio", + env: { ANTHROPIC_API_KEY: "configured-backend-key" }, + clearEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], systemPromptWhen: "always", }, + preparedEnv: { CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3" }, resolveExecutionArgs: (execution) => { toolAvailability = execution.toolAvailability; return [...execution.baseArgs]; }, cliToolAvailability: { native: [], mcp: ["mcp__openclaw__message"] }, }); + context.preparedBackend.secretInput = { + fd: 3, + fingerprint: "selected-node-token-fingerprint", + createData: () => Buffer.from("selected-node-token"), + }; context.openClawHistoryPrompt = "gateway transcript reseed"; context.claudeSkillsPluginArgs = ["--plugin-dir", "/tmp/gateway-skills"]; context.params.forkCliSessionOnResume = true; @@ -546,8 +554,14 @@ describe("runCliAgent spawn path", () => { stdin: "current turn", argv: expect.arrayContaining(["--resume", "source-node-session", "--fork-session"]), systemPrompt: "You are a helpful assistant.", + env: { CLAUDE_CODE_OAUTH_TOKEN: "selected-node-token" }, + clearEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], }), ); + expect(invokeNode.mock.calls[0]?.[0].env).not.toHaveProperty("ANTHROPIC_API_KEY"); + expect(invokeNode.mock.calls[0]?.[0].env).not.toHaveProperty( + "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB", + ); const argv = invokeNode.mock.calls[0]?.[0].argv ?? []; expect(argv).not.toContain("--mcp-config"); expect(argv).not.toContain("--permission-mode"); @@ -591,6 +605,7 @@ describe("runCliAgent spawn path", () => { resumeArgs: ["-p", "--output-format", "stream-json", "--resume", "{sessionId}"], forkArg: "--fork-session", liveSession: "claude-stdio", + env: { ANTHROPIC_API_KEY: "gateway-backend-key" }, systemPromptWhen: "always", }, }); @@ -598,6 +613,8 @@ describe("runCliAgent spawn path", () => { await expect(executePreparedCliRun(context, undefined)).rejects.toThrow( /truncated the Claude CLI stream before the terminal result/, ); + expect(invokeNode.mock.calls[0]?.[0].env).toBeUndefined(); + expect(invokeNode.mock.calls[0]?.[0].clearEnv).toBeUndefined(); }); it("cancels a node-placed Claude process when the run aborts", async () => { @@ -5851,6 +5868,34 @@ ${JSON.stringify({ expect(input.env?.SAFE_OVERRIDE).toBe("from-override"); }); + it("keeps selected Claude auth authoritative over ambient and configured credentials", async () => { + vi.stubEnv("OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV", '["ANTHROPIC_API_KEY"]'); + vi.stubEnv("ANTHROPIC_API_KEY", "ambient-api-key"); + mockSuccessfulClaudeJsonlRun(); + + await executePreparedCliRun( + buildPreparedCliRunContext({ + provider: "claude-cli", + model: "claude-sonnet-4-6", + runId: "run-claude-selected-auth-authority", + preparedEnv: { + CLAUDE_CODE_OAUTH_TOKEN: "selected-oauth-token", + CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1", + }, + backend: { + env: { ANTHROPIC_API_KEY: "configured-api-key" }, + clearEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], + }, + }), + ); + + const input = mockCallArg(supervisorSpawnMock) as { + env?: Record; + }; + expect(input.env?.ANTHROPIC_API_KEY).toBeUndefined(); + expect(input.env?.CLAUDE_CODE_OAUTH_TOKEN).toBe("selected-oauth-token"); + }); + it("clears claude-cli provider-routing, auth, telemetry, compaction, and host-managed env", async () => { vi.stubEnv("ANTHROPIC_BASE_URL", "https://proxy.example.com/v1"); vi.stubEnv("ANTHROPIC_API_TOKEN", "env-api-token"); diff --git a/src/agents/cli-runner/claude-live-session.background-tasks.test.ts b/src/agents/cli-runner/claude-live-session.background-tasks.test.ts index f5e4d74dcf9e..00bf6dbab985 100644 --- a/src/agents/cli-runner/claude-live-session.background-tasks.test.ts +++ b/src/agents/cli-runner/claude-live-session.background-tasks.test.ts @@ -51,6 +51,7 @@ function buildPreparedCliRunContext(params: { timeoutMs?: number; sessionId?: string; sessionKey?: string; + credentialFingerprint?: string; }): PreparedCliRunContext { const backend = { command: "claude", @@ -88,6 +89,15 @@ function buildPreparedCliRunContext(params: { preparedBackend: { backend, env: {}, + ...(params.credentialFingerprint + ? { + secretInput: { + fd: 3, + fingerprint: params.credentialFingerprint, + createData: () => Buffer.from("secret"), + }, + } + : {}), }, reusableCliSession: { mode: "none" }, hadSessionFile: false, @@ -166,10 +176,12 @@ function startLiveTurn(params: { noOutputTimeoutMs?: number; useResume?: boolean; onPhase?: (phase: "send" | "resolve") => void; + credentialFingerprint?: string; }) { const context = buildPreparedCliRunContext({ runId: params.runId, timeoutMs: params.timeoutMs, + credentialFingerprint: params.credentialFingerprint, }); return runClaudeLiveSessionTurn({ context, @@ -186,6 +198,41 @@ function startLiveTurn(params: { } describe("claude live session provisional results", () => { + it("reuses the same credential generation and restarts when it rotates", async () => { + const driver = installLiveStdoutDriver({ + onWrite: (stdout) => { + stdout( + jsonl([ + { type: "system", subtype: "init", session_id: "live-credential-rotation" }, + { + type: "result", + subtype: "success", + session_id: "live-credential-rotation", + result: "done", + }, + ]), + ); + }, + }); + + await startLiveTurn({ + runId: "run-credential-a-first", + credentialFingerprint: "credential-a", + }); + await startLiveTurn({ + runId: "run-credential-a-second", + credentialFingerprint: "credential-a", + }); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(1); + + await startLiveTurn({ + runId: "run-credential-b", + credentialFingerprint: "credential-b", + }); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); + expect(driver.cancel).toHaveBeenCalledOnce(); + }); + it.each([ { taskType: "local_agent", label: "subagent" }, { taskType: "local_workflow", label: "workflow" }, diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts index 7511dae41ed9..ebe081e0304f 100644 --- a/src/agents/cli-runner/claude-live-session.ts +++ b/src/agents/cli-runner/claude-live-session.ts @@ -426,6 +426,7 @@ function buildClaudeLiveFingerprint(params: { extraSystemPromptHash: params.context.extraSystemPromptHash, promptToolNamesHash: params.context.promptToolNamesHash, mcpConfigHash: params.context.preparedBackend.mcpConfigHash, + credentialFingerprint: params.context.preparedBackend.secretInput?.fingerprint, skillsFingerprint, argv: stableArgv, env: Object.keys(params.env) @@ -1323,6 +1324,7 @@ async function createClaudeLiveSession(params: { cwd: params.context.cwd ?? params.context.workspaceDir, env: mcpCaptureAttempt.env ?? params.env, stdinMode: "pipe-open", + secretInput: params.context.preparedBackend.secretInput, captureOutput: false, onStdout: (chunk) => { if (session) { diff --git a/src/agents/cli-runner/execute-node-claude.ts b/src/agents/cli-runner/execute-node-claude.ts index 26482a1458aa..9eca36a46ed1 100644 --- a/src/agents/cli-runner/execute-node-claude.ts +++ b/src/agents/cli-runner/execute-node-claude.ts @@ -170,6 +170,8 @@ export async function executeNodeClaudeRun(params: { executionArgs: string[]; stdinPayload: string; nodeSystemPrompt?: string; + nodeEnv?: Record; + nodeClearEnv?: string[]; noOutputTimeoutMs: number; consumeStdout: (chunk: string) => void; consumeStderr: (chunk: string) => void; @@ -230,6 +232,8 @@ export async function executeNodeClaudeRun(params: { stdin: params.stdinPayload, ...(params.nodePlacement.cwd ? { cwd: params.nodePlacement.cwd } : {}), ...(params.nodeSystemPrompt !== undefined ? { systemPrompt: params.nodeSystemPrompt } : {}), + ...(params.nodeEnv ? { env: params.nodeEnv } : {}), + ...(params.nodeClearEnv ? { clearEnv: params.nodeClearEnv } : {}), ...(contextParams.agentId ? { agentId: contextParams.agentId } : {}), ...(contextParams.sessionKey ? { sessionKey: contextParams.sessionKey } : {}), ...(approval diff --git a/src/agents/cli-runner/execute.supervisor-capture.test.ts b/src/agents/cli-runner/execute.supervisor-capture.test.ts index 6339e29e719f..2ffb8678b5d1 100644 --- a/src/agents/cli-runner/execute.supervisor-capture.test.ts +++ b/src/agents/cli-runner/execute.supervisor-capture.test.ts @@ -237,6 +237,34 @@ describe("executePreparedCliRun supervisor output capture", () => { expect(result.rawText).toBe(fullText); }); + it("passes prepared secret input to a one-shot child", async () => { + const context = buildPreparedCliRunContext({ output: "text", provider: "claude-cli" }); + const secretInput = { + fd: 3, + fingerprint: "credential-a", + createData: () => Buffer.from("secret"), + }; + context.preparedBackend.secretInput = secretInput; + supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { + const input = args[0] as SupervisorSpawnInput; + input.onStdout?.("done"); + return createManagedRun({ + reason: "exit", + exitCode: 0, + exitSignal: null, + durationMs: 50, + stdout: "", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }); + }); + + await executePreparedCliRun(context); + + expect(requireSupervisorSpawnInput()).toEqual(expect.objectContaining({ secretInput })); + }); + it("rejects oversized successful stdout instead of parsing a truncated tail", async () => { const noisyPrefix = "x".repeat(2 * 1024 * 1024); const finalText = "final answer"; @@ -2294,6 +2322,12 @@ describe("executePreparedCliRun supervisor output capture", () => { const context = buildPreparedCliRunContext({ output: "jsonl", provider: "claude-cli" }); context.mcpDeliveryCapture = true; context.preparedBackend.backend.liveSession = "claude-stdio"; + const secretInput = { + fd: 3, + fingerprint: "credential-a", + createData: () => Buffer.from("secret"), + }; + context.preparedBackend.secretInput = secretInput; const activateCapture = vi.fn<(captureKey: string) => void>(); const deactivateCapture = vi.fn<(captureKey: string) => void>(); context.preparedBackend.mcpClientGrantCapture = { @@ -2305,6 +2339,7 @@ describe("executePreparedCliRun supervisor output capture", () => { await expect(executePreparedCliRun(context)).rejects.toThrow("spawn failed"); expect(activateCapture).toHaveBeenCalledOnce(); + expect(requireSupervisorSpawnInput()).toEqual(expect.objectContaining({ secretInput })); expect(deactivateCapture).toHaveBeenCalledExactlyOnceWith(activateCapture.mock.calls[0]?.[0]); expect(activateCapture.mock.invocationCallOrder[0]).toBeLessThan( supervisorSpawnMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 2e6054efec68..9934abf2576e 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -249,6 +249,30 @@ const CLI_ENV_AUTH_LOG_KEYS = [ ] as const; const CLI_ENV_RUNTIME_LOG_KEYS = ["GEMINI_CLI_HOME", "GEMINI_CLI_SYSTEM_SETTINGS_PATH"] as const; +const CLAUDE_SELECTED_AUTH_ENV_KEYS = new Set(["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]); +const NODE_CLAUDE_FORWARD_ENV_KEYS = new Set(["CLAUDE_CODE_AUTO_COMPACT_WINDOW"]); + +function resolveNodeClaudeAuthEnv(context: PreparedCliRunContext): Record { + const secretInput = context.preparedBackend.secretInput; + if (!secretInput) { + return {}; + } + const descriptorEnv = context.preparedBackend.env ?? {}; + const requestEnv = Object.hasOwn(descriptorEnv, "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR") + ? "CLAUDE_CODE_OAUTH_TOKEN" + : Object.hasOwn(descriptorEnv, "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR") + ? "ANTHROPIC_API_KEY" + : undefined; + if (!requestEnv) { + return {}; + } + const data = secretInput.createData(); + try { + return { [requestEnv]: data.toString("utf8") }; + } finally { + data.fill(0); + } +} const CLI_BACKEND_PRESERVE_ENV = "OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV"; @@ -901,6 +925,30 @@ export async function executePreparedCliRun( captureKey: initialGatewayCaptureKey, }); cleanupMcpCaptureAttempt = mcpCaptureAttempt.cleanup; + const preparedBackendEnv = context.preparedBackend.env ?? {}; + const hasSelectedClaudeAuth = + Boolean(context.preparedBackend.secretInput) || + [...CLAUDE_SELECTED_AUTH_ENV_KEYS].some((key) => Object.hasOwn(preparedBackendEnv, key)); + const selectedClaudeClearEnv = hasSelectedClaudeAuth + ? new Set(backend.clearEnv ?? []) + : undefined; + const configuredBackendEnv = Object.fromEntries( + Object.entries(backend.env ?? {}).filter(([key]) => !selectedClaudeClearEnv?.has(key)), + ); + const backendEnv = { ...configuredBackendEnv, ...preparedBackendEnv }; + const nodeEnvEntries = Object.entries(preparedBackendEnv).filter(([key]) => + NODE_CLAUDE_FORWARD_ENV_KEYS.has(key), + ); + const nodeEnv = (() => { + if (!nodePlacement) { + return undefined; + } + const forwarded = { + ...Object.fromEntries(nodeEnvEntries), + ...resolveNodeClaudeAuthEnv(context), + }; + return Object.keys(forwarded).length > 0 ? forwarded : undefined; + })(); const env = (() => { const next = sanitizeHostExecEnv({ baseEnv: process.env, @@ -908,15 +956,11 @@ export async function executePreparedCliRun( }); const preservedEnv = parseCliBackendPreserveEnv(process.env[CLI_BACKEND_PRESERVE_ENV]); for (const key of backend.clearEnv ?? []) { - if (preservedEnv.has(key)) { + if (preservedEnv.has(key) && !selectedClaudeClearEnv?.has(key)) { continue; } delete next[key]; } - const backendEnv = { - ...backend.env, - ...context.preparedBackend.env, - }; if (Object.keys(backendEnv).length > 0) { Object.assign( next, @@ -1699,6 +1743,8 @@ export async function executePreparedCliRun( executionArgs, stdinPayload, ...(nodeSystemPrompt !== undefined ? { nodeSystemPrompt } : {}), + ...(nodeEnv ? { nodeEnv } : {}), + ...(selectedClaudeClearEnv ? { nodeClearEnv: [...selectedClaudeClearEnv] } : {}), noOutputTimeoutMs, consumeStdout, consumeStderr, @@ -1726,6 +1772,7 @@ export async function executePreparedCliRun( cwd: context.cwd ?? context.workspaceDir, env, input: stdinPayload, + secretInput: context.preparedBackend.secretInput, captureOutput: false, onStdout: consumeStdout, onStderr: consumeStderr, diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 3ce305d6d516..5439f454e332 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -187,6 +187,7 @@ function createCliBackendConfig( function setCliBackendForPrepareTest( params: { + authEpochMode?: CliBackendPlugin["authEpochMode"]; autoSelectAuthProfile?: boolean; bundleMcp?: boolean; command?: string; @@ -211,10 +212,12 @@ function setCliBackendForPrepareTest( pluginId: params.pluginId ?? "anthropic", modelProvider: params.modelProvider ?? "anthropic", bundleMcp: params.bundleMcp ?? false, + ...(params.authEpochMode ? { authEpochMode: params.authEpochMode } : {}), ...(params.bundleMcp ? { bundleMcpMode: "claude-config-file" as const } : {}), ...(params.autoSelectAuthProfile !== undefined ? { autoSelectAuthProfile: params.autoSelectAuthProfile } : {}), + ...(params.authEpochMode ? { authEpochMode: params.authEpochMode } : {}), ...(params.prepareExecution ? { prepareExecution: params.prepareExecution } : {}), config: { command: params.command ?? "claude", @@ -914,7 +917,7 @@ describe("prepareCliRunContext", () => { } }); - it("does not expose auth profile credentials to non-Gemini CLI prepare hooks", async () => { + it("does not expose auth profile credentials to non-bundled prepare hooks", async () => { const { dir, sessionFile } = createSessionFile(); const agentDir = path.join(dir, "agents", "main", "agent"); const authProfileId = "test-cli:secret"; @@ -978,6 +981,75 @@ describe("prepareCliRunContext", () => { } }); + it("refreshes and forwards a selected Claude CLI OAuth profile", async () => { + const { dir, sessionFile } = createSessionFile(); + const agentDir = path.join(dir, "agents", "main", "agent"); + const authProfileId = "anthropic:claude-cli"; + const prepareExecution = vi.fn(async () => undefined); + fs.mkdirSync(agentDir, { recursive: true }); + saveAuthProfileStore( + { + version: 1, + profiles: { + [authProfileId]: { + type: "oauth", + provider: "claude-cli", + access: "stored-access-token", + refresh: "stored-refresh-token", + expires: Date.now() + 60 * 60_000, + }, + }, + }, + agentDir, + ); + setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" }); + setCliRunnerPrepareTestDeps({ + resolveApiKeyForProfile: vi.fn(async () => ({ + apiKey: "stored-access-token", + provider: "claude-cli", + profileId: authProfileId, + profileType: "oauth", + credential: { + type: "oauth", + provider: "claude-cli", + access: "stored-access-token", + refresh: "stored-refresh-token", + expires: Date.now() + 60 * 60_000, + }, + })), + }); + + try { + await prepareCliRunContext({ + sessionId: "session-test", + sessionKey: "agent:main:main", + sessionFile, + workspaceDir: dir, + agentDir, + prompt: "latest ask", + provider: "claude-cli", + model: "sonnet", + timeoutMs: 1_000, + runId: "run-test-claude-profile-forwarding", + authProfileId, + config: {}, + }); + + expect(prepareExecution).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId, + authCredential: expect.objectContaining({ + type: "oauth", + provider: "claude-cli", + access: "stored-access-token", + }), + }), + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it.each([ { name: "keeps implicit profile selection for auth bridges", @@ -1043,6 +1115,43 @@ describe("prepareCliRunContext", () => { } }); + it("keeps bundled Claude secret input on the private prepared runner context", async () => { + const { dir, sessionFile } = createSessionFile(); + const secretInput = { + fd: 3, + fingerprint: "credential-a", + createData: () => Buffer.from("secret"), + }; + const prepareExecution = vi.fn(async () => ({ + env: { CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3" }, + secretInput, + })); + + try { + setCliBackendForPrepareTest({ + prepareExecution: prepareExecution as CliBackendPlugin["prepareExecution"], + }); + const context = await prepareCliRunContext({ + sessionId: "session-test", + sessionFile, + workspaceDir: dir, + prompt: "latest ask", + provider: "claude-cli", + model: "sonnet", + timeoutMs: 1_000, + runId: "run-test-private-secret-input", + config: {}, + }); + + expect(context.preparedBackend.secretInput).toBe(secretInput); + expect(context.preparedBackend.env).toMatchObject({ + CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3", + }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("lets Gemini CLI preparation override generated MCP system settings auth", async () => { const { dir, sessionFile } = createSessionFile(); const profileSystemSettingsPath = path.join(dir, "profile-system-settings.json"); @@ -3997,9 +4106,19 @@ describe("prepareCliRunContext", () => { message: { role: "user", content: "gateway-only history", timestamp: 1 }, }); try { + const prepareExecution = vi.fn(async () => ({ + env: { CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3" }, + secretInput: { + fd: 3, + fingerprint: "selected-node-token-fingerprint", + createData: () => Buffer.from("selected-node-token"), + }, + clearEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], + })); setCliBackendForPrepareTest({ bundleMcp: true, liveSession: true, + prepareExecution, reseedFromRawTranscriptWhenUncompacted: true, }); const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer); @@ -4075,6 +4194,16 @@ describe("prepareCliRunContext", () => { expect(prepareClaudeCliSkillsPlugin).not.toHaveBeenCalled(); expect(transcriptCheck).not.toHaveBeenCalled(); expect(orphanCheck).not.toHaveBeenCalled(); + expect(prepareExecution).toHaveBeenCalledOnce(); + expect(context.preparedBackend.env).toMatchObject({ + CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3", + }); + expect(context.preparedBackend.secretInput?.fingerprint).toBe( + "selected-node-token-fingerprint", + ); + expect(context.preparedBackend.backend.clearEnv).toEqual( + expect.arrayContaining(["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]), + ); } finally { fs.rmSync(dir, { recursive: true, force: true }); } diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index a33608d6ccc0..084ae7080b44 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -125,7 +125,16 @@ import { OPENCLAW_MCP_TOOL_PREFIX, resolveLoopbackToolsAllowFromMcpPermissions, } from "./tool-policy.js"; -import type { CliReusableSession, PreparedCliRunContext, RunCliAgentParams } from "./types.js"; +import type { + CliReusableSession, + CliSecretInput, + PreparedCliRunContext, + RunCliAgentParams, +} from "./types.js"; + +type PrivateCliBackendPreparedExecution = CliBackendPreparedExecution & { + secretInput?: CliSecretInput; +}; function resolveClaudeCliContextModelId(modelId: string): string { const trimmed = modelId.trim(); @@ -312,7 +321,7 @@ function shouldRefreshAuthProfileForExecution(params: { authCredential?: AuthProfileCredential; }): boolean { return Boolean( - params.backendId === "google-gemini-cli" && + (params.backendId === "google-gemini-cli" || params.backendId === "claude-cli") && params.authProfileId && (params.authCredential?.type === "oauth" || params.authCredential?.type === "api_key" || @@ -721,8 +730,7 @@ export async function prepareCliRunContext( params.cliToolAvailability?.mcp, ); let cleanupPreparedResources: (() => Promise) | undefined; - let preparedExecution: Awaited>> = - undefined; + let preparedExecution: PrivateCliBackendPreparedExecution | undefined; try { const mcpClientGrant = mcpLoopbackRuntime ? prepareDeps.mintMcpLoopbackClientGrant({ @@ -820,21 +828,20 @@ export async function prepareCliRunContext( executionMode, env: preparedBackend.env, } as Parameters>[0]; - preparedExecution = nodeClaudePlacement - ? undefined - : await backendResolved.prepareExecution?.( - (backendResolved.id === "google-gemini-cli" - ? { - ...prepareExecutionContext, - // Private bridge for bundled Gemini CLI. This is intentionally not - // part of the public Plugin SDK until a credential-forwarding - // contract exists. - authCredential, - } - : prepareExecutionContext) as typeof prepareExecutionContext & { - authCredential?: AuthProfileCredential; - }, - ); + preparedExecution = + (await backendResolved.prepareExecution?.( + (backendResolved.id === "google-gemini-cli" || backendResolved.id === "claude-cli" + ? { + ...prepareExecutionContext, + // Private bridge for bundled auth-owning CLI backends. This is intentionally not + // part of the public Plugin SDK until a credential-forwarding + // contract exists. + authCredential, + } + : prepareExecutionContext) as typeof prepareExecutionContext & { + authCredential?: AuthProfileCredential; + }, + )) ?? undefined; const preparedBackendCleanup = cleanupPreparedBackend || preparedExecution?.cleanup ? async () => { @@ -928,6 +935,7 @@ export async function prepareCliRunContext( ...(preparedBackendBeforeExecution ? { beforeExecution: preparedBackendBeforeExecution } : {}), + ...(preparedExecution?.secretInput ? { secretInput: preparedExecution.secretInput } : {}), ...(mcpClientGrantCapture ? { mcpClientGrantCapture } : {}), ...(preparedCleanup ? { cleanup: preparedCleanup } : {}), }; diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index a82cc5ca9adf..2b1c79e149cf 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -19,6 +19,7 @@ import type { ImageContent } from "../../llm/types.js"; import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js"; import type { CliBackendExecutionMode } from "../../plugins/cli-backend.types.js"; import type { PluginHookChannelContext } from "../../plugins/hook-types.js"; +import type { SpawnSecretInput } from "../../process/supervisor/types.js"; import type { InputProvenance } from "../../sessions/input-provenance.js"; import type { UserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js"; import type { SkillSnapshot } from "../../skills/types.js"; @@ -216,10 +217,17 @@ export type RunCliAgentParams = { }; /** Backend config after MCP, skill, env, and cleanup preparation. */ +export type CliSecretInput = SpawnSecretInput & { + /** Process-local non-secret generation used only to invalidate a warm child. */ + fingerprint: string; +}; + type CliPreparedBackend = { backend: CliBackendConfig; beforeExecution?: () => Promise; cleanup?: () => Promise; + /** Private child-only credential transport; never serialized into env or public plugin state. */ + secretInput?: CliSecretInput; /** Gateway-owned capture fence for this prepared bundle-MCP client. */ mcpClientGrantCapture?: { activate: (captureKey: string) => void; diff --git a/src/gateway/node-agent-cli-runtime.test.ts b/src/gateway/node-agent-cli-runtime.test.ts index 10443e59e239..71969c05328d 100644 --- a/src/gateway/node-agent-cli-runtime.test.ts +++ b/src/gateway/node-agent-cli-runtime.test.ts @@ -69,6 +69,8 @@ describe("invokeNodeClaudeCliRun", () => { nodeId: "node-1", argv: ["-p"], stdin: "hello", + env: { CLAUDE_CODE_OAUTH_TOKEN: "selected-node-token" }, + clearEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], timeoutMs: 10_000, idleTimeoutMs: 1_000, onProgress: () => {}, @@ -80,6 +82,10 @@ describe("invokeNodeClaudeCliRun", () => { expect.objectContaining({ expectedConnId: "conn-1", expectedPairingGeneration: "generation-1", + params: expect.objectContaining({ + env: { CLAUDE_CODE_OAUTH_TOKEN: "selected-node-token" }, + clearEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], + }), }), ); }); diff --git a/src/gateway/node-agent-cli-runtime.ts b/src/gateway/node-agent-cli-runtime.ts index 5bcf3aab9629..104dcfc06405 100644 --- a/src/gateway/node-agent-cli-runtime.ts +++ b/src/gateway/node-agent-cli-runtime.ts @@ -11,6 +11,8 @@ export async function invokeNodeClaudeCliRun(params: { argv: string[]; stdin: string; cwd?: string; + env?: Record; + clearEnv?: string[]; systemPrompt?: string; agentId?: string; sessionKey?: string; @@ -65,6 +67,8 @@ export async function invokeNodeClaudeCliRun(params: { argv: params.argv, stdin: params.stdin, ...(params.cwd ? { cwd: params.cwd } : {}), + ...(params.env ? { env: params.env } : {}), + ...(params.clearEnv ? { clearEnv: params.clearEnv } : {}), ...(params.systemPrompt !== undefined ? { systemPrompt: params.systemPrompt } : {}), ...(params.agentId ? { agentId: params.agentId } : {}), ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), diff --git a/src/node-host/invoke-agent-cli-claude-handler.ts b/src/node-host/invoke-agent-cli-claude-handler.ts index fb3be2f3cd50..e51a71ad7301 100644 --- a/src/node-host/invoke-agent-cli-claude-handler.ts +++ b/src/node-host/invoke-agent-cli-claude-handler.ts @@ -59,6 +59,45 @@ type ClaudeCliNodeInvokeDeps = Pick< ) => Promise; }; +const CLAUDE_NODE_AUTH_INPUTS = [ + { + requestEnv: "CLAUDE_CODE_OAUTH_TOKEN", + descriptorEnv: "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", + }, + { + requestEnv: "ANTHROPIC_API_KEY", + descriptorEnv: "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", + }, +] as const; + +function prepareClaudeNodeSecretInput(params: { + requestEnv: Record | undefined; + childEnv: Record; +}): { secretInput?: { fd: 3; createData: () => Buffer }; cleanup: () => void } { + const selected = CLAUDE_NODE_AUTH_INPUTS.find(({ requestEnv }) => + Object.hasOwn(params.requestEnv ?? {}, requestEnv), + ); + if (!selected) { + return { cleanup: () => {} }; + } + for (const key of [ + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB", + ]) { + delete params.childEnv[key]; + } + const source = Buffer.from(params.requestEnv?.[selected.requestEnv] ?? "", "utf8"); + params.childEnv[selected.descriptorEnv] = "3"; + return { + secretInput: { + fd: 3, + createData: () => Buffer.from(source), + }, + cleanup: () => source.fill(0), + }; +} + export async function handleClaudeCliNodeInvoke(params: { frame: NodeInvokeRequestPayload; client: NodeHostClient; @@ -137,16 +176,31 @@ export async function handleClaudeCliNodeInvoke(params: { isCmdExeInvocation: params.deps.isCmdExeInvocation, sanitizeEnv: params.deps.sanitizeEnv, runCommand: async (approvalArgv, cwd, env, timeoutMs) => { - runResult = await runClaudeCliNodeCommand({ - client: params.client, - frame: params.frame, - request, - argv: approvalArgv, - cwd, - env, - timeoutMs, - signal: params.runtime.signal, + const childEnv = { ...env }; + for (const key of request.clearEnv ?? []) { + if (!Object.hasOwn(request.env ?? {}, key)) { + delete childEnv[key]; + } + } + const preparedSecret = prepareClaudeNodeSecretInput({ + requestEnv: request.env, + childEnv, }); + try { + runResult = await runClaudeCliNodeCommand({ + client: params.client, + frame: params.frame, + request, + argv: approvalArgv, + cwd, + env: childEnv, + secretInput: preparedSecret.secretInput, + timeoutMs, + signal: params.runtime.signal, + }); + } finally { + preparedSecret.cleanup(); + } return runResult; }, runViaMacAppExecHost: params.deps.runViaMacAppExecHost, diff --git a/src/node-host/invoke-agent-cli-claude-params.ts b/src/node-host/invoke-agent-cli-claude-params.ts index a5f70eb5d5b3..6358d575b57c 100644 --- a/src/node-host/invoke-agent-cli-claude-params.ts +++ b/src/node-host/invoke-agent-cli-claude-params.ts @@ -50,13 +50,65 @@ const VALUE_ARGS = new Set([ "--disallowedTools", ]); -const ENV_ALLOWLIST = new Set(["FORCE_COLOR", "LANG", "LC_ALL", "LC_CTYPE", "NO_COLOR", "TERM"]); +const ENV_ALLOWLIST = new Set([ + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_AUTO_COMPACT_WINDOW", + "CLAUDE_CODE_OAUTH_TOKEN", + "FORCE_COLOR", + "LANG", + "LC_ALL", + "LC_CTYPE", + "NO_COLOR", + "TERM", +]); +const CLEAR_ENV_ALLOWLIST = new Set([ + "ANTHROPIC_API_KEY", + "ANTHROPIC_API_KEY_OLD", + "ANTHROPIC_API_TOKEN", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_CUSTOM_HEADERS", + "ANTHROPIC_OAUTH_TOKEN", + "ANTHROPIC_UNIX_SOCKET", + "CLAUDE_CONFIG_DIR", + "CLAUDE_CODE_AUTO_COMPACT_WINDOW", + "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_OAUTH_REFRESH_TOKEN", + "CLAUDE_CODE_OAUTH_SCOPES", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", + "CLAUDE_CODE_PLUGIN_CACHE_DIR", + "CLAUDE_CODE_PLUGIN_SEED_DIR", + "CLAUDE_CODE_REMOTE", + "CLAUDE_CODE_USE_COWORK_PLUGINS", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_FOUNDRY", + "CLAUDE_CODE_USE_VERTEX", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_HEADERS", + "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_HEADERS", + "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "OTEL_LOGS_EXPORTER", + "OTEL_METRICS_EXPORTER", + "OTEL_SDK_DISABLED", + "OTEL_TRACES_EXPORTER", +]); export type ClaudeCliNodeRunParams = { argv: string[]; stdin?: string; cwd?: string; env?: Record; + clearEnv?: string[]; systemPrompt?: string; agentId?: string; sessionKey?: string; @@ -172,6 +224,7 @@ export async function decodeClaudeCliNodeRunParams( "stdin", "cwd", "env", + "clearEnv", "systemPrompt", "agentId", "sessionKey", @@ -234,6 +287,25 @@ export async function decodeClaudeCliNodeRunParams( } env[key] = requireBoundedString(candidate, `env.${key}`, MAX_ARG_BYTES); } + if (Object.hasOwn(env, "ANTHROPIC_API_KEY") && Object.hasOwn(env, "CLAUDE_CODE_OAUTH_TOKEN")) { + throw new Error("INVALID_REQUEST: exactly one Claude credential may be provided"); + } + } + let clearEnv: string[] | undefined; + if (value.clearEnv !== undefined) { + if (!Array.isArray(value.clearEnv) || value.clearEnv.length > CLEAR_ENV_ALLOWLIST.size) { + throw new Error("INVALID_REQUEST: clearEnv must be a bounded array"); + } + clearEnv = []; + for (const candidate of value.clearEnv) { + const key = requireBoundedString(candidate, "clearEnv entry", MAX_ARG_BYTES); + if (!CLEAR_ENV_ALLOWLIST.has(key)) { + throw new Error(`INVALID_REQUEST: clearEnv key is not allowed: ${key}`); + } + if (!clearEnv.includes(key)) { + clearEnv.push(key); + } + } } return { argv, @@ -245,6 +317,7 @@ export async function decodeClaudeCliNodeRunParams( ...(systemRunPlan ? { systemRunPlan: systemRunPlan as SystemRunApprovalPlan } : {}), ...(cwd ? { cwd } : {}), ...(env ? { env } : {}), + ...(clearEnv ? { clearEnv } : {}), idleTimeoutMs: validateTimeout( value.idleTimeoutMs, "idleTimeoutMs", diff --git a/src/node-host/invoke-agent-cli-claude.test.ts b/src/node-host/invoke-agent-cli-claude.test.ts index d1ea301fa466..545e99c23ab3 100644 --- a/src/node-host/invoke-agent-cli-claude.test.ts +++ b/src/node-host/invoke-agent-cli-claude.test.ts @@ -84,7 +84,8 @@ describe("Claude CLI node command", () => { stdin: "hello", systemPrompt: "private prompt", cwd, - env: { NO_COLOR: "1" }, + env: { NO_COLOR: "1", CLAUDE_CODE_OAUTH_TOKEN: "selected-node-token" }, + clearEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], idleTimeoutMs: 1_000, timeoutMs: 2_000, }), @@ -93,7 +94,8 @@ describe("Claude CLI node command", () => { cwd, stdin: "hello", systemPrompt: "private prompt", - env: { NO_COLOR: "1" }, + env: { NO_COLOR: "1", CLAUDE_CODE_OAUTH_TOKEN: "selected-node-token" }, + clearEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], }); }); @@ -118,6 +120,39 @@ describe("Claude CLI node command", () => { }), ), ).rejects.toThrow("environment key is not allowed"); + await expect( + decodeClaudeCliNodeRunParams( + JSON.stringify({ + argv: ["-p"], + clearEnv: [["OPENCLAW", "GATEWAY", "TOKEN"].join("_")], + idleTimeoutMs: 1_000, + timeoutMs: 2_000, + }), + ), + ).rejects.toThrow("clearEnv key is not allowed"); + await expect( + decodeClaudeCliNodeRunParams( + JSON.stringify({ + argv: ["-p"], + env: { CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1" }, + idleTimeoutMs: 1_000, + timeoutMs: 2_000, + }), + ), + ).rejects.toThrow("environment key is not allowed"); + await expect( + decodeClaudeCliNodeRunParams( + JSON.stringify({ + argv: ["-p"], + env: { + ANTHROPIC_API_KEY: "selected-api-key", + CLAUDE_CODE_OAUTH_TOKEN: "selected-oauth-token", + }, + idleTimeoutMs: 1_000, + timeoutMs: 2_000, + }), + ), + ).rejects.toThrow("exactly one Claude credential"); }); it("requires binary availability before consulting exec approval policy", async () => { @@ -191,6 +226,128 @@ describe("Claude CLI node command", () => { }); }); + it("converts forwarded OAuth into a child-only descriptor after approval", async () => { + const executable = await executableScript(` +const fs = require("node:fs"); +const secret = fs.readFileSync(3, "utf8"); +process.stdout.write(JSON.stringify({ + type: "result", + result: secret, + descriptor: process.env.CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR, + rawPresent: Object.hasOwn(process.env, "CLAUDE_CODE_OAUTH_TOKEN"), + scrubPresent: Object.hasOwn(process.env, "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB"), +}) + "\\n");`); + const calls: Array<{ method: string; params: unknown }> = []; + const handleSystemRun = vi.fn( + async (options: { + params: { command: string[]; env?: Record; timeoutMs?: number }; + runCommand: ( + argv: string[], + cwd: string | undefined, + env: Record | undefined, + timeoutMs: number | undefined, + ) => Promise; + sendInvokeResult: (result: unknown) => Promise; + }) => { + await options.runCommand( + options.params.command, + undefined, + { + ...process.env, + ...options.params.env, + CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1", + } as Record, + options.params.timeoutMs, + ); + await options.sendInvokeResult({ ok: true }); + }, + ); + await handleInvoke( + frame({ + argv: ["-p"], + env: { CLAUDE_CODE_OAUTH_TOKEN: "selected-node-oauth" }, + clearEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], + idleTimeoutMs: 1_000, + timeoutMs: 5_000, + }), + client(calls), + { current: async () => [] }, + undefined, + { claudePath: executable, handleSystemRun: handleSystemRun as never }, + ); + + const progress = calls + .filter((call) => call.method === "node.invoke.progress") + .map((call) => (call.params as { chunk: string }).chunk) + .join(""); + expect(progress).toContain('"result":"selected-node-oauth"'); + expect(progress).toContain('"descriptor":"3"'); + expect(progress).toContain('"rawPresent":false'); + expect(progress).toContain('"scrubPresent":false'); + expect(calls).toContainEqual({ + method: "node.invoke.result", + params: expect.objectContaining({ + ok: true, + payloadJSON: expect.stringContaining('"exitCode":0'), + }), + }); + }); + + it("preserves node-native Claude auth when no profile credential is forwarded", async () => { + const executable = await executableScript(` +process.stdout.write(JSON.stringify({ + type: "result", + apiKey: process.env.ANTHROPIC_API_KEY, + oauth: process.env.CLAUDE_CODE_OAUTH_TOKEN, + scrub: process.env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB, +}) + "\\n");`); + const calls: Array<{ method: string; params: unknown }> = []; + const handleSystemRun = vi.fn( + async (options: { + params: { command: string[]; timeoutMs?: number }; + runCommand: ( + argv: string[], + cwd: string | undefined, + env: Record | undefined, + timeoutMs: number | undefined, + ) => Promise; + sendInvokeResult: (result: unknown) => Promise; + }) => { + await options.runCommand( + options.params.command, + undefined, + { + ...process.env, + ANTHROPIC_API_KEY: "node-native-api-key", + CLAUDE_CODE_OAUTH_TOKEN: "node-native-oauth", + CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1", + } as Record, + options.params.timeoutMs, + ); + await options.sendInvokeResult({ ok: true }); + }, + ); + await handleInvoke( + frame({ + argv: ["-p"], + idleTimeoutMs: 1_000, + timeoutMs: 5_000, + }), + client(calls), + { current: async () => [] }, + undefined, + { claudePath: executable, handleSystemRun: handleSystemRun as never }, + ); + + const progress = calls + .filter((call) => call.method === "node.invoke.progress") + .map((call) => (call.params as { chunk: string }).chunk) + .join(""); + expect(progress).toContain('"apiKey":"node-native-api-key"'); + expect(progress).toContain('"oauth":"node-native-oauth"'); + expect(progress).toContain('"scrub":"1"'); + }); + it("streams stdin-driven stdout and cleans up a node-local system prompt file", async () => { const executable = await executableScript(` const fs = require("node:fs"); @@ -236,6 +393,59 @@ process.stdin.on("end", () => { await expect(fs.stat(promptPath ?? "")).rejects.toThrow(); }); + it.each([ + { + descriptorEnv: "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", + rawEnv: "CLAUDE_CODE_OAUTH_TOKEN", + }, + { + descriptorEnv: "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", + rawEnv: "ANTHROPIC_API_KEY", + }, + ])( + "delivers selected credentials through fd 3 for $rawEnv", + async ({ descriptorEnv, rawEnv }) => { + const executable = await executableScript(` +const fs = require("node:fs"); +const secret = fs.readFileSync(3, "utf8"); +process.stdout.write(JSON.stringify({ + type: "result", + result: secret, + descriptor: process.env[${JSON.stringify(descriptorEnv)}], + rawPresent: Object.hasOwn(process.env, ${JSON.stringify(rawEnv)}), + scrubPresent: Object.hasOwn(process.env, "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB"), +}) + "\\n");`); + const request = { argv: ["-p"], idleTimeoutMs: 1_000, timeoutMs: 5_000 }; + const calls: Array<{ method: string; params: unknown }> = []; + const result = await runClaudeCliNodeCommand({ + client: client(calls), + frame: frame(request), + request, + argv: [executable, ...request.argv], + cwd: undefined, + env: { + ...process.env, + [descriptorEnv]: "3", + } as Record, + secretInput: { + fd: 3, + createData: () => Buffer.from("selected-node-secret"), + }, + timeoutMs: request.timeoutMs, + }); + + const progress = calls + .filter((call) => call.method === "node.invoke.progress") + .map((call) => (call.params as { chunk: string }).chunk) + .join(""); + expect(progress).toContain('"result":"selected-node-secret"'); + expect(progress).toContain('"descriptor":"3"'); + expect(progress).toContain('"rawPresent":false'); + expect(progress).toContain('"scrubPresent":false'); + expect(result).toMatchObject({ exitCode: 0, success: true }); + }, + ); + it("caps streamed output consistently with system.run", async () => { const executable = await executableScript( `let writes = 0; diff --git a/src/node-host/invoke-agent-cli-claude.ts b/src/node-host/invoke-agent-cli-claude.ts index 1691cc62ddfc..337bdfbcb15c 100644 --- a/src/node-host/invoke-agent-cli-claude.ts +++ b/src/node-host/invoke-agent-cli-claude.ts @@ -1,10 +1,16 @@ /** Validates and streams one approval-gated Claude CLI turn on a headless node. */ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { StringDecoder } from "node:string_decoder"; import { signalProcessTree } from "../process/kill-tree.js"; +import { + addSecretInputStdio, + type SpawnStdioEntry, + writeSecretInputToChild, +} from "../process/spawn-secret-input.js"; +import type { SpawnSecretInput } from "../process/supervisor/types.js"; import { resolveSafeChildProcessInvocation } from "../process/windows-command.js"; import { truncateUtf8Suffix } from "../utils/utf8-truncate.js"; import type { NodeHostClient } from "./client.js"; @@ -33,6 +39,7 @@ export async function runClaudeCliNodeCommand(params: { argv: string[]; cwd: string | undefined; env: Record | undefined; + secretInput?: SpawnSecretInput; timeoutMs: number | undefined; signal?: AbortSignal; }): Promise { @@ -79,14 +86,16 @@ export async function runClaudeCliNodeCommand(params: { cwd: params.cwd, env: params.env ?? process.env, }); + const stdio: SpawnStdioEntry[] = ["pipe", "pipe", "pipe"]; + addSecretInputStdio(stdio, params.secretInput); const child = spawn(invocation.command, invocation.args, { cwd: params.cwd, env: params.env, - stdio: ["pipe", "pipe", "pipe"], + stdio, ...(process.platform !== "win32" ? { detached: true } : {}), windowsHide: invocation.windowsHide, windowsVerbatimArguments: invocation.windowsVerbatimArguments, - }); + }) as ChildProcessWithoutNullStreams; const kill = () => { const pid = child.pid; @@ -252,6 +261,10 @@ export async function runClaudeCliNodeCommand(params: { truncated, }); }; + void writeSecretInputToChild(child, params.secretInput).catch((error: unknown) => { + kill(); + void finish(null, error instanceof Error ? error : new Error(String(error))); + }); child.once("error", (error) => void finish(null, error)); child.once("close", (code) => void finish(code)); }); diff --git a/src/process/spawn-secret-input.ts b/src/process/spawn-secret-input.ts new file mode 100644 index 000000000000..4ad8454a4cbe --- /dev/null +++ b/src/process/spawn-secret-input.ts @@ -0,0 +1,53 @@ +import type { ChildProcess } from "node:child_process"; +import type { Writable } from "node:stream"; +import type { SpawnSecretInput } from "./supervisor/types.js"; + +export type SpawnStdioEntry = "ignore" | "inherit" | "overlapped" | "pipe"; + +export function addSecretInputStdio( + stdio: SpawnStdioEntry[], + secretInput: SpawnSecretInput | undefined, +): void { + if (!secretInput) { + return; + } + if (!Number.isInteger(secretInput.fd) || secretInput.fd < 3) { + throw new Error("secret input file descriptor must be an integer greater than 2"); + } + while (stdio.length <= secretInput.fd) { + stdio.push("ignore"); + } + stdio[secretInput.fd] = process.platform === "win32" ? "overlapped" : "pipe"; +} + +export async function writeSecretInputToChild( + child: ChildProcess, + secretInput: SpawnSecretInput | undefined, +): Promise { + if (!secretInput) { + return; + } + const stream = child.stdio[secretInput.fd] as Writable | null | undefined; + if (!stream || typeof stream.end !== "function") { + throw new Error(`secret input file descriptor ${secretInput.fd} is unavailable`); + } + let data: Buffer | undefined; + try { + data = secretInput.createData(); + // End the parent pipe immediately after delivery so descendants cannot + // inherit a still-readable credential stream. + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + stream.off("error", onError); + reject(error); + }; + stream.once("error", onError); + stream.end(data, () => { + stream.off("error", onError); + resolve(); + }); + }); + } finally { + data?.fill(0); + } +} diff --git a/src/process/supervisor/adapters/child.test.ts b/src/process/supervisor/adapters/child.test.ts index b32e637fade0..fabcabe9cebb 100644 --- a/src/process/supervisor/adapters/child.test.ts +++ b/src/process/supervisor/adapters/child.test.ts @@ -4,7 +4,7 @@ import { EventEmitter } from "node:events"; import fs from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; -import { PassThrough } from "node:stream"; +import { PassThrough, Writable } from "node:stream"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js"; import { @@ -48,6 +48,10 @@ function createStubChild(pid = 1234) { child.stdin = new PassThrough() as ChildProcess["stdin"]; child.stdout = new PassThrough() as ChildProcess["stdout"]; child.stderr = new PassThrough() as ChildProcess["stderr"]; + Object.defineProperty(child, "stdio", { + value: [child.stdin, child.stdout, child.stderr], + configurable: true, + }); Object.defineProperty(child, "pid", { value: pid, configurable: true }); Object.defineProperty(child, "killed", { value: false, configurable: true, writable: true }); Object.defineProperty(child, "exitCode", { value: null, configurable: true, writable: true }); @@ -219,6 +223,94 @@ describe("createChildAdapter", () => { expect(killMock).toHaveBeenCalledWith("SIGKILL"); }); + it("writes secret input to an extra descriptor and zeroes the transient buffer", async () => { + const { child } = createStubChild(); + const secretStream = new PassThrough(); + const chunks: Buffer[] = []; + secretStream.on("data", (chunk: Buffer) => { + chunks.push(Buffer.from(chunk)); + }); + Object.defineProperty(child, "stdio", { + value: [child.stdin, child.stdout, child.stderr, secretStream], + configurable: true, + }); + spawnWithFallbackMock.mockResolvedValue({ + child, + usedFallback: false, + }); + const transient = Buffer.from("selected-secret", "utf8"); + + await createChildAdapter({ + argv: ["claude", "-p"], + stdinMode: "pipe-open", + secretInput: { + fd: 3, + createData: () => transient, + }, + }); + + expect(firstSpawnWithFallbackParams().options?.stdio).toEqual([ + "pipe", + "pipe", + "pipe", + process.platform === "win32" ? "overlapped" : "pipe", + ]); + expect(Buffer.concat(chunks).toString("utf8")).toBe("selected-secret"); + expect(transient.equals(Buffer.alloc(transient.length))).toBe(true); + }); + + it("captures child close while secret input delivery is still pending", async () => { + const { child, emitClose } = createStubChild(); + const secretStream = new Writable({ + write(_chunk, _encoding, callback) { + emitClose(0); + setImmediate(callback); + }, + }); + Object.defineProperty(child, "stdio", { + value: [child.stdin, child.stdout, child.stderr, secretStream], + configurable: true, + }); + spawnWithFallbackMock.mockResolvedValue({ + child, + usedFallback: false, + }); + + const adapter = await createChildAdapter({ + argv: ["claude", "-p"], + stdinMode: "pipe-open", + secretInput: { + fd: 3, + createData: () => Buffer.from("selected-secret"), + }, + }); + + await expect(adapter.wait()).resolves.toEqual({ code: 0, signal: null }); + }); + + it("uses overlapped I/O for a Windows secret descriptor", async () => { + setPlatform("win32"); + const { child } = createStubChild(); + Object.defineProperty(child, "stdio", { + value: [child.stdin, child.stdout, child.stderr, new PassThrough()], + configurable: true, + }); + spawnWithFallbackMock.mockResolvedValue({ + child, + usedFallback: false, + }); + + await createChildAdapter({ + argv: ["claude.exe", "-p"], + secretInput: { + fd: 3, + createData: () => Buffer.from("secret"), + }, + }); + + expect(firstSpawnWithFallbackParams().options?.stdio?.[3]).toBe("overlapped"); + }); + it("passes detached:false to signalProcessTree when spawn fell back to no-detach (#71662 follow-up)", async () => { // Simulate the fallback scenario: spawnWithFallback retried with // detached:false because the initial detached spawn failed. The kill diff --git a/src/process/supervisor/adapters/child.ts b/src/process/supervisor/adapters/child.ts index 6e067b9758aa..dac0152045fe 100644 --- a/src/process/supervisor/adapters/child.ts +++ b/src/process/supervisor/adapters/child.ts @@ -8,6 +8,11 @@ import { } from "../../../plugin-sdk/windows-spawn.js"; import { signalProcessTree } from "../../kill-tree.js"; import { prepareOomScoreAdjustedSpawn } from "../../linux-oom-score.js"; +import { + addSecretInputStdio, + type SpawnStdioEntry, + writeSecretInputToChild, +} from "../../spawn-secret-input.js"; import { spawnWithFallback } from "../../spawn-utils.js"; import { buildWindowsCmdExeCommandLine, @@ -15,7 +20,7 @@ import { resolveTrustedWindowsCmdExe, resolveWindowsCommandShim, } from "../../windows-command.js"; -import type { ManagedRunStdin, SpawnProcessAdapter } from "../types.js"; +import type { ManagedRunStdin, SpawnProcessAdapter, SpawnSecretInput } from "../types.js"; import { toStringEnv } from "./env.js"; const FORCE_KILL_WAIT_FALLBACK_MS = 4000; @@ -78,6 +83,7 @@ export async function createChildAdapter(params: { windowsVerbatimArguments?: boolean; input?: string; stdinMode?: "inherit" | "pipe-open" | "pipe-closed"; + secretInput?: SpawnSecretInput; }): Promise { const baseEnv = params.env ? toStringEnv(params.env) : undefined; const invocation = resolveChildInvocation({ @@ -96,19 +102,17 @@ export async function createChildAdapter(params: { // existing POSIX detached behavior. const useDetached = process.platform !== "win32" && !isServiceManagedRuntime(); + const stdio: SpawnStdioEntry[] = [stdinMode === "inherit" ? "inherit" : "pipe", "pipe", "pipe"]; + addSecretInputStdio(stdio, params.secretInput); + const options: SpawnOptions = { cwd: params.cwd, env: preparedSpawn.env, - stdio: ["pipe", "pipe", "pipe"], + stdio, detached: useDetached, windowsHide: true, windowsVerbatimArguments: invocation.windowsVerbatimArguments, }; - if (stdinMode === "inherit") { - options.stdio = ["inherit", "pipe", "pipe"]; - } else { - options.stdio = ["pipe", "pipe", "pipe"]; - } const spawned = await spawnWithFallback({ argv: [preparedSpawn.command, ...preparedSpawn.args], @@ -397,6 +401,15 @@ export async function createChildAdapter(params: { settleWait(resolveObservedExitState(childCloseState)); }); + if (params.secretInput) { + try { + await writeSecretInputToChild(spawned.child, params.secretInput); + } catch (error) { + spawned.child.kill("SIGKILL"); + throw error; + } + } + const wait = async () => { if (waitResult) { return waitResult; diff --git a/src/process/supervisor/supervisor.test.ts b/src/process/supervisor/supervisor.test.ts index a3392f73913a..4f532ed6b5a5 100644 --- a/src/process/supervisor/supervisor.test.ts +++ b/src/process/supervisor/supervisor.test.ts @@ -140,6 +140,26 @@ describe("process supervisor", () => { expect(adapter.disposeMock).toHaveBeenCalledTimes(1); }); + it("passes private secret input to the child adapter", async () => { + const adapter = createStubChildAdapter(); + createChildAdapterMock.mockResolvedValue(adapter); + const secretInput = { + fd: 3, + createData: () => Buffer.from("secret"), + }; + + const supervisor = createProcessSupervisor(); + const run = await spawnChild(supervisor, { + sessionId: "s1", + argv: createWriteStdoutArgv("ok"), + secretInput, + }); + adapter.settle(0); + await run.wait(); + + expect(createChildAdapterMock).toHaveBeenCalledWith(expect.objectContaining({ secretInput })); + }); + it("enforces no-output timeout for silent processes", async () => { vi.useFakeTimers(); const adapter = createStubChildAdapter({ diff --git a/src/process/supervisor/supervisor.ts b/src/process/supervisor/supervisor.ts index 9950e70ef09f..6f155c8922ef 100644 --- a/src/process/supervisor/supervisor.ts +++ b/src/process/supervisor/supervisor.ts @@ -211,6 +211,7 @@ export function createProcessSupervisor(): ProcessSupervisor { windowsVerbatimArguments: input.windowsVerbatimArguments, input: input.input, stdinMode: input.stdinMode, + secretInput: input.secretInput, }); registry.updateState(runId, "running", { pid: adapter.pid }); diff --git a/src/process/supervisor/types.ts b/src/process/supervisor/types.ts index d983d2441531..448a674bb990 100644 --- a/src/process/supervisor/types.ts +++ b/src/process/supervisor/types.ts @@ -58,6 +58,11 @@ export type ManagedRunStdin = { writableFinished?: boolean; }; +export type SpawnSecretInput = { + fd: number; + createData: () => Buffer; +}; + export type SpawnProcessAdapter = { pid?: number; stdin?: ManagedRunStdin; @@ -97,6 +102,7 @@ type SpawnChildInput = SpawnBaseInput & { windowsVerbatimArguments?: boolean; input?: string; stdinMode?: "inherit" | "pipe-open" | "pipe-closed"; + secretInput?: SpawnSecretInput; }; type SpawnPtyInput = SpawnBaseInput & { diff --git a/src/system-agent/inference-route.ts b/src/system-agent/inference-route.ts index a15e8aacc079..c41fd8235ff0 100644 --- a/src/system-agent/inference-route.ts +++ b/src/system-agent/inference-route.ts @@ -27,6 +27,7 @@ export type SystemAgentConfiguredRoute = { export type SystemAgentConfiguredRouteDeps = { readConfigFileSnapshot?: typeof import("../config/config.js").readConfigFileSnapshot; + loadAuthProfileStoreForRuntime?: typeof import("../agents/auth-profiles/store.js").loadAuthProfileStoreForRuntime; }; type DistributiveOmit = T extends unknown ? Omit : never; @@ -99,6 +100,7 @@ function projectSystemAgentExecutionConfig( export async function resolveSystemAgentConfiguredRouteFromConfig( runConfig: OpenClawConfig, requestedAgentId?: string, + deps: Pick = {}, ): Promise { const [agentScope, modelSelection, modelRuntimeAliases, simpleCompletion, harnessPolicy] = await Promise.all([ @@ -151,6 +153,9 @@ export async function resolveSystemAgentConfiguredRouteFromConfig( }, } : {}), + ...(deps.loadAuthProfileStoreForRuntime + ? { loadAuthProfileStoreForRuntime: deps.loadAuthProfileStoreForRuntime } + : {}), }) : undefined; const authProfileId = allowCliAuthProfileForwarding ? cliAuthProfileId : selection.profileId; @@ -211,6 +216,7 @@ export async function projectDefaultInferenceRoute( export async function projectInferenceRoute( config: OpenClawConfig, requestedAgentId?: string, + deps: Pick = {}, ): Promise { const [{ resolveDefaultAgentId }, { resolveProviderIdForAuth }] = await Promise.all([ import("../agents/agent-scope.js"), @@ -218,7 +224,7 @@ export async function projectInferenceRoute( ]); const defaultAgentId = resolveDefaultAgentId(config); const routeAgentId = normalizeAgentId(requestedAgentId ?? defaultAgentId); - const route = await resolveSystemAgentConfiguredRouteFromConfig(config, routeAgentId); + const route = await resolveSystemAgentConfiguredRouteFromConfig(config, routeAgentId, deps); const list = listAgentEntries(config); const agent = list.find((entry) => normalizeAgentId(entry.id) === routeAgentId); const executionAgent = listAgentEntries(route?.runConfig ?? {}).find( diff --git a/src/system-agent/setup-inference.ts b/src/system-agent/setup-inference.ts index 250dc857ae42..4d064a168e77 100644 --- a/src/system-agent/setup-inference.ts +++ b/src/system-agent/setup-inference.ts @@ -17,6 +17,7 @@ import { updateAuthProfileStoreWithLock, } from "../agents/auth-profiles/store.js"; import { resolveCliBackendConfig } from "../agents/cli-backends.js"; +import { CliExecutionAuthProfileError } from "../agents/cli-execution-auth.js"; import type { AgentExecutionAuthBinding } from "../agents/execution-auth-binding.js"; import { describeFailoverError } from "../agents/failover-error.js"; import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; @@ -1063,7 +1064,7 @@ async function buildTestPlan(params: { isRemoteProviderAuth?: boolean; routeAgentId?: string; deps: ActivateSetupInferenceDeps; -}): Promise { +}): Promise { const { kind, cfg, workspaceDir } = params; const resolveRouteModelRef = (defaultModelRef: string): string | { error: string } => { const modelRef = params.modelRef?.trim() || defaultModelRef; @@ -1208,7 +1209,17 @@ async function buildTestPlan(params: { } switch (kind) { case "existing-model": { - const route = await resolveSystemAgentConfiguredRouteFromConfig(cfg, params.routeAgentId); + let route; + try { + route = await resolveSystemAgentConfiguredRouteFromConfig(cfg, params.routeAgentId, { + loadAuthProfileStoreForRuntime: params.deps.loadAuthProfileStoreForRuntime, + }); + } catch (error) { + if (error instanceof CliExecutionAuthProfileError) { + return { error: error.message, status: "auth" as const }; + } + throw error; + } if (!route) { return { error: "No configured default-agent inference route is available." }; } @@ -1684,7 +1695,11 @@ async function activateSetupInferenceUnredacted( deps, }); if ("error" in plan) { - return { ok: false, status: "unavailable", error: plan.error }; + return { + ok: false, + status: plan.status ?? "unavailable", + error: plan.error, + }; } const hasPreparedAuthProfiles = (plan.manualAuth?.profiles.length ?? 0) > 0; @@ -2659,7 +2674,11 @@ export async function verifySetupInferenceConfig(params: { deps, }); if ("error" in builtPlan) { - return { ok: false, status: "unavailable", error: builtPlan.error }; + return { + ok: false, + status: builtPlan.status ?? "unavailable", + error: builtPlan.error, + }; } let plan: SetupInferenceTestPlan = builtPlan; if (params.authProfiles && params.authProfiles.length > 0) { @@ -2913,7 +2932,11 @@ export async function completeSetupInferenceConfig(params: { deps, }); if ("error" in plan) { - return { ok: false, status: "unavailable", error: plan.error }; + return { + ok: false, + status: plan.status ?? "unavailable", + error: plan.error, + }; } const result = await runSetupInferenceTest({ plan, diff --git a/src/system-agent/system-agent.test-helpers.ts b/src/system-agent/system-agent.test-helpers.ts index b0a70582385f..9c1d27bb5fc7 100644 --- a/src/system-agent/system-agent.test-helpers.ts +++ b/src/system-agent/system-agent.test-helpers.ts @@ -1,3 +1,4 @@ +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveCliBackendConfig } from "../agents/cli-backends.js"; // OpenClaw test helpers build runtime environments for rescue tests. import { @@ -6,6 +7,8 @@ import { fingerprintResolvedAuthProfileCredential, fingerprintResolvedProviderAuth, } from "../agents/execution-auth-binding.js"; +import { resolveCliRuntimeExecutionProvider } from "../agents/model-runtime-aliases.js"; +import { resolveSimpleCompletionSelectionForAgent } from "../agents/simple-completion-runtime.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveSystemAgentConfiguredRouteFromConfig } from "./inference-route.js"; @@ -24,7 +27,36 @@ type SystemAgentVerifiedInferenceTestFixture = { export async function createSystemAgentVerifiedInferenceTestFixture( config: OpenClawConfig, ): Promise { - const configuredRoute = await resolveSystemAgentConfiguredRouteFromConfig(config); + const routeAgentId = resolveDefaultAgentId(config); + const selection = resolveSimpleCompletionSelectionForAgent({ + cfg: config, + agentId: routeAgentId, + }); + const selectedProfileId = selection?.profileId; + const cliExecutionProvider = selection + ? resolveCliRuntimeExecutionProvider({ + provider: selection.provider, + cfg: config, + agentId: routeAgentId, + modelId: selection.modelId, + ...(selectedProfileId ? { authProfileId: selectedProfileId } : {}), + }) + : undefined; + const selectedCredential = + selectedProfileId && selection + ? ({ + type: "api_key", + provider: cliExecutionProvider ?? selection.runtimeProvider ?? selection.provider, + key: "test-key", + } as const) + : undefined; + const loadAuthProfileStoreForRuntime = (() => ({ + version: 1, + profiles: selectedProfileId ? { [selectedProfileId]: selectedCredential } : {}, + })) as never; + const configuredRoute = await resolveSystemAgentConfiguredRouteFromConfig(config, undefined, { + loadAuthProfileStoreForRuntime, + }); if (!configuredRoute) { throw new Error("missing test route"); } @@ -53,6 +85,7 @@ export async function createSystemAgentVerifiedInferenceTestFixture( configuredRoute.provider === "claude-cli" ? "anthropic" : undefined, ].filter((id, index, ids): id is string => Boolean(id) && ids.indexOf(id) === index); const deps: SystemAgentVerifiedInferenceDeps = { + loadAuthProfileStoreForRuntime, ensureAuthProfileStore: (() => ({ version: 1, profiles: profileId ? { [profileId]: credential } : {}, diff --git a/src/system-agent/verified-inference.test.ts b/src/system-agent/verified-inference.test.ts index 05ab9978215f..e8a6f992e9f3 100644 --- a/src/system-agent/verified-inference.test.ts +++ b/src/system-agent/verified-inference.test.ts @@ -576,20 +576,22 @@ describe("verified OpenClaw inference binding", () => { }, auth: { profiles: { [profileId]: { provider: "claude-cli", mode: "api_key" } } }, } satisfies OpenClawConfig; - const route = await resolveSystemAgentConfiguredRouteFromConfig(cliConfig); - if (!route || route.runner !== "cli" || route.authProfileId !== profileId) { - throw new Error("missing test CLI SecretRef route"); - } const credential = { type: "api_key" as const, provider: "claude-cli", keyRef: { source: "file" as const, provider: "vault", id: "/claude/work" }, }; - let activeKey = "materialized-a"; const ensureStore = vi.fn(() => ({ version: 1, profiles: { [profileId]: credential }, })) as never; + const route = await resolveSystemAgentConfiguredRouteFromConfig(cliConfig, undefined, { + loadAuthProfileStoreForRuntime: ensureStore, + }); + if (!route || route.runner !== "cli" || route.authProfileId !== profileId) { + throw new Error("missing test CLI SecretRef route"); + } + let activeKey = "materialized-a"; const resolveAuth = vi.fn(async () => ({ apiKey: activeKey, profileId, @@ -611,6 +613,7 @@ describe("verified OpenClaw inference binding", () => { deps: { ...pluginArtifactDeps(), ...cliRuntimeArtifactDeps(), + loadAuthProfileStoreForRuntime: ensureStore, ensureAuthProfileStore: ensureStore, resolveApiKeyForProvider: resolveAuth, resolveCliAuthBindingFingerprint: resolveBinding as never, @@ -634,6 +637,7 @@ describe("verified OpenClaw inference binding", () => { config: cliConfig, })) as never, ...cliRuntimeArtifactDeps(), + loadAuthProfileStoreForRuntime: ensureStore, ensureAuthProfileStore: ensureStore, resolveApiKeyForProvider: resolveAuth, resolveCliAuthBindingFingerprint: resolveBinding as never, diff --git a/src/system-agent/verified-inference.ts b/src/system-agent/verified-inference.ts index 4ab640589d9f..1c3ce582d7fe 100644 --- a/src/system-agent/verified-inference.ts +++ b/src/system-agent/verified-inference.ts @@ -404,7 +404,7 @@ async function projectVerifiedExecutionFingerprint( ownerPluginIds: readonly string[], deps: SystemAgentVerifiedInferenceDeps, ): Promise { - const projection = await projectInferenceRoute(config, route.agentId); + const projection = await projectInferenceRoute(config, route.agentId, deps); return { route: projection.route ? (() => { @@ -851,6 +851,7 @@ export async function resolveSystemAgentVerifiedInferenceRoute( const currentRoute = await resolveSystemAgentConfiguredRouteFromConfig( config, binding.execution.agentId, + deps, ); if ( !currentRoute ||