mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-03 00:41:32 +00:00
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
|
|
import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js";
|
|
|
|
const MAX_ARG_COUNT = 128;
|
|
const MAX_ARG_BYTES = 1024 * 1024;
|
|
const MAX_REQUEST_BYTES = 2 * 1024 * 1024;
|
|
const MAX_TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
|
const MIN_IDLE_TIMEOUT_MS = 1_000;
|
|
const MAX_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
|
|
// Mirror the Claude flags produced by extensions/anthropic/cli-shared.ts and
|
|
// cli-backend.ts. Node execution intentionally excludes file/plugin/MCP/tool
|
|
// flags so gateway-local paths can never escape onto the remote host.
|
|
const BARE_ARGS = new Set([
|
|
"-p",
|
|
"--print",
|
|
"--include-partial-messages",
|
|
"--verbose",
|
|
"--fork-session",
|
|
"--safe-mode",
|
|
"--bare",
|
|
"--no-chrome",
|
|
"--disable-slash-commands",
|
|
"--no-session-persistence",
|
|
"--exclude-dynamic-system-prompt-sections",
|
|
"--include-hook-events",
|
|
"--replay-user-messages",
|
|
]);
|
|
|
|
const VALUE_ARGS = new Set([
|
|
"--output-format",
|
|
"--input-format",
|
|
"--setting-sources",
|
|
"--permission-mode",
|
|
"--resume",
|
|
"-r",
|
|
"--session-id",
|
|
"--model",
|
|
"--effort",
|
|
"--max-turns",
|
|
"--fallback-model",
|
|
"--prompt-suggestions",
|
|
"--max-budget-usd",
|
|
// Native tool policy projected from the gateway. --allowedTools stays
|
|
// rejected: Claude reads it as auto-approval, which must not bypass this
|
|
// node's own approval surface. The gateway always sends these as one
|
|
// comma-joined value (resolveClaudeCliToolAvailabilityArgs); a multi-token
|
|
// variadic form fails closed as an unsupported argument.
|
|
"--tools",
|
|
"--disallowedTools",
|
|
]);
|
|
|
|
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;
|
|
approvalDecision?: "allow-once" | "allow-always";
|
|
systemRunPlan?: SystemRunApprovalPlan;
|
|
idleTimeoutMs: number;
|
|
timeoutMs: number;
|
|
};
|
|
|
|
export type ClaudeCliNodeRunResult = {
|
|
exitCode: number;
|
|
stderrTail: string;
|
|
truncated: boolean;
|
|
timeoutKind?: "hard" | "idle";
|
|
};
|
|
|
|
function decodeJson(raw?: string | null): unknown {
|
|
if (!raw) {
|
|
throw new Error("INVALID_REQUEST: paramsJSON required");
|
|
}
|
|
try {
|
|
return JSON.parse(raw) as unknown;
|
|
} catch {
|
|
throw new Error("INVALID_REQUEST: paramsJSON malformed JSON");
|
|
}
|
|
}
|
|
|
|
function requireBoundedString(value: unknown, label: string, maxBytes: number): string {
|
|
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > maxBytes) {
|
|
throw new Error(`INVALID_REQUEST: ${label} must be a bounded string`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** Claude CLI session ids are bounded, non-option argv values. */
|
|
export function validateClaudeSessionId(value: unknown): string {
|
|
const sessionId = requireBoundedString(value, "threadId", MAX_ARG_BYTES).trim();
|
|
if (!sessionId || sessionId.startsWith("-")) {
|
|
throw new Error("INVALID_REQUEST: threadId must be a Claude session id");
|
|
}
|
|
return sessionId;
|
|
}
|
|
|
|
function validateArgs(value: unknown): string[] {
|
|
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_ARG_COUNT) {
|
|
throw new Error("INVALID_REQUEST: argv must be a bounded non-empty array");
|
|
}
|
|
const args = value.map((entry, index) =>
|
|
requireBoundedString(entry, `argv[${index}]`, MAX_ARG_BYTES),
|
|
);
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
const equalsIndex = arg.indexOf("=");
|
|
const name = equalsIndex > 0 ? arg.slice(0, equalsIndex) : arg;
|
|
if (BARE_ARGS.has(arg)) {
|
|
continue;
|
|
}
|
|
if (!VALUE_ARGS.has(name)) {
|
|
throw new Error(`INVALID_REQUEST: unsupported Claude CLI argument: ${arg || "<empty>"}`);
|
|
}
|
|
if (equalsIndex > 0) {
|
|
const inlineValue = arg.slice(equalsIndex + 1);
|
|
if (!inlineValue || inlineValue.startsWith("-")) {
|
|
throw new Error(
|
|
`INVALID_REQUEST: Claude CLI argument requires a non-option value: ${name}`,
|
|
);
|
|
}
|
|
if (name === "--permission-mode" && inlineValue === "bypassPermissions") {
|
|
throw new Error("INVALID_REQUEST: bypassPermissions is not allowed for node agent runs");
|
|
}
|
|
continue;
|
|
}
|
|
if (index + 1 >= args.length) {
|
|
throw new Error(`INVALID_REQUEST: Claude CLI argument requires a value: ${name}`);
|
|
}
|
|
if (args[index + 1]?.startsWith("-")) {
|
|
throw new Error(`INVALID_REQUEST: Claude CLI argument requires a non-option value: ${name}`);
|
|
}
|
|
if (name === "--permission-mode" && args[index + 1] === "bypassPermissions") {
|
|
throw new Error("INVALID_REQUEST: bypassPermissions is not allowed for node agent runs");
|
|
}
|
|
index += 1;
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function validateTimeout(value: unknown, label: string, min: number, max: number): number {
|
|
if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) {
|
|
throw new Error(`INVALID_REQUEST: ${label} must be an integer from ${min} to ${max}`);
|
|
}
|
|
return value as number;
|
|
}
|
|
|
|
/** Decode the narrow, binary-free request accepted by the Claude node command. */
|
|
export async function decodeClaudeCliNodeRunParams(
|
|
raw?: string | null,
|
|
): Promise<ClaudeCliNodeRunParams> {
|
|
if (Buffer.byteLength(raw ?? "", "utf8") > MAX_REQUEST_BYTES) {
|
|
throw new Error("INVALID_REQUEST: Claude CLI request is too large");
|
|
}
|
|
const value = asRecord(decodeJson(raw));
|
|
if (!value) {
|
|
throw new Error("INVALID_REQUEST: Claude CLI params must be an object");
|
|
}
|
|
const allowed = new Set([
|
|
"argv",
|
|
"stdin",
|
|
"cwd",
|
|
"env",
|
|
"clearEnv",
|
|
"systemPrompt",
|
|
"agentId",
|
|
"sessionKey",
|
|
"approvalDecision",
|
|
"systemRunPlan",
|
|
"idleTimeoutMs",
|
|
"timeoutMs",
|
|
]);
|
|
const unknown = Object.keys(value).find((key) => !allowed.has(key));
|
|
if (unknown) {
|
|
throw new Error(`INVALID_REQUEST: unknown Claude CLI parameter: ${unknown}`);
|
|
}
|
|
const argv = validateArgs(value.argv);
|
|
const stdin =
|
|
value.stdin === undefined
|
|
? undefined
|
|
: requireBoundedString(value.stdin, "stdin", MAX_REQUEST_BYTES);
|
|
const systemPrompt =
|
|
value.systemPrompt === undefined
|
|
? undefined
|
|
: requireBoundedString(value.systemPrompt, "systemPrompt", MAX_REQUEST_BYTES);
|
|
const agentId =
|
|
value.agentId === undefined
|
|
? undefined
|
|
: requireBoundedString(value.agentId, "agentId", MAX_ARG_BYTES);
|
|
const sessionKey =
|
|
value.sessionKey === undefined
|
|
? undefined
|
|
: requireBoundedString(value.sessionKey, "sessionKey", MAX_ARG_BYTES);
|
|
const approvalDecision =
|
|
value.approvalDecision === "allow-once" || value.approvalDecision === "allow-always"
|
|
? value.approvalDecision
|
|
: undefined;
|
|
if (value.approvalDecision !== undefined && !approvalDecision) {
|
|
throw new Error("INVALID_REQUEST: approvalDecision is invalid");
|
|
}
|
|
const systemRunPlan =
|
|
value.systemRunPlan === undefined ? undefined : asRecord(value.systemRunPlan);
|
|
if (value.systemRunPlan !== undefined && !systemRunPlan) {
|
|
throw new Error("INVALID_REQUEST: systemRunPlan must be an object");
|
|
}
|
|
const cwd =
|
|
value.cwd === undefined ? undefined : requireBoundedString(value.cwd, "cwd", MAX_ARG_BYTES);
|
|
if (cwd) {
|
|
const stat = await fs.stat(cwd).catch(() => undefined);
|
|
if (!stat?.isDirectory()) {
|
|
throw new Error("INVALID_REQUEST: cwd must be an existing directory on the node");
|
|
}
|
|
}
|
|
let env: Record<string, string> | undefined;
|
|
if (value.env !== undefined) {
|
|
const envValue = asRecord(value.env);
|
|
if (!envValue) {
|
|
throw new Error("INVALID_REQUEST: env must be an object");
|
|
}
|
|
env = {};
|
|
for (const [key, candidate] of Object.entries(envValue)) {
|
|
if (!ENV_ALLOWLIST.has(key)) {
|
|
throw new Error(`INVALID_REQUEST: environment key is not allowed: ${key}`);
|
|
}
|
|
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,
|
|
...(stdin !== undefined ? { stdin } : {}),
|
|
...(systemPrompt !== undefined ? { systemPrompt } : {}),
|
|
...(agentId ? { agentId } : {}),
|
|
...(sessionKey ? { sessionKey } : {}),
|
|
...(approvalDecision ? { approvalDecision } : {}),
|
|
...(systemRunPlan ? { systemRunPlan: systemRunPlan as SystemRunApprovalPlan } : {}),
|
|
...(cwd ? { cwd } : {}),
|
|
...(env ? { env } : {}),
|
|
...(clearEnv ? { clearEnv } : {}),
|
|
idleTimeoutMs: validateTimeout(
|
|
value.idleTimeoutMs,
|
|
"idleTimeoutMs",
|
|
MIN_IDLE_TIMEOUT_MS,
|
|
MAX_IDLE_TIMEOUT_MS,
|
|
),
|
|
timeoutMs: validateTimeout(value.timeoutMs, "timeoutMs", 1, MAX_TIMEOUT_MS),
|
|
};
|
|
}
|