mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06: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:
@@ -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 & {
|
||||
|
||||
Reference in New Issue
Block a user