mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-26 02:41:16 +00:00
Catalog session rows (sidebar context menu + click), the built-in viewer header, and a new "Open Codex/Claude sessions in" preference can launch the native CLI (codex resume / claude --resume) in the operator terminal on the machine that owns the session. - Gateway-local sessions spawn through the existing terminal launch policy (sandbox/enabled gates preserved) with the resume command in the session cwd. - Paired-node sessions run through a new seq-ordered node PTY relay: a duplex node-host command streams PTY output via node.invoke.progress and receives keystrokes/resize via a new node.invoke.input event, behind the unchanged terminal.* client protocol (TerminalSessionManager gains a backend abstraction; node relay reuses the streaming-invoke controller). - Owner boundary: each plugin owns its resume command and builds argv from a validated thread id; the gateway routes node opens through the node command allowlist and plugin invoke policy (no advertisement-only trust), and nodes re-verify session eligibility before spawning. - UI setting catalogOpenTarget + canOpenTerminal capability gate every entry point; capability requires the owning host to actually have the CLI. Node PATH is normalized before command-availability probes, Windows .cmd/.bat shims spawn via ComSpec, and catalog terminal opens reattach persisted tabs before opening the new tab.
257 lines
8.6 KiB
TypeScript
257 lines
8.6 KiB
TypeScript
import fs from "node:fs/promises";
|
|
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(["FORCE_COLOR", "LANG", "LC_ALL", "LC_CTYPE", "NO_COLOR", "TERM"]);
|
|
|
|
export type ClaudeCliNodeRunParams = {
|
|
argv: string[];
|
|
stdin?: string;
|
|
cwd?: string;
|
|
env?: Record<string, 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 asRecord(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
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",
|
|
"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);
|
|
}
|
|
}
|
|
return {
|
|
argv,
|
|
...(stdin !== undefined ? { stdin } : {}),
|
|
...(systemPrompt !== undefined ? { systemPrompt } : {}),
|
|
...(agentId ? { agentId } : {}),
|
|
...(sessionKey ? { sessionKey } : {}),
|
|
...(approvalDecision ? { approvalDecision } : {}),
|
|
...(systemRunPlan ? { systemRunPlan: systemRunPlan as SystemRunApprovalPlan } : {}),
|
|
...(cwd ? { cwd } : {}),
|
|
...(env ? { env } : {}),
|
|
idleTimeoutMs: validateTimeout(
|
|
value.idleTimeoutMs,
|
|
"idleTimeoutMs",
|
|
MIN_IDLE_TIMEOUT_MS,
|
|
MAX_IDLE_TIMEOUT_MS,
|
|
),
|
|
timeoutMs: validateTimeout(value.timeoutMs, "timeoutMs", 1, MAX_TIMEOUT_MS),
|
|
};
|
|
}
|