mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 01:51:39 -06:00
d8d2f83cc1
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.
164 lines
4.9 KiB
TypeScript
164 lines
4.9 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
|
|
import { spawnTerminalPty } from "../process/terminal-pty.js";
|
|
|
|
export type NodePtyCommandResult = { exitCode: number; signal?: number };
|
|
export type NodePtyResumeParams = {
|
|
threadId: string;
|
|
cwd?: string;
|
|
cols: number;
|
|
rows: number;
|
|
};
|
|
|
|
type NodePtyInput = { kind: "data"; data: string } | { kind: "resize"; cols: number; rows: number };
|
|
|
|
function resolvePtyCwd(candidate?: string): string {
|
|
if (candidate && path.isAbsolute(candidate)) {
|
|
try {
|
|
if (fs.statSync(candidate).isDirectory()) {
|
|
return candidate;
|
|
}
|
|
} catch {
|
|
// Missing/unreadable catalog cwd falls back to the node user's home.
|
|
}
|
|
}
|
|
return os.homedir();
|
|
}
|
|
|
|
function decodePtyInput(payloadJSON: string): NodePtyInput | null {
|
|
try {
|
|
const value = JSON.parse(payloadJSON) as unknown;
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return null;
|
|
}
|
|
const input = value as Record<string, unknown>;
|
|
if (input.kind === "data" && typeof input.data === "string") {
|
|
return { kind: "data", data: input.data };
|
|
}
|
|
if (
|
|
input.kind === "resize" &&
|
|
Number.isInteger(input.cols) &&
|
|
Number.isInteger(input.rows) &&
|
|
(input.cols as number) >= 1 &&
|
|
(input.cols as number) <= 2000 &&
|
|
(input.rows as number) >= 1 &&
|
|
(input.rows as number) <= 2000
|
|
) {
|
|
return { kind: "resize", cols: input.cols as number, rows: input.rows as number };
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function decodeNodePtyResumeParams(
|
|
paramsJSON: string | null | undefined,
|
|
validateThreadId: (value: unknown) => string,
|
|
): NodePtyResumeParams {
|
|
let value: unknown;
|
|
try {
|
|
value = JSON.parse(paramsJSON ?? "");
|
|
} catch {
|
|
throw new Error("INVALID_REQUEST: terminal resume params must be valid JSON");
|
|
}
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error("INVALID_REQUEST: terminal resume params must be an object");
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
const allowed = new Set(["threadId", "cwd", "cols", "rows"]);
|
|
const unknown = Object.keys(record).find((key) => !allowed.has(key));
|
|
if (unknown) {
|
|
throw new Error(`INVALID_REQUEST: unknown terminal resume parameter: ${unknown}`);
|
|
}
|
|
const dimension = (candidate: unknown, label: string) => {
|
|
if (!Number.isInteger(candidate) || (candidate as number) < 1 || (candidate as number) > 2000) {
|
|
throw new Error(`INVALID_REQUEST: ${label} must be an integer from 1 to 2000`);
|
|
}
|
|
return candidate as number;
|
|
};
|
|
if (
|
|
record.cwd !== undefined &&
|
|
(typeof record.cwd !== "string" || Buffer.byteLength(record.cwd, "utf8") > 4096)
|
|
) {
|
|
throw new Error("INVALID_REQUEST: cwd must be a bounded string");
|
|
}
|
|
return {
|
|
threadId: validateThreadId(record.threadId),
|
|
...(typeof record.cwd === "string" && record.cwd ? { cwd: record.cwd } : {}),
|
|
cols: dimension(record.cols, "cols"),
|
|
rows: dimension(record.rows, "rows"),
|
|
};
|
|
}
|
|
|
|
/** Runs one allowlisted plugin-owned command in an interactive node PTY. */
|
|
export async function runNodePtyCommand(
|
|
params: {
|
|
file: string;
|
|
args: string[];
|
|
cwd?: string;
|
|
cols: number;
|
|
rows: number;
|
|
},
|
|
io: OpenClawPluginNodeHostCommandIo,
|
|
spawn: typeof spawnTerminalPty = spawnTerminalPty,
|
|
): Promise<NodePtyCommandResult> {
|
|
if (io.signal.aborted) {
|
|
return { exitCode: 130 };
|
|
}
|
|
const env = Object.fromEntries(
|
|
Object.entries(process.env).filter(
|
|
(entry): entry is [string, string] => entry[1] !== undefined,
|
|
),
|
|
);
|
|
env.TERM ??= "xterm-256color";
|
|
env.OPENCLAW_TERMINAL = "1";
|
|
const pty = await spawn({
|
|
file: params.file,
|
|
args: params.args,
|
|
cwd: resolvePtyCwd(params.cwd),
|
|
env,
|
|
cols: params.cols,
|
|
rows: params.rows,
|
|
});
|
|
let outputQueue = Promise.resolve();
|
|
let settled = false;
|
|
const kill = () => pty.kill();
|
|
io.signal.addEventListener("abort", kill, { once: true });
|
|
if (io.signal.aborted) {
|
|
kill();
|
|
}
|
|
io.onInput((payloadJSON) => {
|
|
const input = decodePtyInput(payloadJSON);
|
|
if (input?.kind === "data") {
|
|
pty.write(input.data);
|
|
} else if (input?.kind === "resize") {
|
|
pty.resize(input.cols, input.rows);
|
|
}
|
|
});
|
|
pty.onData((chunk) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
pty.pause();
|
|
outputQueue = outputQueue.then(() => io.emitChunk(chunk)).finally(() => pty.resume());
|
|
});
|
|
return await new Promise<NodePtyCommandResult>((resolve) => {
|
|
pty.onExit((event) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
io.signal.removeEventListener("abort", kill);
|
|
void outputQueue.finally(() =>
|
|
resolve({
|
|
exitCode: event.exitCode,
|
|
...(event.signal ? { signal: event.signal } : {}),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
}
|