Files
openclaw/src/node-host/invoke-payload.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

71 lines
2.1 KiB
TypeScript

import type { NodeInvokeRequestPayload } from "./invoke-types.js";
const MAX_INVOKE_INPUT_BYTES = 16 * 1024;
export function coerceNodeInvokePayload(payload: unknown): NodeInvokeRequestPayload | null {
if (!payload || typeof payload !== "object") {
return null;
}
const obj = payload as Record<string, unknown>;
const id = typeof obj.id === "string" ? obj.id.trim() : "";
const nodeId = typeof obj.nodeId === "string" ? obj.nodeId.trim() : "";
const command = typeof obj.command === "string" ? obj.command.trim() : "";
if (!id || !nodeId || !command) {
return null;
}
const paramsJSON =
typeof obj.paramsJSON === "string"
? obj.paramsJSON
: obj.params !== undefined
? JSON.stringify(obj.params)
: null;
const timeoutMs = typeof obj.timeoutMs === "number" ? obj.timeoutMs : null;
const idempotencyKey = typeof obj.idempotencyKey === "string" ? obj.idempotencyKey : null;
return {
id,
nodeId,
command,
paramsJSON,
timeoutMs,
idempotencyKey,
};
}
export function coerceNodeInvokeCancelPayload(
payload: unknown,
): { invokeId: string; nodeId: string } | null {
const value =
payload && typeof payload === "object" && !Array.isArray(payload)
? (payload as Record<string, unknown>)
: null;
return value && typeof value.invokeId === "string" && typeof value.nodeId === "string"
? { invokeId: value.invokeId, nodeId: value.nodeId }
: null;
}
export function coerceNodeInvokeInputPayload(
payload: unknown,
): { invokeId: string; nodeId: string; seq: number; payloadJSON: string } | null {
const value =
payload && typeof payload === "object" && !Array.isArray(payload)
? (payload as Record<string, unknown>)
: null;
if (
!value ||
typeof value.id !== "string" ||
typeof value.nodeId !== "string" ||
!Number.isInteger(value.seq) ||
(value.seq as number) < 0 ||
typeof value.payloadJSON !== "string" ||
Buffer.byteLength(value.payloadJSON, "utf8") > MAX_INVOKE_INPUT_BYTES
) {
return null;
}
return {
invokeId: value.id,
nodeId: value.nodeId,
seq: value.seq as number,
payloadJSON: value.payloadJSON,
};
}