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

View File

@@ -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 `<conversation_history>` block is capped to keep reseed prompts from exploding. The default is 12,288 characters (about 3,000 tokens).

View File

@@ -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,

View File

@@ -9,6 +9,17 @@ import {
resolveClaudeCliRuntimeToolAvailability,
} from "./cli-shared.js";
type ClaudePreparedExecutionWithSecret = {
env?: Record<string, string>;
clearEnv?: string[];
cleanup?: () => Promise<void>;
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<NonNullable<typeof backend.prepareExecution>>[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<NonNullable<typeof backend.prepareExecution>>[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<NonNullable<typeof backend.prepareExecution>>[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<NonNullable<typeof backend.prepareExecution>>[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();

View File

@@ -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}".`,
);
}

View File

@@ -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<string, string | undefined>;
};
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");

View File

@@ -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" },

View File

@@ -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) {

View File

@@ -170,6 +170,8 @@ export async function executeNodeClaudeRun(params: {
executionArgs: string[];
stdinPayload: string;
nodeSystemPrompt?: string;
nodeEnv?: Record<string, string>;
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

View File

@@ -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,

View File

@@ -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<string, string> {
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,

View File

@@ -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 });
}

View File

@@ -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<void>) | undefined;
let preparedExecution: Awaited<ReturnType<NonNullable<typeof backendResolved.prepareExecution>>> =
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<NonNullable<typeof backendResolved.prepareExecution>>[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 } : {}),
};

View File

@@ -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<void>;
cleanup?: () => Promise<void>;
/** 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;

View File

@@ -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"],
}),
}),
);
});

View File

@@ -11,6 +11,8 @@ export async function invokeNodeClaudeCliRun(params: {
argv: string[];
stdin: string;
cwd?: string;
env?: Record<string, string>;
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 } : {}),

View File

@@ -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,

View File

@@ -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",

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;

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));
});

View File

@@ -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<void> {
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<void>((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);
}
}

View File

@@ -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

View File

@@ -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<ChildAdapter> {
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;

View File

@@ -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({

View File

@@ -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 });

View File

@@ -58,6 +58,11 @@ export type ManagedRunStdin = {
writableFinished?: boolean;
};
export type SpawnSecretInput = {
fd: number;
createData: () => Buffer;
};
export type SpawnProcessAdapter<WaitSignal = NodeJS.Signals | number | null> = {
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 & {

View File

@@ -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, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
@@ -99,6 +100,7 @@ function projectSystemAgentExecutionConfig(
export async function resolveSystemAgentConfiguredRouteFromConfig(
runConfig: OpenClawConfig,
requestedAgentId?: string,
deps: Pick<SystemAgentConfiguredRouteDeps, "loadAuthProfileStoreForRuntime"> = {},
): Promise<SystemAgentConfiguredRoute | null> {
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<SystemAgentConfiguredRouteDeps, "loadAuthProfileStoreForRuntime"> = {},
): Promise<DefaultInferenceRouteProjection> {
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(

View File

@@ -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<SetupInferenceTestPlan | { error: string }> {
}): Promise<SetupInferenceTestPlan | { error: string; status?: SetupInferenceFailureStatus }> {
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,

View File

@@ -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<SystemAgentVerifiedInferenceTestFixture> {
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 } : {},

View File

@@ -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,

View File

@@ -404,7 +404,7 @@ async function projectVerifiedExecutionFingerprint(
ownerPluginIds: readonly string[],
deps: SystemAgentVerifiedInferenceDeps,
): Promise<SystemAgentVerifiedExecutionFingerprint> {
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 ||