Files
openclaw/src/node-host/invoke-agent-cli-claude-params.ts
Peter Steinberger d8d2f83cc1 feat(terminal): open Codex/Claude catalog sessions in a terminal on their owning host
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.
2026-07-13 22:20:50 -07:00

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