| 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; |
|
|
| |
| |
| |
| 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", |
| |
| |
| |
| |
| |
| "--tools", |
| "--disallowedTools", |
| ]); |
|
|
| const ENV_ALLOWLIST = new Set([ |
| "ANTHROPIC_API_KEY", |
| "CLAUDE_CODE_AUTO_COMPACT_WINDOW", |
| "CLAUDE_CODE_DISABLE_1M_CONTEXT", |
| "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_DISABLE_1M_CONTEXT", |
| "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 = { |
| |
| skillRuntime?: true; |
| 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; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| export function requestsClaudeNodeSkillRuntime(raw?: string | null): boolean { |
| if (!raw || Buffer.byteLength(raw, "utf8") > MAX_REQUEST_BYTES) { |
| return false; |
| } |
| try { |
| return asRecord(JSON.parse(raw))?.skillRuntime === true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| 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", |
| "skillRuntime", |
| ]); |
| 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); |
| if (value.skillRuntime !== undefined && value.skillRuntime !== true) { |
| throw new Error("INVALID_REQUEST: skillRuntime must be true when supplied"); |
| } |
| 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, |
| ...(value.skillRuntime === true ? { skillRuntime: true as const } : {}), |
| ...(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), |
| }; |
| } |
|
|