Files
openclaw/src/node-host/invoke-agent-cli-claude-params.ts
Peter Steinberger 3018e8b78a refactor: deduplicate micro helpers onto canonical homes (#113534)
* refactor: deduplicate micro helpers

* fix(ai): keep hash prefix helper internal
2026-07-25 00:41:01 -07:00

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