mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 15:31:32 +00:00
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:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}".`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
53
src/process/spawn-secret-input.ts
Normal file
53
src/process/spawn-secret-input.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user