fix(anthropic): forward selected profiles to Claude CLI (#112458)

* fix(anthropic): forward Claude CLI auth profiles

* fix(system-agent): inject CLI auth route stores

* fix(claude-cli): pass profile credentials by descriptor

* fix(anthropic): repair selected profile CI coverage

* fix(anthropic): preserve profile owner validation

* test(system-agent): preserve selected profile fixtures

* test(system-agent): narrow selected profile fixture

* test(system-agent): resolve profile store merge

* fix(anthropic): forward profiles to node Claude runs

* fix(system-agent): reconcile profile route projection

* test(system-agent): thread profile store through projection

* fix(anthropic): make selected profile authoritative

* fix(system-agent): type auth setup failures

* fix(system-agent): type setup auth failures

* style: format Claude profile maintenance

* fix(anthropic): keep gateway credentials off nodes

* fix(anthropic): clear ambient auth for selected profiles

* fix(anthropic): secure paired-node Claude auth

* fix(node-host): type Claude fd spawn streams

* style(node-host): satisfy Claude spawn lint

* fix(process): capture exit before secret delivery

* fix(anthropic): preserve node-native Claude auth
This commit is contained in:
Jason (Json)
2026-07-21 23:27:37 -06:00
committed by GitHub
parent 13716ad4f4
commit 1a42e005fb
30 changed files with 1252 additions and 75 deletions
@@ -59,6 +59,45 @@ type ClaudeCliNodeInvokeDeps = Pick<
) => Promise<void>;
};
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<string, string> | undefined;
childEnv: Record<string, string>;
}): { 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,
@@ -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<string, string>;
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",
+212 -2
View File
@@ -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<string, string>; timeoutMs?: number };
runCommand: (
argv: string[],
cwd: string | undefined,
env: Record<string, string> | undefined,
timeoutMs: number | undefined,
) => Promise<unknown>;
sendInvokeResult: (result: unknown) => Promise<void>;
}) => {
await options.runCommand(
options.params.command,
undefined,
{
...process.env,
...options.params.env,
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1",
} as Record<string, string>,
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<string, string> | undefined,
timeoutMs: number | undefined,
) => Promise<unknown>;
sendInvokeResult: (result: unknown) => Promise<void>;
}) => {
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<string, string>,
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<string, string>,
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;
+16 -3
View File
@@ -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<string, string> | undefined;
secretInput?: SpawnSecretInput;
timeoutMs: number | undefined;
signal?: AbortSignal;
}): Promise<RunResult> {
@@ -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));
});