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.
This commit is contained in:
Peter Steinberger
2026-07-13 16:33:07 -07:00
parent 177ad6bb61
commit d8d2f83cc1
145 changed files with 4809 additions and 853 deletions
@@ -1,2 +1,2 @@
38de898c2b05a0895c4863373aa7128f073128cc6ace8bd5004203c3ee9735fd plugin-sdk-api-baseline.json
93ca4d93917be8be643293c2a11c58d2a7c138390c15f646991a223815ddec1e plugin-sdk-api-baseline.jsonl
6b509179fce1f5115037be7432b2dc21dda507351296ce5ca769e24741cece87 plugin-sdk-api-baseline.json
d317f5ef0a767f557357a2f7699e27e0f436edd18298401ee7a982805bcd1f76 plugin-sdk-api-baseline.jsonl
+12 -6
View File
@@ -295,17 +295,17 @@ state.
The node advertises the versioned read-only
`codex.appServer.threads.list.v1` and
`codex.appServer.thread.turns.list.v1` commands. Approve the node pairing
`codex.appServer.thread.turns.list.v1` commands. A native node host with the
Codex CLI available also advertises `codex.terminal.resume.v1`. Approve the node pairing
upgrade when those commands first appear. The Gateway invokes them through the
normal plugin node policy and isolates failures by host.
Paired-node rows appear as a **Codex** group in the normal sessions sidebar.
Selecting a row opens the normal Chat pane and reads its persisted transcript
By default, selecting a row opens the normal Chat pane and reads its persisted transcript
through bounded, cursor-paginated
`thread/turns/list` calls with full item projection. The node invoke transport is request/response only and cannot
carry the streaming turns, live events, or approvals required to continue a
native thread through the Codex harness. **Continue** and **Archive** are
therefore unavailable for remote rows. On the Gateway computer, stored and idle
`thread/turns/list` calls with full item projection. Use the row menu, the viewer header, or the **Open Codex/Claude sessions in** preference to start `codex resume <thread-id>` in the operator terminal on the computer that owns the session. The paired-node terminal path is an allowlisted PTY relay owned by the Codex plugin, not arbitrary node command execution.
The relay does not provide the full OpenClaw harness continuation and archive ownership contracts. **Continue** and **Archive** are therefore unavailable for remote rows. On the Gateway computer, stored and idle
rows can start a distinct model-locked Chat branch. Either can be archived only
after the operator confirms that no other Codex client is using it; a stored
row's live activity remains unknown. Active rows cannot branch or archive.
@@ -322,6 +322,12 @@ this needs no separate opt-in: a remote macOS app node advertises
when the Anthropic plugin is enabled and `~/.claude/projects/` exists. Approve
the node pairing upgrade when those commands first appear.
A native node host with the Claude CLI available also advertises
`anthropic.claude.terminal.resume.v1`. Eligible CLI and Desktop rows can open
`claude --resume <session-id>` in the operator terminal on their owning host.
This is a takeover of the native session; unlike OpenClaw adoption, it does not
fork the Claude session first.
The catalog combines valid Claude CLI project-index records with a bounded
metadata prefix from current `sdk-cli` JSONL files. Claude Desktop's local
metadata supplies Desktop titles and archive state. Desktop metadata wins when
+10 -7
View File
@@ -304,16 +304,19 @@ codex unarchive <thread-id>
Paired nodes expose the versioned read-only
`codex.appServer.threads.list.v1` and
`codex.appServer.thread.turns.list.v1` commands. The Gateway receives normalized
`codex.appServer.thread.turns.list.v1` commands. Native node hosts with the
Codex CLI available also expose the allowlisted `codex.terminal.resume.v1`
command. The Gateway receives normalized
metadata and explicitly requested bounded transcript pages, never raw App Server
endpoints. The current node invoke
transport is request/response only, so it cannot carry the long-lived event,
approval, and streaming lifecycle required by the Codex harness.
endpoints. Opening a row in the operator terminal runs `codex resume <thread-id>`
on the owning host and relays that command's PTY; it does not expose a general
shell or gateway-supplied argv.
For that reason, remote rows remain visible but do not offer **Continue** or
The terminal relay does not provide the harness continuation or archive ownership
contracts. Remote rows therefore remain visible but do not offer **Continue** or
**Archive**, even when the remote thread is idle. Use Codex on that computer
until a node-side streaming runner bridge exists for continuation and a safe
runner-ownership boundary exists for archive.
through **Open in terminal**, or use a future continuation flow with a safe
runner-ownership boundary.
## Metadata and permissions
+4
View File
@@ -319,6 +319,10 @@ The terminal is an unconfined host shell and inherits the Gateway process enviro
Use **Ctrl + backtick** to toggle the dock. The layout supports bottom and right docking, resizes with the browser viewport, and keeps multiple shell tabs. See [Gateway configuration](/gateway/configuration-reference#gateway) for `gateway.terminal.enabled` and the optional `gateway.terminal.shell` override.
Codex and Claude Code sessions discovered in the sessions sidebar can open in their native CLI inside the same terminal panel. In **Settings Chat**, set **Open Codex/Claude sessions in** to **Terminal** to make a normal row click open `codex resume` or `claude --resume`; the default remains the read-only OpenClaw viewer. A row's right-click or kebab menu always offers both choices, and the viewer header includes **Open in terminal** when that session is eligible.
Eligibility is per session and per host. Gateway-local sessions start the provider-owned resume command on the Gateway host. Paired-node sessions start an allowlisted provider command on the owning node and relay only that PTY's output, input, and resize events; this does not expose a general node shell or accept browser-supplied commands. Nodes that do not advertise the matching terminal-resume command, including embedded worker bridges without duplex streaming, keep the viewer available and show terminal opening as unavailable.
Sessions survive disconnects: a page reload, laptop sleep, or network blip detaches the session on the Gateway instead of killing it, and the same browser tab reattaches on reconnect with recent output replayed. Detached sessions are killed after `gateway.terminal.detachedSessionTimeoutSeconds` (default 300 seconds; `0` restores kill-on-disconnect). `terminal.list` shows attachable sessions, `terminal.attach` adopts one (tmux-style take-over), and `terminal.text` reads a session's recent output as plain text without attaching - an agent/tooling affordance.
The terminal is also available as a full-screen, terminal-only document at `/?view=terminal`. The iOS and Android apps embed this page in their Terminal screens, reusing the stored gateway credentials; availability follows the same `gateway.terminal.enabled` and `operator.admin` gate, and the page shows a notice when the connected Gateway does not offer the terminal.
@@ -1,18 +1,28 @@
import { statSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
decodeNodePtyResumeParams,
resolveExecutableFromPathEnv,
runNodePtyCommand,
validateClaudeSessionId,
} from "openclaw/plugin-sdk/node-host";
import type {
OpenClawPluginNodeHostCommand,
OpenClawPluginNodeInvokePolicy,
} from "openclaw/plugin-sdk/plugin-entry";
import {
CLAUDE_CLI_NODE_RUN_COMMAND,
CLAUDE_SESSION_READ_COMMAND,
CLAUDE_SESSIONS_LIST_COMMAND,
listLocalClaudeSessionPage,
readLocalClaudeTranscriptPage,
} from "./session-catalog.js";
CLAUDE_TERMINAL_RESUME_COMMAND,
isResumableClaudeSource,
} from "./session-catalog-shared.js";
import type { ClaudeSessionCatalogSession } from "./session-catalog-types.js";
import { listLocalClaudeSessionPage, readLocalClaudeTranscriptPage } from "./session-catalog.js";
const CLAUDE_SESSIONS_CAPABILITY = "claude-sessions";
const CLAUDE_NODE_LOOKUP_PAGE_LIMIT = 100;
// Nodes advertise the catalog commands only when this machine has a Claude
// Code session store; without it the gateway skips the node entirely.
@@ -36,6 +46,33 @@ function parseNodeParams(paramsJSON?: string | null): unknown {
}
}
async function requireLocalResumableClaudeSession(
threadId: string,
): Promise<ClaudeSessionCatalogSession> {
let cursor: string | undefined;
const seenCursors = new Set<string>();
while (true) {
const page = await listLocalClaudeSessionPage({
limit: CLAUDE_NODE_LOOKUP_PAGE_LIMIT,
...(cursor ? { cursor } : {}),
});
const record = page.sessions.find((candidate) => candidate.threadId === threadId);
if (record) {
if (isResumableClaudeSource(record.source)) {
return record;
}
break;
}
const nextCursor = page.nextCursor?.trim();
if (!nextCursor || seenCursors.has(nextCursor)) {
break;
}
seenCursors.add(nextCursor);
cursor = nextCursor;
}
throw new Error("Claude session cannot be resumed in a terminal");
}
export function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] {
return [
{
@@ -54,15 +91,53 @@ export function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCom
handle: async (paramsJSON) =>
JSON.stringify(await readLocalClaudeTranscriptPage(parseNodeParams(paramsJSON))),
},
{
command: CLAUDE_TERMINAL_RESUME_COMMAND,
cap: CLAUDE_SESSIONS_CAPABILITY,
dangerous: false,
duplex: true,
isAvailable: ({ env }) =>
claudeProjectsAvailable(env) &&
Boolean(resolveExecutableFromPathEnv("claude", env.PATH ?? "")),
handle: async (paramsJSON, io) => {
if (!io) {
throw new Error("Claude terminal command requires duplex transport");
}
const params = decodeNodePtyResumeParams(paramsJSON, validateClaudeSessionId);
const record = await requireLocalResumableClaudeSession(params.threadId);
const file = resolveExecutableFromPathEnv("claude", process.env.PATH ?? "");
if (!file) {
throw new Error("Claude CLI is unavailable");
}
return JSON.stringify(
await runNodePtyCommand(
{
file,
args: ["--resume", params.threadId],
cwd: record.cwd,
cols: params.cols,
rows: params.rows,
},
io,
),
);
},
},
];
}
export function createClaudeSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] {
return [
{
commands: [CLAUDE_SESSIONS_LIST_COMMAND, CLAUDE_SESSION_READ_COMMAND],
commands: [
CLAUDE_SESSIONS_LIST_COMMAND,
CLAUDE_SESSION_READ_COMMAND,
CLAUDE_CLI_NODE_RUN_COMMAND,
CLAUDE_TERMINAL_RESUME_COMMAND,
],
defaultPlatforms: ["macos", "linux", "windows"],
handle: (context) => context.invokeNode(),
handle: (context) =>
context.command === CLAUDE_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(),
},
];
}
@@ -0,0 +1,12 @@
// Dependency-free Claude catalog contracts shared by catalog and terminal ownership.
export const CLAUDE_SESSIONS_LIST_COMMAND = "anthropic.claude.sessions.list.v1";
export const CLAUDE_SESSION_READ_COMMAND = "anthropic.claude.sessions.read.v1";
export const CLAUDE_CLI_NODE_RUN_COMMAND = "agent.cli.claude.run.v1";
export const CLAUDE_TERMINAL_RESUME_COMMAND = "anthropic.claude.terminal.resume.v1";
export class ClaudeCatalogParamsError extends Error {}
// Desktop sessions share the resumable projects store with CLI sessions.
export function isResumableClaudeSource(source: string | undefined): boolean {
return source === "claude-cli" || source === "claude-desktop";
}
@@ -0,0 +1,136 @@
// Claude catalog terminal ownership: validated local and paired-node resume plans.
import fs from "node:fs/promises";
import { resolveExecutableFromPathEnv } from "openclaw/plugin-sdk/node-host";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import type { SessionCatalogTerminalPlan } from "openclaw/plugin-sdk/session-catalog";
import { CLAUDE_LOCAL_SESSION_HOST_ID } from "./session-catalog-adoption.js";
import {
CLAUDE_SESSIONS_LIST_COMMAND,
CLAUDE_TERMINAL_RESUME_COMMAND,
ClaudeCatalogParamsError,
isResumableClaudeSource,
} from "./session-catalog-shared.js";
export { isResumableClaudeSource } from "./session-catalog-shared.js";
type ClaudeTerminalDependencies = {
listClaudeSessions: () => Promise<
Array<{ threadId: string; source?: string; filePath: string; cwd?: string }>
>;
resolveNodeClaudeRecord: (params: {
runtime: OpenClawPluginApi["runtime"];
nodeId: string;
threadId: string;
}) => Promise<{ source?: string; cwd?: string }>;
};
export function isClaudeCliAvailable(pathEnv = process.env.PATH ?? ""): boolean {
return resolveExecutableFromPathEnv("claude", pathEnv) !== undefined;
}
export function claudeNodeTerminalCapability(node: {
connected?: boolean;
commands?: string[];
invocableCommands?: string[];
}): {
canOpenTerminalClaude?: true;
} {
const commands = node.invocableCommands ?? node.commands;
return node.connected === true && commands?.includes(CLAUDE_TERMINAL_RESUME_COMMAND) === true
? { canOpenTerminalClaude: true }
: {};
}
export function isLocalClaudeResumable(
host: { hostId: string },
source: string | undefined,
): boolean {
return host.hostId === CLAUDE_LOCAL_SESSION_HOST_ID && isResumableClaudeSource(source);
}
export function canOpenClaudeTerminalSession(
host: { hostId: string; canOpenTerminalClaude?: boolean },
source: string | undefined,
localCliAvailable: boolean,
): boolean {
return (
isResumableClaudeSource(source) &&
((host.hostId === CLAUDE_LOCAL_SESSION_HOST_ID && localCliAvailable) ||
host.canOpenTerminalClaude === true)
);
}
export function terminalEligibility(
host: { hostId: string; canOpenTerminalClaude?: boolean },
source: string | undefined,
localCliAvailable: boolean,
): { localResumable: boolean; canOpenTerminal: boolean } {
return {
localResumable: isLocalClaudeResumable(host, source),
canOpenTerminal: canOpenClaudeTerminalSession(host, source, localCliAvailable),
};
}
export async function openClaudeCatalogTerminal(
params: {
api: OpenClawPluginApi;
hostId: string;
threadId: string;
} & ClaudeTerminalDependencies,
): Promise<SessionCatalogTerminalPlan> {
const title = `claude --resume ${params.threadId.slice(0, 8)}`;
if (params.hostId === CLAUDE_LOCAL_SESSION_HOST_ID) {
const record = (await params.listClaudeSessions()).find(
(candidate) => candidate.threadId === params.threadId,
);
if (!record || !isResumableClaudeSource(record.source)) {
throw new ClaudeCatalogParamsError("Claude session is unavailable");
}
const source = await fs.stat(record.filePath).catch(() => undefined);
if (!source?.isFile()) {
throw new ClaudeCatalogParamsError("Claude session transcript is unavailable");
}
const executable = resolveExecutableFromPathEnv("claude", process.env.PATH ?? "");
if (!executable) {
throw new ClaudeCatalogParamsError("Claude CLI is unavailable");
}
return {
kind: "local",
argv: [executable, "--resume", params.threadId],
...(record.cwd ? { cwd: record.cwd } : {}),
title,
};
}
if (!params.hostId.startsWith("node:")) {
throw new ClaudeCatalogParamsError("hostId is invalid");
}
const nodeId = params.hostId.slice("node:".length);
const node = (await params.api.runtime.nodes.list()).nodes.find((candidate) => {
const commands = candidate.invocableCommands ?? candidate.commands;
return (
candidate.nodeId === nodeId &&
candidate.connected === true &&
commands?.includes(CLAUDE_SESSIONS_LIST_COMMAND) === true &&
commands.includes(CLAUDE_TERMINAL_RESUME_COMMAND)
);
});
if (!node) {
throw new ClaudeCatalogParamsError("paired-node Claude terminal is unavailable");
}
const record = await params.resolveNodeClaudeRecord({
runtime: params.api.runtime,
nodeId,
threadId: params.threadId,
});
if (!isResumableClaudeSource(record.source)) {
throw new ClaudeCatalogParamsError("Claude session cannot be resumed in a terminal");
}
return {
kind: "node",
nodeId,
command: CLAUDE_TERMINAL_RESUME_COMMAND,
paramsJSON: JSON.stringify({ threadId: params.threadId }),
...(record.cwd ? { cwd: record.cwd } : {}),
title,
};
}
@@ -0,0 +1,46 @@
import type { ClaudeTranscriptItem } from "./session-catalog-transcript.js";
type ClaudeSessionSource = "claude-cli" | "claude-desktop";
export type ClaudeSessionCatalogSession = {
threadId: string;
name?: string | null;
cwd?: string;
status: "stored";
createdAt?: number;
updatedAt?: number;
recencyAt?: number | null;
source: ClaudeSessionSource;
modelProvider: "anthropic";
cliVersion?: string;
gitBranch?: string;
archived: false;
};
export type ClaudeSessionCatalogPage = {
sessions: ClaudeSessionCatalogSession[];
nextCursor?: string;
};
export type ClaudeSessionCatalogHost = ClaudeSessionCatalogPage & {
hostId: string;
label: string;
kind: "gateway" | "node";
connected: boolean;
nodeId?: string;
canContinueClaude?: boolean;
canOpenTerminalClaude?: boolean;
error?: { code: string; message: string };
};
export type ClaudeSessionCatalogResult = {
hosts: ClaudeSessionCatalogHost[];
};
export type ClaudeSessionTranscriptPage = {
hostId: string;
label: string;
threadId: string;
items: ClaudeTranscriptItem[];
nextCursor?: string;
};
+200 -13
View File
@@ -7,12 +7,16 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog";
import { afterEach, describe, expect, it, vi } from "vitest";
import { adoptedSourceKey } from "./session-catalog-adoption.js";
import { createClaudeSessionNodeHostCommands } from "./session-catalog-node-commands.js";
import {
createClaudeSessionNodeHostCommands,
createClaudeSessionNodeInvokePolicies,
} from "./session-catalog-node-commands.js";
import { listBoundClaudeSessions } from "./session-catalog-runtime.js";
import {
CLAUDE_CLI_NODE_RUN_COMMAND,
CLAUDE_SESSIONS_LIST_COMMAND,
CLAUDE_SESSION_READ_COMMAND,
CLAUDE_TERMINAL_RESUME_COMMAND,
listClaudeSessionCatalog,
listLocalClaudeSessionPage,
readLocalClaudeTranscriptPage,
@@ -21,6 +25,15 @@ import {
const homes: string[] = [];
const originalHome = process.env.HOME;
const originalPath = process.env.PATH;
const nodeHostMocks = vi.hoisted(() => ({
runNodePtyCommand: vi.fn(async () => ({ exitCode: 0 })),
}));
vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/node-host")>()),
runNodePtyCommand: nodeHostMocks.runNodePtyCommand,
}));
async function createHome(): Promise<string> {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-claude-catalog-"));
@@ -89,7 +102,9 @@ function message(
}
afterEach(async () => {
nodeHostMocks.runNodePtyCommand.mockClear();
process.env.HOME = originalHome;
process.env.PATH = originalPath;
await Promise.all(homes.splice(0).map((home) => fs.rm(home, { recursive: true, force: true })));
});
@@ -467,21 +482,23 @@ describe("Claude session catalog", () => {
sessionId: "adopted-node-session",
entry: { sessionId: "adopted-node-session", updatedAt: 1 },
}));
const commands = [
CLAUDE_SESSIONS_LIST_COMMAND,
CLAUDE_SESSION_READ_COMMAND,
CLAUDE_CLI_NODE_RUN_COMMAND,
CLAUDE_TERMINAL_RESUME_COMMAND,
];
const authorizedCommands = new Set(
createClaudeSessionNodeInvokePolicies().flatMap((policy) => policy.commands),
);
expect(authorizedCommands).toEqual(new Set(commands));
const nodes = [
{
nodeId: "node-a",
displayName: "Node A",
connected: true,
commands: [
CLAUDE_SESSIONS_LIST_COMMAND,
CLAUDE_SESSION_READ_COMMAND,
CLAUDE_CLI_NODE_RUN_COMMAND,
],
invocableCommands: [
CLAUDE_SESSIONS_LIST_COMMAND,
CLAUDE_SESSION_READ_COMMAND,
CLAUDE_CLI_NODE_RUN_COMMAND,
],
commands,
invocableCommands: commands.filter((command) => authorizedCommands.has(command)),
},
];
const invoke = vi.fn(async ({ command }: { command: string }) => {
@@ -533,6 +550,15 @@ describe("Claude session catalog", () => {
expect(hosts?.[0]?.sessions[0]).toMatchObject({
threadId,
canContinue: true,
canOpenTerminal: true,
});
await expect(
provider?.openTerminal?.({ hostId: "node:node-a", threadId }),
).resolves.toMatchObject({
kind: "node",
nodeId: "node-a",
command: CLAUDE_TERMINAL_RESUME_COMMAND,
cwd: "/work/on-node",
});
await expect(provider?.continueSession?.({ hostId: "node:node-a", threadId })).resolves.toEqual(
{
@@ -561,6 +587,18 @@ describe("Claude session catalog", () => {
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({ command: CLAUDE_SESSION_READ_COMMAND }),
);
nodes[0]!.invocableCommands = [
CLAUDE_SESSIONS_LIST_COMMAND,
CLAUDE_SESSION_READ_COMMAND,
CLAUDE_CLI_NODE_RUN_COMMAND,
];
await expect(provider?.list({ hostIds: ["node:node-a"] })).resolves.toMatchObject([
{ sessions: [{ threadId, canOpenTerminal: false }] },
]);
await expect(provider?.openTerminal?.({ hostId: "node:node-a", threadId })).rejects.toThrow(
"paired-node Claude terminal is unavailable",
);
});
it("keeps policy-blocked, non-advertising, and Desktop rows view-only", async () => {
@@ -830,18 +868,60 @@ describe("Claude session catalog", () => {
expect(older.nextCursor).toBeUndefined();
});
it("registers read-only node commands only when a Claude store exists", async () => {
it("advertises terminal resume only when the store and Claude binary exist", async () => {
const home = await createHome();
const commands = createClaudeSessionNodeHostCommands();
expect(commands.map((command) => command.command)).toEqual([
CLAUDE_SESSIONS_LIST_COMMAND,
CLAUDE_SESSION_READ_COMMAND,
CLAUDE_TERMINAL_RESUME_COMMAND,
]);
expect(commands.every((command) => command.dangerous === false)).toBe(true);
const policy = createClaudeSessionNodeInvokePolicies()[0];
expect(policy?.commands).toEqual([
CLAUDE_SESSIONS_LIST_COMMAND,
CLAUDE_SESSION_READ_COMMAND,
CLAUDE_CLI_NODE_RUN_COMMAND,
CLAUDE_TERMINAL_RESUME_COMMAND,
]);
if (!policy) {
throw new Error("expected Claude node invoke policy");
}
const invokeNode = vi.fn(async () => ({ ok: true as const, payload: "listed" }));
expect(policy.handle({ command: CLAUDE_TERMINAL_RESUME_COMMAND, invokeNode } as never)).toEqual(
{ ok: true },
);
expect(invokeNode).not.toHaveBeenCalled();
await expect(
policy.handle({ command: CLAUDE_SESSIONS_LIST_COMMAND, invokeNode } as never),
).resolves.toEqual({ ok: true, payload: "listed" });
expect(invokeNode).toHaveBeenCalledOnce();
const availabilityContext = { config: {}, env: { HOME: home } } as never;
expect(commands.every((command) => command.isAvailable?.(availabilityContext))).toBe(false);
await fs.mkdir(path.join(home, ".claude", "projects"), { recursive: true });
expect(commands.every((command) => command.isAvailable?.(availabilityContext))).toBe(true);
expect(
commands.slice(0, 2).every((command) => command.isAvailable?.(availabilityContext)),
).toBe(true);
expect(commands[2]?.isAvailable?.(availabilityContext)).toBe(false);
const binDir = path.join(home, "bin");
await fs.mkdir(binDir);
await fs.writeFile(path.join(binDir, "claude"), "#!/bin/sh\n");
await fs.chmod(path.join(binDir, "claude"), 0o755);
expect(
commands[2]?.isAvailable?.({ config: {}, env: { HOME: home, PATH: binDir } } as never),
).toBe(true);
const terminalCommand = commands[2];
if (!terminalCommand || terminalCommand.duplex !== true) {
throw new Error("expected duplex Claude terminal command");
}
await expect(
terminalCommand.handle(JSON.stringify({ threadId: "--bad", cols: 80, rows: 24 }), {
signal: new AbortController().signal,
emitChunk: async () => {},
onInput: () => {},
}),
).rejects.toThrow("threadId must be a Claude session id");
const registerSessionCatalog = vi.fn();
const api = {
@@ -854,6 +934,113 @@ describe("Claude session catalog", () => {
);
});
it("resolves Claude terminal eligibility and cwd from the node-owned catalog", async () => {
const home = await createHome();
process.env.HOME = home;
const threadId = "node-owned-session";
await writeProject({
home,
entries: [
{
sessionId: threadId,
fullPath: path.join(home, ".claude", "projects", "-workspace", `${threadId}.jsonl`),
projectPath: "/node/catalog/cwd",
summary: "Node-owned session",
},
],
transcripts: { [threadId]: [message(threadId, "user", "hello", 1)] },
});
const binDir = path.join(home, "bin");
await fs.mkdir(binDir);
const executable = path.join(binDir, process.platform === "win32" ? "claude.cmd" : "claude");
await fs.writeFile(executable, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n");
if (process.platform !== "win32") {
await fs.chmod(executable, 0o755);
}
process.env.PATH = binDir;
const command = createClaudeSessionNodeHostCommands().find(
(candidate) => candidate.command === CLAUDE_TERMINAL_RESUME_COMMAND,
);
if (!command || command.duplex !== true) {
throw new Error("expected duplex Claude terminal command");
}
await command.handle(JSON.stringify({ threadId, cwd: "/caller/cwd", cols: 80, rows: 24 }), {
signal: new AbortController().signal,
emitChunk: async () => {},
onInput: () => {},
});
expect(nodeHostMocks.runNodePtyCommand).toHaveBeenCalledWith(
expect.objectContaining({ file: executable, cwd: "/node/catalog/cwd" }),
expect.any(Object),
);
await expect(
command.handle(JSON.stringify({ threadId: "missing", cols: 80, rows: 24 }), {
signal: new AbortController().signal,
emitChunk: async () => {},
onInput: () => {},
}),
).rejects.toThrow("Claude session cannot be resumed in a terminal");
});
it("requires the Claude binary for local terminal capability and plans", async () => {
const home = await createHome();
process.env.HOME = home;
const sessionId = "claude-session-1";
await writeProject({
home,
entries: [
{
sessionId,
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
projectPath: home,
summary: "Resume session",
},
],
transcripts: { [sessionId]: [message(sessionId, "user", "hello", 1)] },
});
const binDir = path.join(home, "bin");
await fs.mkdir(binDir);
process.env.PATH = binDir;
let provider: SessionCatalogProvider | undefined;
registerClaudeSessionCatalog({
id: "anthropic",
config: {},
runtime: {
config: { current: () => ({}) },
nodes: { list: async () => ({ nodes: [] }) },
agent: { session: { listSessionEntries: () => [] } },
},
registerSessionCatalog: (candidate: SessionCatalogProvider) => {
provider = candidate;
},
} as unknown as OpenClawPluginApi);
await expect(provider?.list({})).resolves.toMatchObject([
{ sessions: [{ threadId: sessionId, canOpenTerminal: false }] },
]);
await expect(
provider?.openTerminal?.({ hostId: "gateway:local", threadId: sessionId }),
).rejects.toThrow("Claude CLI is unavailable");
await fs.writeFile(path.join(binDir, "claude"), "#!/bin/sh\n");
await fs.chmod(path.join(binDir, "claude"), 0o755);
await expect(provider?.list({})).resolves.toMatchObject([
{ sessions: [{ threadId: sessionId, canOpenTerminal: true }] },
]);
await expect(
provider?.openTerminal?.({ hostId: "gateway:local", threadId: sessionId }),
).resolves.toMatchObject({
kind: "local",
argv: [path.join(binDir, "claude"), "--resume", sessionId],
cwd: home,
});
await expect(
provider?.openTerminal?.({ hostId: "gateway:local", threadId: "missing" }),
).rejects.toThrow("Claude session is unavailable");
});
it("keeps one failed node isolated from healthy hosts", async () => {
const runtime = {
nodes: {
+40 -65
View File
@@ -13,10 +13,6 @@ import type {
} from "openclaw/plugin-sdk/session-catalog";
import { withSessionTranscriptWriteLock } from "openclaw/plugin-sdk/session-transcript-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
export const CLAUDE_SESSIONS_LIST_COMMAND = "anthropic.claude.sessions.list.v1";
export const CLAUDE_SESSION_READ_COMMAND = "anthropic.claude.sessions.read.v1";
export const CLAUDE_CLI_NODE_RUN_COMMAND = "agent.cli.claude.run.v1";
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_MODEL_REF } from "./cli-constants.js";
import {
adoptedSessionKey,
@@ -28,13 +24,36 @@ import {
listBoundClaudeSessions,
resolveClaudeCatalogCreateSession,
} from "./session-catalog-runtime.js";
import {
CLAUDE_CLI_NODE_RUN_COMMAND,
CLAUDE_SESSION_READ_COMMAND,
CLAUDE_SESSIONS_LIST_COMMAND,
ClaudeCatalogParamsError,
isResumableClaudeSource,
} from "./session-catalog-shared.js";
import * as catalogTerminal from "./session-catalog-terminal.js";
import {
collectTranscriptText,
parseTranscriptLine,
type ClaudeTranscriptItem,
} from "./session-catalog-transcript.js";
import type {
ClaudeSessionCatalogHost,
ClaudeSessionCatalogPage,
ClaudeSessionCatalogResult,
ClaudeSessionCatalogSession,
ClaudeSessionTranscriptPage,
} from "./session-catalog-types.js";
export type { ClaudeTranscriptItem } from "./session-catalog-transcript.js";
export type {
ClaudeSessionCatalogHost,
ClaudeSessionCatalogPage,
ClaudeSessionCatalogResult,
ClaudeSessionCatalogSession,
ClaudeSessionTranscriptPage,
} from "./session-catalog-types.js";
export * from "./session-catalog-shared.js";
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 100;
@@ -57,57 +76,6 @@ const NODE_INVOKE_TIMEOUT_MS = 30_000;
const CLAUDE_HISTORY_IMPORT_MAX_ITEMS = 200;
const CLAUDE_HISTORY_IMPORT_MAX_BYTES = 512 * 1024;
type ClaudeSessionSource = "claude-cli" | "claude-desktop";
// Desktop-app sessions live in the same ~/.claude/projects store as CLI
// sessions (records without a projects JSONL are dropped during discovery),
// so `claude --resume --fork-session` continues both alike.
function isResumableClaudeSource(source: string | undefined): boolean {
return source === "claude-cli" || source === "claude-desktop";
}
export type ClaudeSessionCatalogSession = {
threadId: string;
name?: string | null;
cwd?: string;
status: "stored";
createdAt?: number;
updatedAt?: number;
recencyAt?: number | null;
source: ClaudeSessionSource;
modelProvider: "anthropic";
cliVersion?: string;
gitBranch?: string;
archived: false;
};
export type ClaudeSessionCatalogPage = {
sessions: ClaudeSessionCatalogSession[];
nextCursor?: string;
};
export type ClaudeSessionCatalogHost = ClaudeSessionCatalogPage & {
hostId: string;
label: string;
kind: "gateway" | "node";
connected: boolean;
nodeId?: string;
canContinueClaude?: boolean;
error?: { code: string; message: string };
};
export type ClaudeSessionCatalogResult = {
hosts: ClaudeSessionCatalogHost[];
};
export type ClaudeSessionTranscriptPage = {
hostId: string;
label: string;
threadId: string;
items: ClaudeTranscriptItem[];
nextCursor?: string;
};
type SessionIndexEntry = {
sessionId?: unknown;
fullPath?: unknown;
@@ -138,8 +106,6 @@ type CatalogRecord = ClaudeSessionCatalogSession & {
filePath: string;
};
class ClaudeCatalogParamsError extends Error {}
function optionalString(value: unknown, maxLength = MAX_STRING_LENGTH): string | undefined {
if (typeof value !== "string") {
return undefined;
@@ -481,7 +447,7 @@ async function discoverCliRecords(
}
}
async function listClaudeSessions(homeDir = currentHomeDir()): Promise<CatalogRecord[]> {
export async function listClaudeSessions(homeDir = currentHomeDir()): Promise<CatalogRecord[]> {
const [indexed, desktop] = await Promise.all([
readIndexRecords(homeDir),
readDesktopMetadata(homeDir),
@@ -973,6 +939,7 @@ export async function listClaudeSessionCatalog(params: {
node.invocableCommands?.includes(CLAUDE_SESSIONS_LIST_COMMAND) === true &&
node.invocableCommands.includes(CLAUDE_SESSION_READ_COMMAND) &&
node.invocableCommands.includes(CLAUDE_CLI_NODE_RUN_COMMAND),
...catalogTerminal.claudeNodeTerminalCapability(node),
};
if (node.connected !== true) {
return Object.assign(common, {
@@ -1167,7 +1134,7 @@ async function importClaudeHistory(params: {
const claudeContinueOperations = new Map<string, Promise<{ sessionKey: string }>>();
async function resolveNodeClaudeRecord(params: {
export async function resolveNodeClaudeRecord(params: {
runtime: PluginRuntime;
nodeId: string;
threadId: string;
@@ -1340,6 +1307,7 @@ function toGenericClaudeItem(item: ClaudeTranscriptItem): SessionCatalogTranscri
function toGenericClaudeHost(
host: ClaudeSessionCatalogHost,
adopted: ReadonlyMap<string, string>,
cliAvailable: boolean,
): SessionCatalogHost {
return {
hostId: host.hostId,
@@ -1348,16 +1316,14 @@ function toGenericClaudeHost(
connected: host.connected,
...(host.nodeId ? { nodeId: host.nodeId } : {}),
sessions: host.sessions.map((session) => {
const localResumable =
host.hostId === CLAUDE_LOCAL_SESSION_HOST_ID && isResumableClaudeSource(session.source);
const terminal = catalogTerminal.terminalEligibility(host, session.source, cliAvailable);
const nodeCli =
host.kind === "node" && host.canContinueClaude === true && session.source === "claude-cli";
const existingSessionKey = adopted.get(adoptedSourceKey(host.hostId, session.threadId));
// Already-adopted rows stay continuable even if node policy later denies
// the run command: continue only returns the existing session key, and
// the turn itself still fails closed at invoke time.
const continuable = localResumable || nodeCli || Boolean(existingSessionKey);
const openClawSessionKey = continuable ? existingSessionKey : undefined;
const continuable = terminal.localResumable || nodeCli || Boolean(existingSessionKey);
return {
threadId: session.threadId,
...(session.name ? { name: session.name } : {}),
@@ -1371,9 +1337,10 @@ function toGenericClaudeHost(
...(session.cliVersion ? { cliVersion: session.cliVersion } : {}),
...(session.gitBranch ? { gitBranch: session.gitBranch } : {}),
archived: session.archived,
...(openClawSessionKey ? { openClawSessionKey } : {}),
...(continuable && existingSessionKey ? { openClawSessionKey: existingSessionKey } : {}),
canContinue: continuable,
canArchive: false,
canOpenTerminal: terminal.canOpenTerminal,
};
}),
...(host.nextCursor ? { nextCursor: host.nextCursor } : {}),
@@ -1388,8 +1355,9 @@ export function registerClaudeSessionCatalog(api: OpenClawPluginApi): void {
resolveCreateSession: ({ agentId }) => resolveClaudeCatalogCreateSession(api, agentId),
list: async (query) => {
const adopted = listBoundClaudeSessions(api);
const localCliAvailable = catalogTerminal.isClaudeCliAvailable();
const result = await listClaudeSessionCatalog({ runtime: api.runtime, query });
return result.hosts.map((host) => toGenericClaudeHost(host, adopted));
return result.hosts.map((host) => toGenericClaudeHost(host, adopted, localCliAvailable));
},
read: async (request) => {
const page = await readClaudeSessionTranscript({
@@ -1403,6 +1371,13 @@ export function registerClaudeSessionCatalog(api: OpenClawPluginApi): void {
},
continueSession: async (request) =>
await continueClaudeSession(api, request.hostId, request.threadId),
openTerminal: (request) =>
catalogTerminal.openClaudeCatalogTerminal({
api,
...request,
listClaudeSessions,
resolveNodeClaudeRecord,
}),
};
api.registerSessionCatalog(provider);
}
@@ -20,11 +20,14 @@ import {
import {
catalogError,
CatalogParamsError,
CODEX_APP_SERVER_THREADS_LIST_COMMAND,
CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND,
CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT,
filterCatalogPageByTitle,
isInteractiveThreadSource,
MAX_ACTION_CATALOG_PAGES,
MAX_TRANSCRIPT_PAGE_LIMIT,
NODE_INVOKE_TIMEOUT_MS,
parseCatalogPage,
parseTranscriptPage,
unwrapNodeInvokePayload,
@@ -35,13 +38,6 @@ import type {
CodexSessionCatalogSession,
} from "./session-catalog-types.js";
export const CODEX_APP_SERVER_THREADS_LIST_COMMAND = "codex.appServer.threads.list.v1";
export const CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND = "codex.appServer.thread.turns.list.v1";
// A node may need a cold Codex state scan before returning a large catalog page.
// Keep this above the Mac node's native 60-second deadline so its specific
// timeout wins instead of the less useful generic node.invoke timeout.
export const NODE_INVOKE_TIMEOUT_MS = 65_000;
const CODEX_NODE_CONTINUE_COMMANDS = [
CODEX_APP_SERVER_THREADS_LIST_COMMAND,
CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND,
@@ -11,7 +11,13 @@ import type {
} from "./session-catalog-types.js";
const DEFAULT_PAGE_LIMIT = 50;
export const CODEX_APP_SERVER_THREADS_CAPABILITY = "codex-app-server-threads";
export const CODEX_APP_SERVER_THREADS_LIST_COMMAND = "codex.appServer.threads.list.v1";
export const CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND = "codex.appServer.thread.turns.list.v1";
export const CODEX_LOCAL_SESSION_HOST_ID = "gateway:local";
export const CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT = 100;
// Cold Codex state scans can outlive the Mac node's native 60-second deadline.
export const NODE_INVOKE_TIMEOUT_MS = 65_000;
const MAX_SEARCH_LENGTH = 500;
export const MAX_CURSOR_LENGTH = 4096;
const MAX_CURSOR_COUNT = 100;
@@ -0,0 +1,218 @@
// Codex catalog terminal ownership: validated resume commands and terminal plans.
import {
decodeNodePtyResumeParams,
resolveExecutableFromPathEnv,
runNodePtyCommand,
} from "openclaw/plugin-sdk/node-host";
import type {
OpenClawPluginApi,
OpenClawPluginNodeHostCommand,
} from "openclaw/plugin-sdk/plugin-entry";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import type { SessionCatalogTerminalPlan } from "openclaw/plugin-sdk/session-catalog";
import {
CatalogParamsError,
CODEX_APP_SERVER_THREADS_CAPABILITY,
CODEX_APP_SERVER_THREADS_LIST_COMMAND,
CODEX_LOCAL_SESSION_HOST_ID,
CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT,
isInteractiveThreadSource,
MAX_ACTION_CATALOG_PAGES,
NODE_INVOKE_TIMEOUT_MS,
unwrapNodeInvokePayload,
} from "./session-catalog-parsing.js";
import type {
CodexSessionCatalogControl,
CodexSessionCatalogPage,
CodexSessionCatalogSession,
} from "./session-catalog-types.js";
export const CODEX_TERMINAL_RESUME_COMMAND = "codex.terminal.resume.v1";
export function resolveLocalCodexTerminalExecutable(
pathEnv = process.env.PATH ?? "",
): string | undefined {
return resolveExecutableFromPathEnv("codex", pathEnv);
}
export function codexNodeTerminalCapability(node: {
connected?: boolean;
commands?: string[];
invocableCommands?: string[];
}): { canOpenTerminalCodex?: true } {
const commands = node.invocableCommands ?? node.commands;
return node.connected === true && commands?.includes(CODEX_TERMINAL_RESUME_COMMAND) === true
? { canOpenTerminalCodex: true }
: {};
}
export async function requireCatalogEligibleThread(
control: CodexSessionCatalogControl,
threadId: string,
): Promise<CodexSessionCatalogSession> {
let cursor: string | undefined;
const seenCursors = new Set<string>();
for (let pageIndex = 0; pageIndex < MAX_ACTION_CATALOG_PAGES; pageIndex += 1) {
const page = await control.listPage({
limit: CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT,
...(cursor ? { cursor } : {}),
});
const candidate = page.sessions.find((session) => session.threadId === threadId);
if (candidate) {
if (candidate.source === "cli" || candidate.source === "vscode") {
return candidate;
}
throw new CatalogParamsError(
"Codex session is not a non-archived interactive CLI or VS Code session",
);
}
const nextCursor = page.nextCursor?.trim();
if (!nextCursor) {
throw new CatalogParamsError(
"Codex session is not a non-archived interactive CLI or VS Code session",
);
}
if (seenCursors.has(nextCursor)) {
throw new CatalogParamsError("Codex session eligibility could not be verified");
}
seenCursors.add(nextCursor);
cursor = nextCursor;
}
throw new CatalogParamsError("Codex session eligibility could not be verified");
}
export function createCodexTerminalNodeHostCommand(
control: CodexSessionCatalogControl,
): OpenClawPluginNodeHostCommand {
return {
command: CODEX_TERMINAL_RESUME_COMMAND,
cap: CODEX_APP_SERVER_THREADS_CAPABILITY,
dangerous: false,
duplex: true,
isAvailable: ({ env }) => Boolean(resolveExecutableFromPathEnv("codex", env.PATH ?? "")),
handle: async (paramsJSON, io) => {
if (!io) {
throw new Error("Codex terminal command requires duplex transport");
}
const resume = decodeNodePtyResumeParams(paramsJSON, (value) => {
if (
typeof value !== "string" ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value)
) {
throw new CatalogParamsError("threadId must be a UUID");
}
return value;
});
const record = await requireCatalogEligibleThread(control, resume.threadId);
const file = resolveExecutableFromPathEnv("codex", process.env.PATH ?? "");
if (!file) {
throw new Error("Codex CLI is unavailable");
}
return JSON.stringify(
await runNodePtyCommand(
{
file,
args: ["resume", resume.threadId],
cwd: record.cwd,
cols: resume.cols,
rows: resume.rows,
},
io,
),
);
},
};
}
async function resolveNodeCatalogEligibleThread(params: {
runtime: PluginRuntime;
nodeId: string;
threadId: string;
parseCatalogPage: (value: unknown) => CodexSessionCatalogPage;
}): Promise<CodexSessionCatalogSession> {
let cursor: string | undefined;
const seenCursors = new Set<string>();
for (let pageIndex = 0; pageIndex < MAX_ACTION_CATALOG_PAGES; pageIndex += 1) {
const raw = await params.runtime.nodes.invoke({
nodeId: params.nodeId,
command: CODEX_APP_SERVER_THREADS_LIST_COMMAND,
params: {
limit: CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT,
...(cursor ? { cursor } : {}),
},
timeoutMs: NODE_INVOKE_TIMEOUT_MS,
});
const page = params.parseCatalogPage(unwrapNodeInvokePayload(raw));
const record = page.sessions.find((candidate) => candidate.threadId === params.threadId);
if (record) {
if (isInteractiveThreadSource(record.source)) {
return record;
}
break;
}
const nextCursor = page.nextCursor?.trim();
if (!nextCursor || seenCursors.has(nextCursor)) {
break;
}
seenCursors.add(nextCursor);
cursor = nextCursor;
}
throw new CatalogParamsError(
"Codex session is not a non-archived interactive CLI or VS Code session",
);
}
export async function openCodexCatalogTerminal(params: {
api: OpenClawPluginApi;
control: CodexSessionCatalogControl;
hostId: string;
threadId: string;
parseCatalogPage: (value: unknown) => CodexSessionCatalogPage;
}): Promise<SessionCatalogTerminalPlan> {
const title = `codex resume ${params.threadId.slice(0, 8)}`;
if (params.hostId === CODEX_LOCAL_SESSION_HOST_ID) {
const record = await requireCatalogEligibleThread(params.control, params.threadId);
const executable = resolveLocalCodexTerminalExecutable();
// A managed app-server may exist without a local CLI. Fail closed so
// terminal resume never targets a different machine or missing binary.
if (!executable) {
throw new CatalogParamsError("Codex CLI is unavailable");
}
return {
kind: "local",
argv: [executable, "resume", params.threadId],
...(record.cwd ? { cwd: record.cwd } : {}),
title,
};
}
if (!params.hostId.startsWith("node:")) {
throw new CatalogParamsError("hostId is invalid");
}
const nodeId = params.hostId.slice("node:".length);
const node = (await params.api.runtime.nodes.list()).nodes.find((candidate) => {
const commands = candidate.invocableCommands ?? candidate.commands;
return (
candidate.nodeId === nodeId &&
candidate.connected === true &&
commands?.includes(CODEX_APP_SERVER_THREADS_LIST_COMMAND) === true &&
commands.includes(CODEX_TERMINAL_RESUME_COMMAND)
);
});
if (!node) {
throw new CatalogParamsError("paired-node Codex terminal is unavailable");
}
const record = await resolveNodeCatalogEligibleThread({
runtime: params.api.runtime,
nodeId,
threadId: params.threadId,
parseCatalogPage: params.parseCatalogPage,
});
return {
kind: "node",
nodeId,
command: CODEX_TERMINAL_RESUME_COMMAND,
paramsJSON: JSON.stringify({ threadId: params.threadId }),
...(record.cwd ? { cwd: record.cwd } : {}),
title,
};
}
@@ -1,3 +1,11 @@
import type {
CodexThread,
CodexThreadListParams,
CodexThreadListResponse,
CodexThreadTurnsListParams,
CodexThreadTurnsListResponse,
} from "./app-server/protocol.js";
/** Read-only metadata for one Codex app-server thread. */
export type CodexSessionCatalogSession = {
threadId: string;
@@ -31,6 +39,16 @@ export type CodexSessionCatalogPageParams = {
cwd?: string;
};
export type CodexSessionCatalogControl = {
connectionFingerprint?: string;
withPinnedConnection<T>(run: (control: CodexSessionCatalogControl) => Promise<T>): Promise<T>;
listPage(params: CodexSessionCatalogPageParams): Promise<CodexSessionCatalogPage>;
listDescendantPage(params: CodexThreadListParams): Promise<CodexThreadListResponse>;
listTurnPage(params: CodexThreadTurnsListParams): Promise<CodexThreadTurnsListResponse>;
readThread(threadId: string, includeTurns?: boolean): Promise<CodexThread>;
archiveThread(threadId: string): Promise<void>;
};
export type CodexSessionCatalogError = {
code: string;
message: string;
@@ -43,6 +61,7 @@ export type CodexSessionCatalogHost = {
connected: boolean;
nodeId?: string;
canContinueCodex?: boolean;
canOpenTerminalCodex?: boolean;
sessions: CodexSessionCatalogSession[];
nextCursor?: string;
backwardsCursor?: string;
+198 -1
View File
@@ -1,12 +1,15 @@
// Codex supervision tests cover passive listing and safe local session takeover.
/* oxlint-disable typescript/unbound-method -- assertions inspect vi.fn-backed object methods, not unbound class methods. */
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog";
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexThread } from "./app-server/protocol.js";
import { sessionBindingIdentity } from "./app-server/session-binding.js";
import {
@@ -16,9 +19,11 @@ import {
} from "./app-server/session-binding.test-helpers.js";
import {
CODEX_LOCAL_SESSION_HOST_ID,
CODEX_TERMINAL_RESUME_COMMAND,
codexSessionCatalogRuntime,
createCodexSessionCatalogControl,
createCodexSessionCatalogNodeHostCommands,
createCodexSessionCatalogNodeInvokePolicies,
} from "./session-catalog.js";
const CODEX_APP_SERVER_THREADS_LIST_COMMAND = "codex.appServer.threads.list.v1";
@@ -30,6 +35,8 @@ const CODEX_NODE_CONTINUE_COMMANDS = [
CODEX_CLI_SESSION_RESUME_COMMAND,
] as const;
type CodexSessionCatalogControl = ReturnType<typeof createCodexSessionCatalogControl>;
const originalPath = process.env.PATH;
const tempDirs: string[] = [];
const archiveLocalCodexSession = codexSessionCatalogRuntime.archiveLocal;
const continueLocalCodexSession = codexSessionCatalogRuntime.continueLocal;
@@ -52,6 +59,9 @@ const transcriptMirrorMocks = vi.hoisted(() => ({
omittedMessages: 0,
})),
}));
const nodeHostMocks = vi.hoisted(() => ({
runNodePtyCommand: vi.fn(async () => ({ exitCode: 0 })),
}));
vi.mock("./command-rpc.js", () => ({
codexControlRequest: commandRpcMocks.codexControlRequest,
@@ -66,6 +76,10 @@ vi.mock("./app-server/shared-client.js", () => ({
vi.mock("./app-server/transcript-mirror.js", () => ({
importCodexThreadHistoryToTranscript: transcriptMirrorMocks.importCodexThreadHistoryToTranscript,
}));
vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/node-host")>()),
runNodePtyCommand: nodeHostMocks.runNodePtyCommand,
}));
type CreateSessionEntryParams = Parameters<
PluginRuntime["agent"]["session"]["createSessionEntry"]
@@ -337,6 +351,7 @@ function createGatewayApi(runtime: PluginRuntime) {
}
beforeEach(() => {
nodeHostMocks.runNodePtyCommand.mockClear();
commandRpcMocks.codexControlRequest.mockReset();
pinnedConnectionMocks.getClient.mockReset();
pinnedConnectionMocks.getClient.mockResolvedValue(pinnedConnectionMocks.client);
@@ -349,6 +364,11 @@ beforeEach(() => {
});
});
afterEach(async () => {
process.env.PATH = originalPath;
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("Codex supervision catalog", () => {
it("lists non-archived interactive threads without probing transcript previews", async () => {
const pluginConfig = { supervision: { enabled: true } };
@@ -972,6 +992,64 @@ describe("Codex supervision catalog", () => {
});
});
it("rejects malformed terminal resume thread ids before spawning", async () => {
const command = createCodexSessionCatalogNodeHostCommands(createEligibleControl()).find(
(candidate) => candidate.command === CODEX_TERMINAL_RESUME_COMMAND,
);
if (!command || command.duplex !== true) {
throw new Error("Codex terminal command was not registered as duplex");
}
await expect(
command.handle(JSON.stringify({ threadId: "not-a-uuid", cols: 80, rows: 24 }), {
signal: new AbortController().signal,
emitChunk: async () => {},
onInput: () => {},
}),
).rejects.toThrow("threadId must be a UUID");
});
it("resolves node terminal eligibility and cwd from the node-owned catalog record", async () => {
const threadId = "123e4567-e89b-12d3-a456-426614174000";
const binDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-node-terminal-"));
tempDirs.push(binDir);
const executable = path.join(binDir, process.platform === "win32" ? "codex.cmd" : "codex");
await fs.writeFile(executable, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n");
if (process.platform !== "win32") {
await fs.chmod(executable, 0o755);
}
process.env.PATH = binDir;
const command = createCodexSessionCatalogNodeHostCommands(
createEligibleControl({
listPage: vi.fn(async () => ({
sessions: [
{
threadId,
status: "idle",
source: "cli",
cwd: "/node/catalog/cwd",
archived: false,
},
],
})),
}),
).find((candidate) => candidate.command === CODEX_TERMINAL_RESUME_COMMAND);
if (!command || command.duplex !== true) {
throw new Error("Codex terminal command was not registered as duplex");
}
await command.handle(JSON.stringify({ threadId, cwd: "/caller/cwd", cols: 80, rows: 24 }), {
signal: new AbortController().signal,
emitChunk: async () => {},
onInput: () => {},
});
expect(command.dangerous).toBe(false);
expect(nodeHostMocks.runNodePtyCommand).toHaveBeenCalledWith(
expect.objectContaining({ file: executable, cwd: "/node/catalog/cwd" }),
expect.any(Object),
);
});
it("rejects an oversized transcript page before returning it over node.invoke", async () => {
const control = createEligibleControl({
listTurnPage: vi.fn(async () => ({
@@ -2976,6 +3054,125 @@ describe("Codex supervision actions", () => {
expect(createSessionEntry).not.toHaveBeenCalled();
});
it("builds local and paired-node terminal plans from verified catalog records", async () => {
const threadId = "123e4567-e89b-12d3-a456-426614174000";
const binDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-terminal-"));
tempDirs.push(binDir);
process.env.PATH = binDir;
const executable = path.join(binDir, process.platform === "win32" ? "codex.cmd" : "codex");
const control = createEligibleControl({
listPage: vi.fn(async () => ({
sessions: [
{ threadId, status: "active", source: "cli", cwd: "/workspace/local", archived: false },
],
})),
});
const invoke = vi.fn<PluginRuntime["nodes"]["invoke"]>(async (request) => ({
payloadJSON: JSON.stringify({
sessions:
// The node thread lookup must page without a title searchTerm; if a
// regression ever sends one, this returns [] and the test fails.
typeof (request.params as { searchTerm?: string } | undefined)?.searchTerm === "string"
? []
: [
{
threadId,
name: "Normal project title",
status: "active",
source: "vscode",
cwd: "/workspace/node",
archived: false,
},
],
}),
}));
const commands = [CODEX_APP_SERVER_THREADS_LIST_COMMAND, CODEX_TERMINAL_RESUME_COMMAND];
const authorizedCommands = new Set(
createCodexSessionCatalogNodeInvokePolicies().flatMap((policy) => policy.commands),
);
expect(authorizedCommands).toContain(CODEX_TERMINAL_RESUME_COMMAND);
const policy = createCodexSessionCatalogNodeInvokePolicies()[0];
if (!policy) {
throw new Error("expected Codex node invoke policy");
}
const invokeNode = vi.fn(async () => ({ ok: true as const, payload: "listed" }));
expect(policy.handle({ command: CODEX_TERMINAL_RESUME_COMMAND, invokeNode } as never)).toEqual({
ok: true,
});
expect(invokeNode).not.toHaveBeenCalled();
const node = {
nodeId: "devbox",
connected: true,
commands,
invocableCommands: commands.filter((command) => authorizedCommands.has(command)),
};
const { runtime } = createRuntime({ nodes: [node], invoke });
const { api, getProvider } = createGatewayApi(runtime);
registerCodexSessionCatalog({
api,
bindingStore: createCodexTestBindingStore(),
control,
getRuntimeConfig: () => config,
});
await expect(getProvider()?.list({})).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
hostId: CODEX_LOCAL_SESSION_HOST_ID,
sessions: [expect.objectContaining({ threadId, canOpenTerminal: false })],
}),
expect.objectContaining({
hostId: "node:devbox",
sessions: [expect.objectContaining({ threadId, canOpenTerminal: true })],
}),
]),
);
await expect(
getProvider()?.openTerminal?.({ hostId: CODEX_LOCAL_SESSION_HOST_ID, threadId }),
).rejects.toThrow("Codex CLI is unavailable");
await fs.writeFile(executable, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n");
if (process.platform !== "win32") {
await fs.chmod(executable, 0o755);
}
await expect(getProvider()?.list({})).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
hostId: CODEX_LOCAL_SESSION_HOST_ID,
sessions: [expect.objectContaining({ threadId, canOpenTerminal: true })],
}),
expect.objectContaining({
hostId: "node:devbox",
sessions: [expect.objectContaining({ threadId, canOpenTerminal: true })],
}),
]),
);
await expect(
getProvider()?.openTerminal?.({ hostId: CODEX_LOCAL_SESSION_HOST_ID, threadId }),
).resolves.toMatchObject({
kind: "local",
argv: [executable, "resume", threadId],
cwd: "/workspace/local",
});
await expect(
getProvider()?.openTerminal?.({ hostId: "node:devbox", threadId }),
).resolves.toMatchObject({
kind: "node",
nodeId: "devbox",
command: CODEX_TERMINAL_RESUME_COMMAND,
cwd: "/workspace/node",
});
expect(invoke.mock.calls.at(-1)?.[0].params).not.toHaveProperty("searchTerm");
node.invocableCommands = [CODEX_APP_SERVER_THREADS_LIST_COMMAND];
await expect(getProvider()?.list({ hostIds: ["node:devbox"] })).resolves.toMatchObject([
{ sessions: [{ threadId, canOpenTerminal: false }] },
]);
await expect(
getProvider()?.openTerminal?.({ hostId: "node:devbox", threadId }),
).rejects.toThrow("paired-node Codex terminal is unavailable");
});
it("marks not-loaded local interactive sessions as actionable", async () => {
const { runtime } = createRuntime();
const { api, getProvider } = createGatewayApi(runtime);
+52 -66
View File
@@ -52,28 +52,28 @@ import {
type CodexSessionDisposition,
} from "./session-catalog-node-adoption.js";
import {
CODEX_APP_SERVER_THREADS_LIST_COMMAND,
CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND,
compareNodeLabels,
continueNodeCodexSession,
listPairedNode,
nodeLabel,
NODE_INVOKE_TIMEOUT_MS,
type CatalogNode,
} from "./session-catalog-node-continue.js";
import {
catalogError,
CatalogParamsError,
CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT,
CODEX_APP_SERVER_THREADS_CAPABILITY,
CODEX_APP_SERVER_THREADS_LIST_COMMAND,
CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND,
CODEX_LOCAL_SESSION_HOST_ID,
DEFAULT_TRANSCRIPT_PAGE_LIMIT,
filterCatalogPageByTitle,
isInteractiveThreadSource,
MAX_ACTION_CATALOG_PAGES,
MAX_CURSOR_LENGTH,
MAX_HOST_COUNT,
MAX_SESSION_ID_LENGTH,
MAX_TITLE_SEARCH_CATALOG_PAGES,
MAX_TRANSCRIPT_PAGE_LIMIT,
NODE_INVOKE_TIMEOUT_MS,
normalizeLimit,
parseCatalogPage,
parseJsonParams,
@@ -86,32 +86,30 @@ import {
toCatalogSession,
unwrapNodeInvokePayload,
} from "./session-catalog-parsing.js";
import {
CODEX_TERMINAL_RESUME_COMMAND,
codexNodeTerminalCapability,
createCodexTerminalNodeHostCommand,
openCodexCatalogTerminal,
requireCatalogEligibleThread,
resolveLocalCodexTerminalExecutable,
} from "./session-catalog-terminal.js";
import type {
CodexSessionCatalogControl,
CodexSessionCatalogHost,
CodexSessionCatalogPage,
CodexSessionCatalogPageParams,
CodexSessionCatalogParams,
CodexSessionCatalogResult,
CodexSessionCatalogSession,
CodexSessionTranscriptPage,
} from "./session-catalog-types.js";
const CODEX_APP_SERVER_THREADS_CAPABILITY = "codex-app-server-threads";
const CODEX_SUPERVISION_SESSION_KEY_PREFIX = "harness:codex:supervision:";
export { CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT } from "./session-catalog-parsing.js";
export const CODEX_LOCAL_SESSION_HOST_ID = "gateway:local";
type CodexSessionCatalogControl = {
connectionFingerprint?: string;
withPinnedConnection<T>(run: (control: CodexSessionCatalogControl) => Promise<T>): Promise<T>;
listPage(params: CodexSessionCatalogPageParams): Promise<CodexSessionCatalogPage>;
listDescendantPage(params: CodexThreadListParams): Promise<CodexThreadListResponse>;
listTurnPage(params: CodexThreadTurnsListParams): Promise<CodexThreadTurnsListResponse>;
readThread(threadId: string, includeTurns?: boolean): Promise<CodexThread>;
archiveThread(threadId: string): Promise<void>;
};
export {
CODEX_LOCAL_SESSION_HOST_ID,
CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT,
} from "./session-catalog-parsing.js";
export { CODEX_TERMINAL_RESUME_COMMAND } from "./session-catalog-terminal.js";
type CodexSessionCatalogRequestSnapshot = {
requestTimeoutMs: number;
@@ -433,14 +431,15 @@ async function listCodexSessionCatalog(params: {
config: params.config,
runtime: params.runtime,
});
const nodeHosts = nodes.toSorted(compareNodeLabels).map((node) =>
listPairedNode({
const nodeHosts = nodes.toSorted(compareNodeLabels).map(async (node) => {
const host = await listPairedNode({
runtime: params.runtime,
node,
query,
adoptedSessions: adoptedNodeSessions,
}),
);
});
return Object.assign(host, codexNodeTerminalCapability(node));
});
return { hosts: await Promise.all([...localHosts, ...nodeHosts]) };
}
@@ -493,6 +492,7 @@ export function createCodexSessionCatalogNodeHostCommands(
}
},
},
createCodexTerminalNodeHostCommand(control),
];
}
@@ -615,41 +615,6 @@ function requireIdleThread(thread: CodexThread, action: "continue" | "archive"):
);
}
async function requireCatalogEligibleThread(
control: CodexSessionCatalogControl,
threadId: string,
): Promise<void> {
let cursor: string | undefined;
const seenCursors = new Set<string>();
for (let pageIndex = 0; pageIndex < MAX_ACTION_CATALOG_PAGES; pageIndex += 1) {
const page = await control.listPage({
limit: CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT,
...(cursor ? { cursor } : {}),
});
const candidate = page.sessions.find((session) => session.threadId === threadId);
if (candidate) {
if (candidate.source === "cli" || candidate.source === "vscode") {
return;
}
throw new CatalogParamsError(
"Codex session is not a non-archived interactive CLI or VS Code session",
);
}
const nextCursor = page.nextCursor?.trim();
if (!nextCursor) {
throw new CatalogParamsError(
"Codex session is not a non-archived interactive CLI or VS Code session",
);
}
if (seenCursors.has(nextCursor)) {
throw new CatalogParamsError("Codex session eligibility could not be verified");
}
seenCursors.add(nextCursor);
cursor = nextCursor;
}
throw new CatalogParamsError("Codex session eligibility could not be verified");
}
function adoptionSessionKey(threadId: string): string {
const digest = createHash("sha256").update(threadId).digest("hex");
return `${CODEX_SUPERVISION_SESSION_KEY_PREFIX}${digest}`;
@@ -1168,14 +1133,22 @@ async function archiveLocalCodexSession(params: {
export function createCodexSessionCatalogNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] {
return [
{
commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND, CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND],
commands: [
CODEX_APP_SERVER_THREADS_LIST_COMMAND,
CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND,
CODEX_TERMINAL_RESUME_COMMAND,
],
defaultPlatforms: ["macos", "linux", "windows"],
handle: (context) => context.invokeNode(),
handle: (context) =>
context.command === CODEX_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(),
},
];
}
function toGenericCatalogHost(host: CodexSessionCatalogHost): SessionCatalogHost {
function toGenericCatalogHost(
host: CodexSessionCatalogHost,
localTerminalAvailable: boolean,
): SessionCatalogHost {
const local = host.hostId === CODEX_LOCAL_SESSION_HOST_ID;
return {
hostId: host.hostId,
@@ -1191,6 +1164,9 @@ function toGenericCatalogHost(host: CodexSessionCatalogHost): SessionCatalogHost
continuableStatus &&
isInteractiveThreadSource(session.source);
const canArchive = local && continuableStatus && isInteractiveThreadSource(session.source);
const canOpenTerminal =
isInteractiveThreadSource(session.source) &&
(local ? localTerminalAvailable : host.canOpenTerminalCodex === true);
return {
threadId: session.threadId,
...(session.name != null ? { name: session.name } : {}),
@@ -1207,6 +1183,7 @@ function toGenericCatalogHost(host: CodexSessionCatalogHost): SessionCatalogHost
...(session.openClawSessionKey ? { openClawSessionKey: session.openClawSessionKey } : {}),
canContinue,
canArchive,
canOpenTerminal,
};
}),
...(host.nextCursor ? { nextCursor: host.nextCursor } : {}),
@@ -1267,8 +1244,9 @@ function registerCodexSessionCatalog(params: {
const provider: SessionCatalogProvider = {
id: "codex",
label: "Codex",
list: async (query) =>
(
list: async (query) => {
const localTerminalAvailable = resolveLocalCodexTerminalExecutable() !== undefined;
return (
await listCodexSessionCatalog({
bindingStore: params.bindingStore,
config: params.getRuntimeConfig(),
@@ -1276,7 +1254,8 @@ function registerCodexSessionCatalog(params: {
control: params.control,
query,
})
).hosts.map(toGenericCatalogHost),
).hosts.map((host) => toGenericCatalogHost(host, localTerminalAvailable));
},
read: async (request) => {
const page = await readCodexSessionTranscript({
runtime: params.api.runtime,
@@ -1337,6 +1316,13 @@ function registerCodexSessionCatalog(params: {
});
return { ok: true };
},
openTerminal: (request) =>
openCodexCatalogTerminal({
api: params.api,
control: params.control,
parseCatalogPage,
...request,
}),
};
params.api.registerSessionCatalog(provider);
}
+4
View File
@@ -991,6 +991,10 @@
"types": "./dist/plugin-sdk/inline-image-data-url-runtime.d.ts",
"default": "./dist/plugin-sdk/inline-image-data-url-runtime.js"
},
"./plugin-sdk/node-host": {
"types": "./dist/plugin-sdk/node-host.d.ts",
"default": "./dist/plugin-sdk/node-host.js"
},
"./plugin-sdk/response-limit-runtime": {
"types": "./dist/plugin-sdk/response-limit-runtime.d.ts",
"default": "./dist/plugin-sdk/response-limit-runtime.js"
+5 -8
View File
@@ -1,11 +1,4 @@
export {
buildClawHubTrustErrorDetails,
ClawHubTrustErrorCodes,
isClawHubTrustErrorCode,
readClawHubTrustErrorDetails,
type ClawHubTrustErrorCode,
type ClawHubTrustErrorDetails,
} from "./clawhub-trust-error-details.js";
export * from "./clawhub-trust-error-details.js";
export { validateApprovalGetResult } from "./approval-result-validators.js";
export { validateApprovalResolveResult } from "./approval-result-validators.js";
import type { ValidationError } from "./validation-errors.js";
@@ -289,6 +282,7 @@ import {
NodePresenceAliveReasonSchema,
NodePresenceActivityPayloadSchema,
NodeInvokeParamsSchema,
NodeInvokeInputEventSchema,
NodeInvokeProgressParamsSchema,
NodeInvokeResultParamsSchema,
NodeListParamsSchema,
@@ -601,6 +595,7 @@ export const validateSystemInfoResult = lazyCompile(SystemInfoResultSchema);
export const validateNodePendingAckParams = lazyCompile(NodePendingAckParamsSchema);
export const validateNodeDescribeParams = lazyCompile(NodeDescribeParamsSchema);
export const validateNodeInvokeParams = lazyCompile(NodeInvokeParamsSchema);
export const validateNodeInvokeInputEvent = lazyCompile(NodeInvokeInputEventSchema);
export const validateNodeInvokeResultParams = lazyCompile(NodeInvokeResultParamsSchema);
export const validateNodeInvokeProgressParams = lazyCompile(NodeInvokeProgressParamsSchema);
export const validateNodeEventParams = lazyCompile(NodeEventParamsSchema);
@@ -944,6 +939,7 @@ export {
NodeSkillsUpdateParamsSchema,
NodePendingAckParamsSchema,
NodeInvokeParamsSchema,
NodeInvokeInputEventSchema,
NodeInvokeProgressParamsSchema,
NodeEventResultSchema,
NodePresenceAlivePayloadSchema,
@@ -1528,6 +1524,7 @@ export type {
NodeSkillDescriptor,
NodeSkillsUpdateParams,
NodeInvokeParams,
NodeInvokeInputEvent,
NodeInvokeProgressParams,
NodeInvokeResultParams,
NodeEventParams,
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { validateNodeInvokeInputEvent } from "../index.js";
describe("node.invoke.input protocol", () => {
it("accepts ordered bounded frames and rejects oversized or open-ended payloads", () => {
expect(
validateNodeInvokeInputEvent({
id: "invoke-1",
nodeId: "node-1",
seq: 0,
payloadJSON: '{"kind":"data","data":"x"}',
}),
).toBe(true);
expect(
validateNodeInvokeInputEvent({
id: "invoke-1",
nodeId: "node-1",
seq: 0,
payloadJSON: "x".repeat(16 * 1024 + 1),
}),
).toBe(false);
expect(
validateNodeInvokeInputEvent({
id: "invoke-1",
nodeId: "node-1",
seq: 0,
payloadJSON: "{}",
argv: ["sh"],
}),
).toBe(false);
});
});
@@ -230,6 +230,14 @@ export const NodeInvokeRequestEventSchema = closedObject({
idempotencyKey: Type.Optional(NonEmptyString),
});
/** Ordered input frame sent by the gateway to one long-lived node invoke. */
export const NodeInvokeInputEventSchema = closedObject({
id: NonEmptyString,
nodeId: NonEmptyString,
seq: Type.Integer({ minimum: 0 }),
payloadJSON: Type.String({ maxLength: 16 * 1024 }),
});
// Wire types derive directly from local schema consts so public d.ts graphs never
// pull in the ProtocolSchemas registry.
export type NodePairListParams = Static<typeof NodePairListParamsSchema>;
@@ -243,6 +251,7 @@ export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
export type NodeInvokeResultParams = Static<typeof NodeInvokeResultParamsSchema>;
export type NodeInvokeProgressParams = Static<typeof NodeInvokeProgressParamsSchema>;
export type NodeInvokeInputEvent = Static<typeof NodeInvokeInputEventSchema>;
export type NodeEventParams = Static<typeof NodeEventParamsSchema>;
export type NodeEventResult = Static<typeof NodeEventResultSchema>;
export type NodePresenceAlivePayload = Static<typeof NodePresenceAlivePayloadSchema>;
@@ -0,0 +1,17 @@
import {
NodeInvokeInputEventSchema,
NodeInvokeParamsSchema,
NodeInvokeProgressParamsSchema,
NodeInvokeRequestEventSchema,
NodeInvokeResultParamsSchema,
} from "./nodes.js";
// Node invoke request/input/progress/result wire schemas, grouped like the
// sibling node-presence bundle so the main registry stays within its budget.
export const NodeInvokeProtocolSchemas = {
NodeInvokeParams: NodeInvokeParamsSchema,
NodeInvokeInputEvent: NodeInvokeInputEventSchema,
NodeInvokeProgressParams: NodeInvokeProgressParamsSchema,
NodeInvokeResultParams: NodeInvokeResultParamsSchema,
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
};
@@ -310,10 +310,6 @@ import {
NodePendingEnqueueParamsSchema,
NodePendingEnqueueResultSchema,
NodePresenceAlivePayloadSchema,
NodeInvokeParamsSchema,
NodeInvokeProgressParamsSchema,
NodeInvokeResultParamsSchema,
NodeInvokeRequestEventSchema,
NodeListParamsSchema,
NodePendingAckParamsSchema,
NodePairApproveParamsSchema,
@@ -355,6 +351,7 @@ import {
PluginsUninstallParamsSchema,
PluginsUninstallResultSchema,
} from "./plugins.js";
import { NodeInvokeProtocolSchemas } from "./protocol-schemas-node-invoke.js";
import { NodePresenceProtocolSchemas } from "./protocol-schemas-node-presence.js";
import { PushTestParamsSchema, PushTestResultSchema } from "./push.js";
import {
@@ -587,9 +584,7 @@ export const ProtocolSchemas = {
NodeSkillsUpdateParams: NodeSkillsUpdateParamsSchema,
NodePendingAckParams: NodePendingAckParamsSchema,
NodeDescribeParams: NodeDescribeParamsSchema,
NodeInvokeParams: NodeInvokeParamsSchema,
NodeInvokeProgressParams: NodeInvokeProgressParamsSchema,
NodeInvokeResultParams: NodeInvokeResultParamsSchema,
...NodeInvokeProtocolSchemas,
NodeEventParams: NodeEventParamsSchema,
NodeEventResult: NodeEventResultSchema,
NodePresenceAlivePayload: NodePresenceAlivePayloadSchema,
@@ -598,7 +593,6 @@ export const ProtocolSchemas = {
NodePendingDrainResult: NodePendingDrainResultSchema,
NodePendingEnqueueParams: NodePendingEnqueueParamsSchema,
NodePendingEnqueueResult: NodePendingEnqueueResultSchema,
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
// Push and secret-resolution payloads used by mobile/control integrations.
PushTestParams: PushTestParamsSchema,
@@ -17,6 +17,7 @@ describe("SessionsCatalogListResultSchema", () => {
continueSession: true,
archive: false,
createSession: { model: "anthropic/claude-opus-4-8" },
openTerminal: true,
},
hosts: [
{
@@ -24,7 +25,16 @@ describe("SessionsCatalogListResultSchema", () => {
label: "Gateway",
kind: "gateway",
connected: true,
sessions: [],
sessions: [
{
threadId: "thread-1",
status: "idle",
archived: false,
canContinue: true,
canArchive: false,
canOpenTerminal: true,
},
],
},
],
},
@@ -10,6 +10,7 @@ export const SessionCatalogCapabilitiesSchema = closedObject({
continueSession: Type.Boolean(),
archive: Type.Boolean(),
createSession: Type.Optional(closedObject({ model: NonEmptyString })),
openTerminal: Type.Optional(Type.Boolean()),
});
export const SessionCatalogDescriptorSchema = closedObject({
@@ -34,6 +35,7 @@ export const SessionCatalogSessionSchema = closedObject({
openClawSessionKey: Type.Optional(NonEmptyString),
canContinue: Type.Boolean(),
canArchive: Type.Boolean(),
canOpenTerminal: Type.Optional(Type.Boolean()),
});
export const SessionCatalogHostSchema = closedObject({
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { validateTerminalOpenParams } from "../index.js";
describe("terminal protocol", () => {
it("accepts a typed catalog reference and rejects client command fields", () => {
expect(
validateTerminalOpenParams({
cols: 80,
rows: 24,
catalog: { catalogId: "codex", hostId: "gateway:local", threadId: "thread" },
}),
).toBe(true);
expect(
validateTerminalOpenParams({
cols: 80,
rows: 24,
catalog: {
catalogId: "codex",
hostId: "gateway:local",
threadId: "thread",
argv: ["sh"],
},
}),
).toBe(false);
expect(
validateTerminalOpenParams({
cols: 80,
rows: 24,
cwd: "/tmp",
}),
).toBe(false);
});
});
@@ -15,6 +15,13 @@ export const TerminalOpenParamsSchema = closedObject({
// Optional agent selector; defaults to the gateway's default agent. The
// session starts in that agent's workspace and inherits its isolation.
agentId: Type.Optional(NonEmptyString),
catalog: Type.Optional(
closedObject({
catalogId: NonEmptyString,
hostId: NonEmptyString,
threadId: NonEmptyString,
}),
),
cols: TerminalDimension,
rows: TerminalDimension,
});
@@ -29,6 +36,7 @@ export const TerminalOpenResultSchema = closedObject({
// True when the shell runs inside the agent's sandbox and cannot escape the
// workspace; false for a host shell that can navigate the whole filesystem.
confined: Type.Boolean(),
title: Type.Optional(NonEmptyString),
});
export type TerminalOpenResult = Static<typeof TerminalOpenResultSchema>;
+7
View File
@@ -8,6 +8,7 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [
"extensions/canvas/src/host/a2ui.ts: injectCanvasRuntime",
"extensions/canvas/src/host/a2ui.ts: isA2uiPath",
"extensions/canvas/src/host/server.ts: CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES",
"extensions/codex/src/session-catalog.ts: CODEX_TERMINAL_RESUME_COMMAND",
"extensions/copilot/src/auth-bridge.ts: COPILOT_DEFAULT_AGENT_ID",
"extensions/copilot/src/auth-bridge.ts: COPILOT_TOKEN_PROFILE_ERROR",
"extensions/copilot/src/auth-bridge.ts: sanitizeAgentId",
@@ -793,6 +794,7 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [
"src/flows/doctor-health-contributions.ts: resolveDoctorHealthContributions",
"src/flows/doctor-health-contributions.ts: shouldSkipLegacyUpdateDoctorConfigWrite",
"src/flows/health-check-adapter.ts: defineSplitHealthCheck",
"src/gateway/terminal/launch.ts: shellQuote",
"src/hooks/bundled/session-memory/transcript.ts: getRecentSessionContent",
"src/hooks/bundled/session-memory/transcript.ts: sanitizeSessionMemoryTranscriptText",
"src/hooks/gmail-setup-utils.ts: resetGmailSetupUtilsCachesForTest",
@@ -843,12 +845,15 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [
"src/node-host/mcp.ts: buildNodeMcpToolDescriptors",
"src/node-host/mcp.ts: countConfiguredNodeHostMcpServers",
"src/node-host/mcp.ts: NodeHostMcpErrorCode",
"src/node-host/node-invoke-progress.ts: resolveNodeInvokeHeartbeatInterval",
"src/node-host/runner.ts: buildNodeInvokeResultParams",
"src/node-host/runner.ts: handleNodeHostReconnectPaused",
"src/node-host/runner.ts: resolveNodeHostGatewayCredentials",
"src/node-host/runner.ts: resolveNodeHostGatewayDeviceFamily",
"src/node-host/runner.ts: resolveNodeHostGatewayPlatform",
"src/node-host/runner.ts: shouldExitNodeHostOnReconnectPaused",
"src/node-host/runtime.ts: dispatchNodeInvokeInput",
"src/node-host/runtime.ts: registerNodeInvokeInputHandler",
"src/pairing/pairing-store.types.ts: ReadChannelAllowFromStoreForAccount",
"src/pairing/pairing-store.types.ts: UpsertChannelPairingRequestForAccount",
"src/plugin-state/plugin-state-store.sqlite.ts: probePluginStateStore",
@@ -858,6 +863,8 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [
"src/process/command-queue.ts: CommandLaneTaskTimeoutError",
"src/process/command-queue.ts: resetCommandQueueStateForTest",
"src/process/gateway-work-admission.ts: tryBeginGatewayIndependentRootWorkAdmission",
"src/process/terminal-pty.ts: resolveTerminalPtyInvocation",
"src/process/terminal-pty.ts: TerminalPtyHandle",
"src/proxy-capture/env.ts: OPENCLAW_DEBUG_PROXY_ENABLED",
"src/proxy-capture/env.ts: OPENCLAW_DEBUG_PROXY_SESSION_ID",
"src/proxy-capture/proxy-server.ts: assertDebugProxyDirectUpstreamAllowed",
+1
View File
@@ -216,6 +216,7 @@
"fetch-runtime",
"runtime-fetch",
"inline-image-data-url-runtime",
"node-host",
"response-limit-runtime",
"session-binding-runtime",
"session-catalog",
+3 -3
View File
@@ -199,7 +199,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
const budgets = {
publicEntrypoints: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS",
327,
328,
env,
),
// ScopeTree adds six channel-policy exports, mirrored by compat, including three functions.
@@ -207,12 +207,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// Its case-insensitive scope-key resolver adds one function, also mirrored by compat.
publicExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
10665,
10674,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
5371,
5375,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
@@ -21,7 +21,8 @@
"terminal.exit": "Embedded terminal is a web/desktop surface; iOS has no terminal client.",
"update.available": "Gateway self-update notices do not apply to iOS; app updates ship via the App Store.",
"session.approval": "Native approval review uses exec.approval push/nudge delivery; the session-scoped approval stream is a Control UI chat surface.",
"node.invoke.cancel": "Cancel targets streaming agent.cli.claude.run.v1 invokes; app nodes never advertise agent runs, so no cancel can address them."
"node.invoke.cancel": "Cancel targets streaming agent.cli.claude.run.v1 invokes; app nodes never advertise agent runs, so no cancel can address them.",
"node.invoke.input": "Carries terminal keystrokes/resize to a node PTY relay invoke; the relay runs on gateway/CLI node hosts and app nodes never host it, so iOS has no consumer."
},
"android": {
"session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.",
@@ -44,6 +45,7 @@
"terminal.data": "Embedded terminal is a web/desktop surface; Android has no terminal client.",
"terminal.exit": "Embedded terminal is a web/desktop surface; Android has no terminal client.",
"session.approval": "Native approval review uses exec.approval push/nudge delivery; the session-scoped approval stream is a Control UI chat surface.",
"node.invoke.cancel": "Cancel targets streaming agent.cli.claude.run.v1 invokes; app nodes never advertise agent runs, so no cancel can address them."
"node.invoke.cancel": "Cancel targets streaming agent.cli.claude.run.v1 invokes; app nodes never advertise agent runs, so no cancel can address them.",
"node.invoke.input": "Carries terminal keystrokes/resize to a node PTY relay invoke; the relay runs on gateway/CLI node hosts and app nodes never host it, so Android has no consumer."
}
}
+84 -7
View File
@@ -22,19 +22,102 @@ export type PendingInvoke = {
onProgress?: (chunk: string) => void;
nextProgressSeq: number;
progressChunks: Map<number, string>;
nextInputSeq: number;
removeAbortListener?: () => void;
};
export type NodeInvokeProgressParams = {
invokeId: string;
nodeId: string;
connId: string | undefined;
seq: number;
chunk: string;
};
export type NodeInvokeResultParams = {
id: string;
nodeId: string;
connId: string | undefined;
ok: boolean;
payload?: unknown;
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
};
const MAX_PENDING_PROGRESS_CHUNKS = 128;
const MAX_INVOKE_INPUT_BYTES = 16 * 1024;
export class NodeInvokeStreamController {
constructor(
private readonly options: {
pendingInvokes: Map<string, PendingInvoke>;
sendCancel: (requestId: string, pending: PendingInvoke) => void;
isConnectionActive: (pending: PendingInvoke) => boolean;
sendInput: (
invokeId: string,
pending: PendingInvoke,
seq: number,
payloadJSON: string,
) => boolean;
onFailedResult: (pending: PendingInvoke) => void;
// Settles a pending invoke on transport loss. The registry's callback
// preserves MCP's structured-failure contract (resolve, not reject) so
// MCP callers can degrade instead of seeing an opaque invoke error.
disconnectPending: (pending: PendingInvoke) => void;
},
) {}
sendInput(invokeId: string, payload: unknown): void {
const pending = this.options.pendingInvokes.get(invokeId);
if (!pending) {
throw new Error("node invoke is not pending");
}
const payloadJSON = JSON.stringify(payload);
if (payloadJSON === undefined) {
throw new Error("node invoke input is not serializable");
}
if (Buffer.byteLength(payloadJSON, "utf8") > MAX_INVOKE_INPUT_BYTES) {
throw new Error("node invoke input exceeds 16 KiB");
}
if (!this.options.isConnectionActive(pending)) {
throw new Error("node invoke connection is unavailable");
}
if (!this.options.sendInput(invokeId, pending, pending.nextInputSeq, payloadJSON)) {
throw new Error("failed to send node invoke input");
}
pending.nextInputSeq += 1;
}
handleDisconnect(connId: string): void {
for (const [id, pending] of this.options.pendingInvokes) {
if (pending.connId !== connId) {
continue;
}
this.clearTimers(pending);
this.options.disconnectPending(pending);
this.options.pendingInvokes.delete(id);
}
}
handleResult(params: NodeInvokeResultParams): boolean {
const pending = this.options.pendingInvokes.get(params.id);
if (!pending || pending.nodeId !== params.nodeId || pending.connId !== params.connId) {
return false;
}
this.clearTimers(pending);
this.options.pendingInvokes.delete(params.id);
if (!params.ok) {
this.options.onFailedResult(pending);
}
pending.resolve({
ok: params.ok,
payload: params.payload,
payloadJSON: params.payloadJSON ?? null,
error: params.error ?? null,
});
return true;
}
armPending(params: {
requestId: string;
pending: PendingInvoke;
@@ -79,13 +162,7 @@ export class NodeInvokeStreamController {
}
}
handleProgress(params: {
invokeId: string;
nodeId: string;
connId: string | undefined;
seq: number;
chunk: string;
}): boolean {
handleProgress(params: NodeInvokeProgressParams): boolean {
const pending = this.options.pendingInvokes.get(params.invokeId);
if (
!pending ||
+77
View File
@@ -218,6 +218,83 @@ function authorizeSystemRun(registry: NodeRegistry, overrides: Partial<SystemRun
}
describe("gateway/node-registry", () => {
it("routes ordered input to the pending invoke connection and rejects unknown invokes", async () => {
const registry = new NodeRegistry();
const frames = registerNode(registry);
const controller = new AbortController();
const invoke = registry.invoke({
nodeId: "node-1",
command: "codex.terminal.resume.v1",
timeoutMs: 0,
signal: controller.signal,
onProgress: () => {},
});
const request = JSON.parse(frames[0] ?? "{}") as { payload?: { id?: string } };
const invokeId = request.payload?.id ?? "";
registry.sendInvokeInput(invokeId, { kind: "data", data: "a" });
registry.sendInvokeInput(invokeId, { kind: "resize", cols: 90, rows: 30 });
expect(JSON.parse(frames[1] ?? "{}")).toMatchObject({
event: "node.invoke.input",
payload: {
id: invokeId,
nodeId: "node-1",
seq: 0,
payloadJSON: JSON.stringify({ kind: "data", data: "a" }),
},
});
expect(JSON.parse(frames[2] ?? "{}")).toMatchObject({
event: "node.invoke.input",
payload: { id: invokeId, nodeId: "node-1", seq: 1 },
});
expect(() => registry.sendInvokeInput("missing", { kind: "data", data: "x" })).toThrow(
"node invoke is not pending",
);
controller.abort();
await expect(invoke).resolves.toMatchObject({ ok: false, error: { code: "ABORTED" } });
});
it("rejects node invoke input above 16 KiB", async () => {
const registry = new NodeRegistry();
const frames = registerNode(registry);
const controller = new AbortController();
const invoke = registry.invoke({
nodeId: "node-1",
command: "codex.terminal.resume.v1",
timeoutMs: 0,
signal: controller.signal,
});
const request = JSON.parse(frames[0] ?? "{}") as { payload?: { id?: string } };
expect(() =>
registry.sendInvokeInput(request.payload?.id ?? "", {
kind: "data",
data: "x".repeat(17 * 1024),
}),
).toThrow("exceeds 16 KiB");
controller.abort();
await invoke;
});
it("rejects node invoke input that cannot be serialized", async () => {
const registry = new NodeRegistry();
const frames = registerNode(registry);
const controller = new AbortController();
const invoke = registry.invoke({
nodeId: "node-1",
command: "codex.terminal.resume.v1",
timeoutMs: 0,
signal: controller.signal,
});
const request = JSON.parse(frames[0] ?? "{}") as { payload?: { id?: string } };
expect(() => registry.sendInvokeInput(request.payload?.id ?? "", undefined)).toThrow(
"node invoke input is not serializable",
);
controller.abort();
await invoke;
});
it("ranks connected nodes by gateway-derived input activity", () => {
const registry = createTestNodeRegistry();
registry.register(
+74 -74
View File
@@ -27,6 +27,8 @@ import {
} from "./node-plugin-tool-snapshot.js";
import {
NodeInvokeStreamController,
type NodeInvokeProgressParams,
type NodeInvokeResultParams,
type PendingInvoke,
type PendingSystemRunEvent,
} from "./node-registry.invoke-stream.js";
@@ -210,6 +212,40 @@ export class NodeRegistry {
nodeId: pending.nodeId,
});
},
isConnectionActive: (pending) => this.nodesById.get(pending.nodeId)?.connId === pending.connId,
sendInput: (invokeId, pending, seq, payloadJSON) => {
const node = this.nodesById.get(pending.nodeId);
return node
? this.sendEventToSession(node, "node.invoke.input", {
id: invokeId,
nodeId: pending.nodeId,
seq,
payloadJSON,
})
: false;
},
onFailedResult: (pending) => {
if (pending.systemRunEvent) {
this.forgetAuthorizedSystemRunEvent({
nodeId: pending.nodeId,
connId: pending.connId,
...pending.systemRunEvent,
});
}
},
disconnectPending: (pending) => {
if (pending.command === NODE_MCP_TOOLS_CALL_COMMAND) {
pending.resolve({
ok: false,
error: {
code: "MCP_SERVER_UNAVAILABLE",
message: "node host disconnected during MCP tool call",
},
});
} else {
pending.reject(new Error(`node disconnected (${pending.command})`));
}
},
});
private authorizedSystemRunEvents = new Map<string, AuthorizedSystemRunEvent>();
@@ -379,26 +415,7 @@ export class NodeRegistry {
this.publishActiveNodeContext();
}
}
for (const [id, pending] of this.pendingInvokes.entries()) {
if (pending.connId !== connId) {
continue;
}
this.invokeStreams.clearTimers(pending);
if (pending.command === NODE_MCP_TOOLS_CALL_COMMAND) {
// Preserve MCP's structured failure contract when transport loss wins
// the race; callers can degrade instead of seeing an opaque invoke error.
pending.resolve({
ok: false,
error: {
code: "MCP_SERVER_UNAVAILABLE",
message: "node host disconnected during MCP tool call",
},
});
} else {
pending.reject(new Error(`node disconnected (${pending.command})`));
}
this.pendingInvokes.delete(id);
}
this.invokeStreams.handleDisconnect(connId);
for (const [key, event] of this.authorizedSystemRunEvents) {
if (event.connId === connId) {
this.authorizedSystemRunEvents.delete(key);
@@ -672,6 +689,8 @@ export class NodeRegistry {
onProgress?: (chunk: string) => void;
signal?: AbortSignal;
idempotencyKey?: string;
/** Receives the id synchronously after send; the terminal relay depends on this timing. */
onInvokeId?: (invokeId: string) => void;
}): Promise<NodeInvokeResult> {
if (params.signal?.aborted) {
return { ok: false, error: { code: "ABORTED", message: "node invoke cancelled" } };
@@ -705,25 +724,11 @@ export class NodeRegistry {
timeoutMs,
idempotencyKey: params.idempotencyKey,
};
const ok = this.sendEventToSession(node, "node.invoke.request", payload);
if (!ok) {
return {
ok: false,
error: { code: "UNAVAILABLE", message: "failed to send invoke to node" },
};
}
const systemRunEvent = resolvePendingSystemRunEvent({
command: params.command,
params: invokeParams,
});
if (systemRunEvent) {
this.rememberAuthorizedSystemRunEvent({
nodeId: params.nodeId,
connId: node.connId,
...systemRunEvent,
});
}
return await new Promise<NodeInvokeResult>((resolve, reject) => {
const result = new Promise<NodeInvokeResult>((resolve, reject) => {
const pending: PendingInvoke = {
nodeId: params.nodeId,
connId: node.connId,
@@ -733,6 +738,7 @@ export class NodeRegistry {
reject,
nextProgressSeq: 0,
progressChunks: new Map(),
nextInputSeq: 0,
...(params.onProgress ? { onProgress: params.onProgress } : {}),
};
const idleTimeoutMs = resolveTimerTimeoutMs(params.idleTimeoutMs, 0, 0);
@@ -744,15 +750,39 @@ export class NodeRegistry {
...(params.signal ? { signal: params.signal } : {}),
});
});
if (!this.pendingInvokes.has(requestId)) {
return await result;
}
const ok = this.sendEventToSession(node, "node.invoke.request", payload);
if (!ok) {
const pending = this.pendingInvokes.get(requestId);
if (pending) {
this.invokeStreams.clearTimers(pending);
this.pendingInvokes.delete(requestId);
pending.resolve({
ok: false,
error: { code: "UNAVAILABLE", message: "failed to send invoke to node" },
});
}
return await result;
}
if (systemRunEvent) {
this.rememberAuthorizedSystemRunEvent({
nodeId: params.nodeId,
connId: node.connId,
...systemRunEvent,
});
}
params.onInvokeId?.(requestId);
return await result;
}
handleInvokeProgress(params: {
invokeId: string;
nodeId: string;
connId: string | undefined;
seq: number;
chunk: string;
}): boolean {
/** Send one ordered input frame to a pending streaming invoke. */
sendInvokeInput(invokeId: string, payload: unknown): void {
this.invokeStreams.sendInput(invokeId, payload);
}
handleInvokeProgress(params: NodeInvokeProgressParams): boolean {
return this.invokeStreams.handleProgress(params);
}
@@ -905,38 +935,8 @@ export class NodeRegistry {
return `${params.nodeId}\0${params.connId}\0${params.sessionKey ?? ""}\0${params.runId}`;
}
handleInvokeResult(params: {
id: string;
nodeId: string;
connId: string | undefined;
ok: boolean;
payload?: unknown;
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
}): boolean {
const pending = this.pendingInvokes.get(params.id);
if (!pending) {
return false;
}
if (pending.nodeId !== params.nodeId || pending.connId !== params.connId) {
return false;
}
this.invokeStreams.clearTimers(pending);
this.pendingInvokes.delete(params.id);
if (!params.ok && pending.systemRunEvent) {
this.forgetAuthorizedSystemRunEvent({
nodeId: pending.nodeId,
connId: pending.connId,
...pending.systemRunEvent,
});
}
pending.resolve({
ok: params.ok,
payload: params.payload,
payloadJSON: params.payloadJSON ?? null,
error: params.error ?? null,
});
return true;
handleInvokeResult(params: NodeInvokeResultParams): boolean {
return this.invokeStreams.handleResult(params);
}
sendEvent(nodeId: string, event: string, payload?: unknown): boolean {
+1
View File
@@ -59,6 +59,7 @@ export const GATEWAY_EVENTS = [
"node.pair.resolved",
"node.presence",
"node.invoke.cancel",
"node.invoke.input",
"node.invoke.request",
"device.pair.requested",
"device.pair.resolved",
@@ -79,6 +79,24 @@ describe("session catalog Gateway methods", () => {
});
});
it("advertises terminal opening only for providers that implement it", async () => {
activeRegistry.sessionCatalogs = [
{
provider: provider("codex", {
openTerminal: async () => ({ kind: "local", argv: ["codex", "resume", "thread"] }),
}),
},
{ provider: provider("readonly") },
];
const respond = await call("sessions.catalog.list", {});
expect(respond).toHaveBeenCalledWith(true, {
catalogs: [
expect.objectContaining({ capabilities: expect.objectContaining({ openTerminal: true }) }),
expect.objectContaining({ capabilities: { continueSession: false, archive: false } }),
],
});
});
it("refreshes a provider's core new-session target when listing", async () => {
let createSession: { model: string; agentRuntime: string } | undefined = {
model: "anthropic/claude-opus-4-8",
@@ -37,6 +37,12 @@ function providers(): SessionCatalogProvider[] {
return registrations().map((entry) => entry.provider);
}
export function resolveSessionCatalogProvider(
catalogId: string,
): SessionCatalogProvider | undefined {
return providers().find((candidate) => candidate.id === catalogId);
}
function registrations() {
return (getPluginRegistryState()?.activeRegistry?.sessionCatalogs ?? []).toSorted((left, right) =>
left.provider.id.localeCompare(right.provider.id),
@@ -90,7 +96,7 @@ function providerOrRespond(
catalogId: string,
respond: RespondFn,
): SessionCatalogProvider | undefined {
const provider = providers().find((candidate) => candidate.id === catalogId);
const provider = resolveSessionCatalogProvider(catalogId);
if (!provider) {
respond(
false,
@@ -125,6 +131,7 @@ function catalogResult(
capabilities: {
continueSession: Boolean(provider.continueSession),
archive: Boolean(provider.archive),
...(provider.openTerminal ? { openTerminal: true } : {}),
...(createSession ? { createSession } : {}),
},
hosts,
@@ -129,6 +129,7 @@ export type GatewayRequestContext = {
nodeUnsubscribe: (nodeId: string, sessionKey: string) => void;
nodeUnsubscribeAll: (nodeId: string) => void;
hasConnectedTalkNode: () => boolean;
isConnectionActive?: (connId: string) => boolean;
hasExecApprovalClients?: (excludeConnId?: string) => boolean;
getApprovalClientConnIds?: <TPayload>(params?: {
excludeConnId?: string;
@@ -0,0 +1,62 @@
// Catalog terminal admission and spawn planning live here so the RPC handler
// can repeat the security checks without duplicating policy logic.
import type { SessionCatalogTerminalPlan } from "../../plugins/session-catalog.js";
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
import {
resolveTerminalSpawnPlan,
type TerminalLaunchPlan,
type TerminalSpawnPlan,
} from "../terminal/launch.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
type NodeCatalogTerminalPlan = Extract<SessionCatalogTerminalPlan, { kind: "node" }>;
export function authorizeCatalogTerminalNode(
context: GatewayRequestHandlerOptions["context"],
plan: NodeCatalogTerminalPlan,
):
| { ok: true; node: NonNullable<ReturnType<typeof context.nodeRegistry.get>> }
| {
ok: false;
message: string;
} {
const node = context.nodeRegistry.get(plan.nodeId);
if (!node) {
return { ok: false, message: "catalog terminal node is not connected" };
}
if (!node.commands.includes(plan.command)) {
return { ok: false, message: "catalog terminal command is not available" };
}
const allowlist = resolveNodeCommandAllowlist(context.getRuntimeConfig(), {
...node,
approvedCommands: node.commands,
});
const allowed = isNodeCommandAllowed({
command: plan.command,
declaredCommands: node.commands,
allowlist,
});
return allowed.ok ? { ok: true, node } : { ok: false, message: allowed.reason };
}
export function resolveTerminalOpenSpawnPlan(
launchPlan: TerminalLaunchPlan,
catalogPlan?: SessionCatalogTerminalPlan,
): TerminalSpawnPlan {
if (!catalogPlan) {
return resolveTerminalSpawnPlan(launchPlan);
}
if (catalogPlan.kind === "local") {
return resolveTerminalSpawnPlan({
...launchPlan,
initialCommand: catalogPlan.argv,
cwdOverride: catalogPlan.cwd,
});
}
return {
agentId: launchPlan.agentId,
cwd: catalogPlan.cwd ?? launchPlan.cwd,
shell: catalogPlan.title ?? catalogPlan.command,
args: [],
};
}
+414 -5
View File
@@ -1,16 +1,53 @@
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import type { SessionCatalogProvider } from "../../plugins/session-catalog.js";
import { createTerminalLaunchPolicy } from "../terminal/launch.js";
import { terminalHandlers } from "./terminal.js";
const policyMocks = vi.hoisted(() => ({
resolveNodeCommandAllowlist: vi.fn(() => new Set<string>()),
isNodeCommandAllowed: vi.fn<() => { ok: true } | { ok: false; reason: string }>(() => ({
ok: true,
})),
applyPluginNodeInvokePolicy: vi.fn<() => Promise<{ ok: false; message: string } | null>>(
async () => null,
),
}));
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
vi.mock("../node-command-policy.js", () => ({
resolveNodeCommandAllowlist: policyMocks.resolveNodeCommandAllowlist,
isNodeCommandAllowed: policyMocks.isNodeCommandAllowed,
}));
vi.mock("../node-invoke-plugin-policy.js", () => ({
applyPluginNodeInvokePolicy: policyMocks.applyPluginNodeInvokePolicy,
}));
function makeOpts(
params: unknown,
terminalConfig: { enabled?: boolean } | undefined,
terminalPolicyConfig?: OpenClawConfig,
nodeRegistry: { get: (nodeId: string) => unknown } = { get: () => undefined },
) {
const sessions = {
open: vi.fn(),
open: vi.fn(async () => ({
ok: true as const,
sessionId: "terminal-1",
agentId: "main",
shell: "/bin/zsh",
cwd: "/work",
})),
write: vi.fn(() => true),
resize: vi.fn(() => true),
close: vi.fn(() => true),
@@ -22,11 +59,16 @@ function makeOpts(
policy.prepareConfig(terminalPolicyConfig, { restartPending: true });
}
const respond = vi.fn();
const isConnectionActive = vi.fn(() => true);
const isTerminalEnabled = vi.fn(() => policy.isEnabled());
const resolveTerminalLaunchPolicy = vi.fn((agentId?: string) => policy.resolve(agentId));
const context = {
getRuntimeConfig: () => runtimeConfig,
resolveTerminalLaunchPolicy: (agentId?: string) => policy.resolve(agentId),
isTerminalEnabled: () => policy.isEnabled(),
resolveTerminalLaunchPolicy,
isTerminalEnabled,
terminalSessions: sessions,
nodeRegistry,
isConnectionActive,
logGateway: { info: vi.fn() },
} as unknown as Parameters<(typeof terminalHandlers)["terminal.input"]>[0]["context"];
const opts = {
@@ -35,10 +77,377 @@ function makeOpts(
context,
client: { connId: "conn-1", connect: {} },
} as unknown as Parameters<(typeof terminalHandlers)["terminal.input"]>[0];
return { opts, sessions, respond };
return {
opts,
sessions,
respond,
isConnectionActive,
isTerminalEnabled,
resolveTerminalLaunchPolicy,
};
}
function installCatalog(provider: SessionCatalogProvider) {
const registry = createEmptyPluginRegistry();
registry.sessionCatalogs.push({ pluginId: "test", provider, source: "test" });
setActivePluginRegistry(registry);
}
afterEach(() => {
resetPluginRuntimeStateForTest();
policyMocks.resolveNodeCommandAllowlist.mockReset();
policyMocks.isNodeCommandAllowed.mockReset().mockReturnValue({ ok: true });
policyMocks.applyPluginNodeInvokePolicy.mockReset().mockResolvedValue(null);
});
describe("terminal gateway policy", () => {
it("rejects catalog opens for missing providers", async () => {
const { opts, sessions, respond } = makeOpts(
{
cols: 80,
rows: 24,
catalog: { catalogId: "missing", hostId: "gateway:local", threadId: "thread" },
},
{ enabled: true },
);
await expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts);
expect(sessions.open).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(false, undefined, expect.any(Object));
});
it("opens a provider-built local resume plan and returns its title", async () => {
const openTerminal = vi.fn(async () => ({
kind: "local" as const,
argv: ["codex", "resume", "thread"],
title: "codex resume thread",
}));
installCatalog({
id: "codex",
label: "Codex",
list: async () => [],
read: async (request) => ({
hostId: request.hostId,
threadId: request.threadId,
items: [],
}),
openTerminal,
});
const { opts, sessions, respond } = makeOpts(
{
cols: 80,
rows: 24,
catalog: { catalogId: "codex", hostId: "gateway:local", threadId: "thread" },
},
{ enabled: true },
);
await expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts);
expect(openTerminal).toHaveBeenCalledWith({ hostId: "gateway:local", threadId: "thread" });
expect(sessions.open).toHaveBeenCalledWith(
expect.objectContaining({
shell: expect.any(String),
args: ["-il", "-c", "'codex' 'resume' 'thread'"],
}),
);
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ sessionId: "terminal-1", title: "codex resume thread" }),
);
});
it("does not create a terminal after the owning connection closes during catalog lookup", async () => {
const plan = deferred<{ kind: "local"; argv: string[] }>();
const openTerminal = vi.fn(() => plan.promise);
installCatalog({
id: "codex",
label: "Codex",
list: async () => [],
read: async (request) => ({ ...request, items: [] }),
openTerminal,
});
const { opts, sessions, respond, isConnectionActive } = makeOpts(
{
cols: 80,
rows: 24,
catalog: { catalogId: "codex", hostId: "gateway:local", threadId: "thread" },
},
{ enabled: true },
);
const opening = expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts);
await vi.waitFor(() => expect(openTerminal).toHaveBeenCalledOnce());
isConnectionActive.mockReturnValue(false);
plan.resolve({ kind: "local", argv: ["codex", "resume", "thread"] });
await opening;
expect(sessions.open).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: "terminal connection closed" }),
);
});
it("does not create a terminal when disabled during catalog lookup", async () => {
const plan = deferred<{ kind: "local"; argv: string[] }>();
const openTerminal = vi.fn(() => plan.promise);
installCatalog({
id: "codex",
label: "Codex",
list: async () => [],
read: async (request) => ({ ...request, items: [] }),
openTerminal,
});
const { opts, sessions, respond, isTerminalEnabled } = makeOpts(
{
cols: 80,
rows: 24,
catalog: { catalogId: "codex", hostId: "gateway:local", threadId: "thread" },
},
{ enabled: true },
);
const opening = expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts);
await vi.waitFor(() => expect(openTerminal).toHaveBeenCalledOnce());
isTerminalEnabled.mockReturnValue(false);
plan.resolve({ kind: "local", argv: ["codex", "resume", "thread"] });
await opening;
expect(sessions.open).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: "terminal is disabled" }),
);
});
it("uses the refreshed launch plan after catalog lookup", async () => {
const plan = deferred<{ kind: "local"; argv: string[] }>();
const openTerminal = vi.fn(() => plan.promise);
installCatalog({
id: "codex",
label: "Codex",
list: async () => [],
read: async (request) => ({ ...request, items: [] }),
openTerminal,
});
const { opts, sessions, resolveTerminalLaunchPolicy } = makeOpts(
{
cols: 80,
rows: 24,
catalog: { catalogId: "codex", hostId: "gateway:local", threadId: "thread" },
},
{ enabled: true },
);
const opening = expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts);
await vi.waitFor(() => expect(openTerminal).toHaveBeenCalledOnce());
resolveTerminalLaunchPolicy.mockReturnValue({
ok: true,
plan: { agentId: "main", cwd: process.cwd(), shell: "/bin/refreshed", args: [] },
});
plan.resolve({ kind: "local", argv: ["codex", "resume", "thread"] });
await opening;
expect(sessions.open).toHaveBeenCalledWith(
expect.objectContaining({ cwd: process.cwd(), shell: "/bin/refreshed" }),
);
});
it("rejects a node plan when its owner node is disconnected", async () => {
installCatalog({
id: "claude",
label: "Claude",
list: async () => [],
read: async (request) => ({
hostId: request.hostId,
threadId: request.threadId,
items: [],
}),
openTerminal: async () => ({
kind: "node",
nodeId: "node-1",
command: "anthropic.claude.terminal.resume.v1",
paramsJSON: JSON.stringify({ threadId: "thread" }),
}),
});
const { opts, sessions, respond } = makeOpts(
{
cols: 80,
rows: 24,
catalog: { catalogId: "claude", hostId: "node:node-1", threadId: "thread" },
},
{ enabled: true },
);
await expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts);
expect(sessions.open).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(false, undefined, expect.any(Object));
});
it("rejects a node plan denied by the node command allowlist", async () => {
const command = "anthropic.claude.terminal.resume.v1";
installCatalog({
id: "claude",
label: "Claude",
list: async () => [],
read: async (request) => ({ ...request, items: [] }),
openTerminal: async () => ({
kind: "node",
nodeId: "node-1",
command,
paramsJSON: JSON.stringify({ threadId: "thread" }),
}),
});
policyMocks.isNodeCommandAllowed.mockReturnValue({
ok: false,
reason: "command not allowlisted",
});
const { opts, sessions, respond } = makeOpts(
{
cols: 80,
rows: 24,
catalog: { catalogId: "claude", hostId: "node:node-1", threadId: "thread" },
},
{ enabled: true },
undefined,
{
get: () => ({ nodeId: "node-1", connId: "conn-node", commands: [command] }),
},
);
await expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts);
expect(sessions.open).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: "command not allowlisted" }),
);
});
it("opens a normally approved node relay after generic invoke policy", async () => {
const command = "anthropic.claude.terminal.resume.v1";
installCatalog({
id: "claude",
label: "Claude",
list: async () => [],
read: async (request) => ({ ...request, items: [] }),
openTerminal: async () => ({
kind: "node",
nodeId: "node-1",
command,
paramsJSON: JSON.stringify({ threadId: "thread" }),
}),
});
const node = { nodeId: "node-1", connId: "conn-node", commands: [command] };
const { opts, sessions } = makeOpts(
{
cols: 100,
rows: 30,
catalog: { catalogId: "claude", hostId: "node:node-1", threadId: "thread" },
},
{ enabled: true },
undefined,
{ get: () => node },
);
await expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts);
expect(policyMocks.resolveNodeCommandAllowlist).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ nodeId: "node-1", approvedCommands: [command] }),
);
expect(policyMocks.applyPluginNodeInvokePolicy).toHaveBeenCalledWith(
expect.objectContaining({
nodeSession: node,
command,
params: { threadId: "thread", cols: 100, rows: 30 },
}),
);
expect(sessions.open).toHaveBeenCalledOnce();
});
it("rejects a replacement node connection that lacks the terminal command", async () => {
const command = "anthropic.claude.terminal.resume.v1";
const policy = deferred<null>();
policyMocks.applyPluginNodeInvokePolicy.mockImplementationOnce(() => policy.promise);
installCatalog({
id: "claude",
label: "Claude",
list: async () => [],
read: async (request) => ({ ...request, items: [] }),
openTerminal: async () => ({
kind: "node",
nodeId: "node-1",
command,
paramsJSON: JSON.stringify({ threadId: "thread" }),
}),
});
let node = { nodeId: "node-1", connId: "conn-old", commands: [command] };
const { opts, sessions, respond } = makeOpts(
{
cols: 80,
rows: 24,
catalog: { catalogId: "claude", hostId: "node:node-1", threadId: "thread" },
},
{ enabled: true },
undefined,
{ get: () => node },
);
const opening = expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts);
await vi.waitFor(() => expect(policyMocks.applyPluginNodeInvokePolicy).toHaveBeenCalledOnce());
node = { nodeId: "node-1", connId: "conn-new", commands: [] };
policy.resolve(null);
await opening;
expect(sessions.open).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: "catalog terminal command is not available" }),
);
});
it("reports plugin invoke policy denial as unavailable", async () => {
const command = "codex.terminal.resume.v1";
installCatalog({
id: "codex",
label: "Codex",
list: async () => [],
read: async (request) => ({ ...request, items: [] }),
openTerminal: async () => ({
kind: "node",
nodeId: "node-1",
command,
paramsJSON: JSON.stringify({ threadId: "thread" }),
}),
});
policyMocks.applyPluginNodeInvokePolicy.mockResolvedValue({
ok: false,
message: "terminal resume denied",
});
const { opts, sessions, respond } = makeOpts(
{
cols: 80,
rows: 24,
catalog: { catalogId: "codex", hostId: "node:node-1", threadId: "thread" },
},
{ enabled: true },
undefined,
{ get: () => ({ nodeId: "node-1", connId: "conn-node", commands: [command] }) },
);
await expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts);
expect(sessions.open).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: "terminal resume denied" }),
);
});
it("rejects reopening after an accepted disable while restart is pending", async () => {
const { opts, sessions, respond } = makeOpts(
{ cols: 80, rows: 24 },
+148 -27
View File
@@ -6,6 +6,7 @@ import {
ErrorCodes,
errorShape,
formatValidationErrors,
type TerminalOpenParams,
validateTerminalAttachParams,
validateTerminalCloseParams,
validateTerminalInputParams,
@@ -13,8 +14,16 @@ import {
validateTerminalResizeParams,
validateTerminalTextParams,
} from "../../../packages/gateway-protocol/src/index.js";
import type { SessionCatalogTerminalPlan } from "../../plugins/session-catalog.js";
import { applyPluginNodeInvokePolicy } from "../node-invoke-plugin-policy.js";
import { renderTerminalBufferText } from "../terminal/buffer-text.js";
import { buildTerminalEnv } from "../terminal/launch.js";
import { buildTerminalEnv, type TerminalLaunchResolution } from "../terminal/launch.js";
import { createNodeRelayBackend } from "../terminal/node-relay.js";
import { resolveSessionCatalogProvider } from "./session-catalog.js";
import {
authorizeCatalogTerminalNode,
resolveTerminalOpenSpawnPlan,
} from "./terminal-open-plan.js";
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js";
function invalid(respond: GatewayRequestHandlerOptions["respond"], detail: string): void {
@@ -34,6 +43,33 @@ function terminalEnabled(context: GatewayRequestHandlerOptions["context"]): bool
return context.isTerminalEnabled();
}
function respondLaunchBlocked(
respond: GatewayRequestHandlerOptions["respond"],
block: Extract<TerminalLaunchResolution, { ok: false }>["block"],
): void {
if (block.kind === "disabled") {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is disabled"));
return;
}
if (block.kind === "unknown-agent") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent "${block.agentId}"`),
);
return;
}
// Fail closed: a sandboxed agent must never receive a host shell.
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`terminal unavailable: agent "${block.agentId}" runs in a sandbox (mode "${block.mode}"); in-sandbox terminals are not supported yet`,
),
);
}
/** Handlers for the operator terminal method family. */
export const terminalHandlers: GatewayRequestHandlers = {
"terminal.open": async (opts) => {
@@ -54,42 +90,126 @@ export const terminalHandlers: GatewayRequestHandlers = {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is not available"));
return;
}
const p = params as { agentId?: string; cols: number; rows: number };
const p = params as TerminalOpenParams;
const launch = context.resolveTerminalLaunchPolicy(p.agentId);
if (!launch.ok) {
if (launch.block.kind === "disabled") {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is disabled"));
return;
}
if (launch.block.kind === "unknown-agent") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent "${launch.block.agentId}"`),
);
return;
}
// Fail closed: a sandboxed agent must never receive a host shell.
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`terminal unavailable: agent "${launch.block.agentId}" runs in a sandbox (mode "${launch.block.mode}"); in-sandbox terminals are not supported yet`,
),
);
respondLaunchBlocked(respond, launch.block);
return;
}
let catalogPlan: SessionCatalogTerminalPlan | undefined;
let title: string | undefined;
let createBackend: (() => ReturnType<typeof createNodeRelayBackend>) | undefined;
if (p.catalog) {
const provider = resolveSessionCatalogProvider(p.catalog.catalogId);
if (!provider) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown session catalog: ${p.catalog.catalogId}`),
);
return;
}
if (!provider.openTerminal) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "session catalog cannot open terminals"),
);
return;
}
try {
catalogPlan = await provider.openTerminal({
hostId: p.catalog.hostId,
threadId: p.catalog.threadId,
});
} catch (error) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
error instanceof Error ? error.message : "catalog terminal open failed",
),
);
return;
}
title = catalogPlan.title;
if (catalogPlan.kind === "local") {
if (catalogPlan.argv.length === 0) {
invalid(respond, "catalog terminal plan has no command");
return;
}
} else {
const nodeCatalogPlan = catalogPlan;
const access = authorizeCatalogTerminalNode(context, nodeCatalogPlan);
if (!access.ok) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, access.message));
return;
}
let nodeParams: Record<string, unknown>;
try {
const parsed = JSON.parse(catalogPlan.paramsJSON) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("invalid params");
}
nodeParams = { ...(parsed as Record<string, unknown>), cols: p.cols, rows: p.rows };
} catch {
invalid(respond, "catalog terminal plan has invalid params");
return;
}
const policyResult = await applyPluginNodeInvokePolicy({
context,
client: opts.client,
nodeSession: access.node,
command: nodeCatalogPlan.command,
params: nodeParams,
});
if (policyResult && !policyResult.ok) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, policyResult.message));
return;
}
createBackend = async () =>
await createNodeRelayBackend({
registry: context.nodeRegistry,
nodeId: nodeCatalogPlan.nodeId,
command: nodeCatalogPlan.command,
params: nodeParams,
});
}
}
if (context.isConnectionActive?.(connId) === false) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal connection closed"));
return;
}
if (!terminalEnabled(context)) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is disabled"));
return;
}
const refreshedLaunch = context.resolveTerminalLaunchPolicy(p.agentId);
if (!refreshedLaunch.ok) {
respondLaunchBlocked(respond, refreshedLaunch.block);
return;
}
if (catalogPlan?.kind === "node") {
const access = authorizeCatalogTerminalNode(context, catalogPlan);
if (!access.ok) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, access.message));
return;
}
}
const spawnPlan = resolveTerminalOpenSpawnPlan(refreshedLaunch.plan, catalogPlan);
const outcome = await manager.open({
connId,
agentId: launch.plan.agentId,
cwd: launch.plan.cwd,
shell: launch.plan.shell,
args: launch.plan.args,
agentId: spawnPlan.agentId,
cwd: spawnPlan.cwd,
shell: spawnPlan.shell,
args: spawnPlan.args,
cols: p.cols,
rows: p.rows,
env: buildTerminalEnv(process.env),
...(createBackend ? { createBackend } : {}),
});
if (!outcome.ok) {
const code = outcome.code === "limit" ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE;
@@ -105,6 +225,7 @@ export const terminalHandlers: GatewayRequestHandlers = {
shell: outcome.shell,
cwd: outcome.cwd,
confined: false,
...(title ? { title } : {}),
});
},
+2
View File
@@ -130,6 +130,8 @@ export function createGatewayRequestContext(
nodeUnsubscribe: params.nodeUnsubscribe,
nodeUnsubscribeAll: params.nodeUnsubscribeAll,
hasConnectedTalkNode: params.hasConnectedTalkNode,
isConnectionActive: (connId) =>
[...params.clients].some((client) => client.connId === connId && !client.invalidated),
hasExecApprovalClients: (excludeConnId?: string) => {
for (const gatewayClient of params.clients) {
if (excludeConnId && gatewayClient.connId === excludeConnId) {
+31
View File
@@ -0,0 +1,31 @@
import { spawnTerminalPty } from "../../process/terminal-pty.js";
export type TerminalBackendExit = {
exitCode?: number;
signal?: number;
error?: string;
};
export interface TerminalBackend {
write(data: string): void;
resize(cols: number, rows: number): void;
kill(): void;
onData(callback: (data: string) => void): void;
onExit(callback: (exit: TerminalBackendExit) => void): void;
}
export type LocalTerminalBackendSpawner = typeof spawnTerminalPty;
export async function createLocalTerminalBackend(
params: Parameters<typeof spawnTerminalPty>[0],
spawn: LocalTerminalBackendSpawner = spawnTerminalPty,
): Promise<TerminalBackend> {
const pty = await spawn(params);
return {
write: (data) => pty.write(data),
resize: (cols, rows) => pty.resize(cols, rows),
kill: () => pty.kill(),
onData: (callback) => pty.onData(callback),
onExit: (callback) => pty.onExit(callback),
};
}
+59 -1
View File
@@ -1,7 +1,12 @@
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { buildTerminalEnv, createTerminalLaunchPolicy } from "./launch.js";
import {
buildTerminalEnv,
createTerminalLaunchPolicy,
resolveTerminalSpawnPlan,
shellQuote,
} from "./launch.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
@@ -352,3 +357,56 @@ describe("buildTerminalEnv", () => {
expect(env.TERM).toBe("screen-256color");
});
});
describe("resolveTerminalSpawnPlan", () => {
it("quotes every command argument for a login shell", () => {
expect(shellQuote("plain")).toBe("'plain'");
expect(shellQuote("a b;$HOME")).toBe("'a b;$HOME'");
expect(shellQuote("it's")).toBe("'it'\"'\"'s'");
const plan = resolveTerminalSpawnPlan({
agentId: "main",
cwd: "/work",
shell: "/bin/zsh",
args: ["-l"],
initialCommand: ["codex", "resume", "a b;$HOME", "it's"],
});
expect(plan).toMatchObject({
shell: "/bin/zsh",
args: ["-il", "-c", "'codex' 'resume' 'a b;$HOME' 'it'\"'\"'s'"],
});
});
it("uses a valid cwd override and falls back to home for a missing override", () => {
const cwd = tempDirs.make("terminal-resume-cwd-");
const base = {
agentId: "main",
cwd: "/missing/base",
shell: "/bin/sh",
args: [],
initialCommand: ["claude", "--resume", "id"],
};
expect(resolveTerminalSpawnPlan({ ...base, cwdOverride: cwd }).cwd).toBe(cwd);
expect(
resolveTerminalSpawnPlan(
{ ...base, cwdOverride: "/definitely/missing" },
{ env: { HOME: "/fallback/home" } },
).cwd,
).toBe("/fallback/home");
});
it("spawns the resume executable directly on Windows", () => {
expect(
resolveTerminalSpawnPlan(
{
agentId: "main",
cwd: "/work",
shell: "cmd.exe",
args: [],
initialCommand: ["codex.exe", "resume", "thread"],
},
{ platform: "win32" },
),
).toMatchObject({ shell: "codex.exe", args: ["resume", "thread"] });
});
});
+36 -1
View File
@@ -19,13 +19,17 @@ type TerminalLaunchBlock =
| { kind: "sandboxed"; agentId: string; mode: "all" };
/** Resolved plan for a host terminal session. */
type TerminalLaunchPlan = {
export type TerminalLaunchPlan = {
agentId: string;
cwd: string;
shell: string;
args: string[];
initialCommand?: string[];
cwdOverride?: string;
};
export type TerminalSpawnPlan = Pick<TerminalLaunchPlan, "agentId" | "shell" | "args" | "cwd">;
/** Terminal launch resolution result: either a runnable plan or a block reason. */
export type TerminalLaunchResolution =
| { ok: true; plan: TerminalLaunchPlan }
@@ -285,6 +289,37 @@ export function buildTerminalEnv(baseEnv: NodeJS.ProcessEnv): Record<string, str
return env;
}
export function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
/** Converts a policy-approved plan into the exact local PTY spawn. */
export function resolveTerminalSpawnPlan(
plan: TerminalLaunchPlan,
options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {},
): TerminalSpawnPlan {
const env = options.env ?? process.env;
const cwd = existingDirOrHome(plan.cwdOverride ?? plan.cwd, env);
const command = plan.initialCommand;
if (!command || command.length === 0) {
return { agentId: plan.agentId, shell: plan.shell, args: plan.args, cwd };
}
if ((options.platform ?? process.platform) === "win32") {
return {
agentId: plan.agentId,
shell: command[0] ?? plan.shell,
args: command.slice(1),
cwd,
};
}
return {
agentId: plan.agentId,
shell: plan.shell,
args: ["-il", "-c", command.map(shellQuote).join(" ")],
cwd,
};
}
// A workspace dir that has not been created yet would make the PTY spawn fail;
// fall back to the home directory so the terminal still opens.
function existingDirOrHome(dir: string, env: NodeJS.ProcessEnv): string {
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from "vitest";
import type { NodeInvokeResult, NodeRegistry } from "../node-registry.js";
import { createNodeRelayBackend } from "./node-relay.js";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
describe("createNodeRelayBackend", () => {
it("relays progress, input, resize, cancellation, and the node exit result", async () => {
const invokeResult = deferred<NodeInvokeResult>();
let onProgress: ((chunk: string) => void) | undefined;
let signal: AbortSignal | undefined;
const sendInvokeInput = vi.fn();
const registry = {
invoke: vi.fn(
(params: {
onInvokeId?: (id: string) => void;
onProgress?: (chunk: string) => void;
signal?: AbortSignal;
}) => {
onProgress = params.onProgress;
signal = params.signal;
params.onInvokeId?.("invoke-1");
return invokeResult.promise;
},
),
sendInvokeInput,
} as unknown as NodeRegistry;
const backend = await createNodeRelayBackend({
registry,
nodeId: "node-1",
command: "codex.terminal.resume.v1",
params: { threadId: "thread" },
});
const data = vi.fn();
const exit = vi.fn();
backend.onData(data);
backend.onExit(exit);
onProgress?.("");
onProgress?.("hello");
expect(data).toHaveBeenCalledWith("hello");
backend.write("keys");
backend.resize(100, 30);
expect(sendInvokeInput).toHaveBeenNthCalledWith(1, "invoke-1", {
kind: "data",
data: "keys",
});
expect(sendInvokeInput).toHaveBeenNthCalledWith(2, "invoke-1", {
kind: "resize",
cols: 100,
rows: 30,
});
invokeResult.resolve({ ok: true, payloadJSON: JSON.stringify({ exitCode: 7, signal: 15 }) });
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith({ exitCode: 7, signal: 15 }));
backend.kill();
expect(signal?.aborted).toBe(true);
});
it("maps node disconnect failures to terminal errors", async () => {
const registry = {
invoke: vi.fn((params: { onInvokeId?: (id: string) => void }) => {
params.onInvokeId?.("invoke-2");
return Promise.resolve({
ok: false,
error: { code: "NOT_CONNECTED", message: "node disconnected" },
});
}),
sendInvokeInput: vi.fn(),
} as unknown as NodeRegistry;
const backend = await createNodeRelayBackend({
registry,
nodeId: "node-1",
command: "anthropic.claude.terminal.resume.v1",
params: {},
});
const exit = vi.fn();
backend.onExit(exit);
await vi.waitFor(() =>
expect(exit).toHaveBeenCalledWith({ error: "NOT_CONNECTED: node disconnected" }),
);
});
});
+145
View File
@@ -0,0 +1,145 @@
import { NODE_DUPLEX_INVOKE_IDLE_TIMEOUT_MS } from "../../infra/node-commands.js";
import type { NodeRegistry, NodeInvokeResult } from "../node-registry.js";
import type { TerminalBackend, TerminalBackendExit } from "./backend.js";
const DATA_INPUT_CHUNK_BYTES = 2 * 1024;
function parseExit(result: NodeInvokeResult): TerminalBackendExit {
if (!result.ok) {
const code = result.error?.code ?? "NODE_INVOKE_FAILED";
const message = result.error?.message ?? "node terminal invoke failed";
return { error: `${code}: ${message}` };
}
try {
const raw =
result.payloadJSON ??
(result.payload === undefined ? undefined : JSON.stringify(result.payload));
if (!raw) {
return { exitCode: 0 };
}
const value = JSON.parse(raw) as unknown;
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { exitCode: 0 };
}
const record = value as Record<string, unknown>;
return {
...(typeof record.exitCode === "number" ? { exitCode: record.exitCode } : {}),
...(typeof record.signal === "number" ? { signal: record.signal } : {}),
};
} catch {
return { error: "node terminal returned an invalid exit result" };
}
}
function splitInput(data: string): string[] {
const chunks: string[] = [];
let start = 0;
let bytes = 0;
for (let index = 0; index < data.length; index += 1) {
const codePoint = data.codePointAt(index);
if (codePoint === undefined) {
break;
}
const char = String.fromCodePoint(codePoint);
const size = Buffer.byteLength(char, "utf8");
if (bytes > 0 && bytes + size > DATA_INPUT_CHUNK_BYTES) {
chunks.push(data.slice(start, index));
start = index;
bytes = 0;
}
bytes += size;
if (char.length === 2) {
index += 1;
}
}
if (start < data.length) {
chunks.push(data.slice(start));
}
return chunks;
}
export async function createNodeRelayBackend(params: {
registry: NodeRegistry;
nodeId: string;
command: string;
params: Record<string, unknown>;
}): Promise<TerminalBackend> {
let invokeId: string | undefined;
let dataCallback: ((data: string) => void) | undefined;
let exitCallback: ((exit: TerminalBackendExit) => void) | undefined;
const pendingData: string[] = [];
let pendingExit: TerminalBackendExit | undefined;
const abort = new AbortController();
const result = params.registry
.invoke({
nodeId: params.nodeId,
command: params.command,
params: params.params,
timeoutMs: 0,
idleTimeoutMs: NODE_DUPLEX_INVOKE_IDLE_TIMEOUT_MS,
signal: abort.signal,
onInvokeId: (id) => {
invokeId = id;
},
onProgress: (chunk) => {
if (!chunk) {
return;
}
if (dataCallback) {
dataCallback(chunk);
} else {
pendingData.push(chunk);
}
},
})
.then(parseExit)
.catch(
(error: unknown): TerminalBackendExit => ({
error: error instanceof Error ? error.message : String(error),
}),
)
.then((exit) => {
if (exitCallback) {
exitCallback(exit);
} else {
pendingExit = exit;
}
return exit;
});
// NodeRegistry invokes onInvokeId synchronously after a successful send, before its first await.
// Failure paths resolve the result instead; keeping that callback synchronous is load-bearing.
await Promise.resolve();
if (!invokeId) {
const exit = await result;
throw new Error(exit.error ?? "failed to start node terminal invoke");
}
const activeInvokeId = invokeId;
const send = (payload: unknown) => params.registry.sendInvokeInput(activeInvokeId, payload);
return {
write(data) {
for (const chunk of splitInput(data)) {
send({ kind: "data", data: chunk });
}
},
resize(cols, rows) {
send({ kind: "resize", cols, rows });
},
kill() {
abort.abort();
},
onData(callback) {
dataCallback = callback;
for (const chunk of pendingData.splice(0)) {
callback(chunk);
}
},
onExit(callback) {
exitCallback = callback;
if (pendingExit) {
const exit = pendingExit;
pendingExit = undefined;
callback(exit);
}
},
};
}
+33
View File
@@ -0,0 +1,33 @@
// Bounded terminal scrollback storage shared by session lifecycle operations.
/**
* Raw output, not a screen snapshot: head truncation can start replay mid-escape,
* and the emulator recovers on its next full repaint.
*/
export class TerminalOutputRing {
private chunks: string[] = [];
private total = 0;
constructor(private readonly cap: number) {}
push(chunk: string): void {
if (chunk.length >= this.cap) {
this.chunks = [chunk.slice(chunk.length - this.cap)];
this.total = this.cap;
return;
}
this.chunks.push(chunk);
this.total += chunk.length;
// Evict whole PTY writes so surviving data keeps its original boundaries.
while (this.total > this.cap && this.chunks.length > 1) {
const head = this.chunks.shift();
if (!head) {
break;
}
this.total -= head.length;
}
}
snapshot(): string {
return this.chunks.join("");
}
}
-68
View File
@@ -1,68 +0,0 @@
// Thin, resize-capable PTY wrapper for the operator terminal.
//
// The process supervisor's PTY adapter is shaped for one-shot managed runs and
// hides resize; the operator terminal needs a long-lived, interactive handle, so
// it owns this narrow loader instead of reshaping the supervisor contract.
import type { IPty } from "@lydell/node-pty";
import { signalProcessTree } from "../../process/kill-tree.js";
/** Live PTY handle used by one operator terminal session. */
export type TerminalPtyHandle = {
pid: number;
write: (data: string) => void;
resize: (cols: number, rows: number) => void;
onData: (listener: (chunk: string) => void) => void;
onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => void;
kill: (signal?: string) => void;
};
/** Spawns a PTY process and adapts it to the terminal session handle. */
export async function spawnTerminalPty(params: {
file: string;
args: string[];
cwd?: string;
env: Record<string, string>;
cols: number;
rows: number;
}): Promise<TerminalPtyHandle> {
const { spawn } = await import("@lydell/node-pty");
const pty = spawn(params.file, params.args, {
name: params.env.TERM ?? "xterm-256color",
cols: params.cols,
rows: params.rows,
cwd: params.cwd,
env: params.env,
});
return {
get pid() {
return pty.pid;
},
write: (data) => pty.write(data),
resize: (cols, rows) => pty.resize(cols, rows),
onData: (listener) => {
pty.onData(listener);
},
onExit: (listener) => {
pty.onExit(listener);
},
kill: (signal) => killPtyTree(pty, signal),
} satisfies TerminalPtyHandle;
}
// node-pty's kill only signals the shell; commands it launched (a long-running
// `npm install`, `sleep`, etc.) would survive close/disconnect/shutdown. Signal
// the whole process tree instead, mirroring the process supervisor's PTY adapter.
function killPtyTree(pty: Pick<IPty, "pid" | "kill">, signal?: string): void {
const sig = (signal ?? "SIGKILL") as NodeJS.Signals;
try {
if ((sig === "SIGKILL" || sig === "SIGTERM") && typeof pty.pid === "number" && pty.pid > 0) {
signalProcessTree(pty.pid, sig);
} else if (process.platform === "win32") {
pty.kill();
} else {
pty.kill(sig);
}
} catch {
// Process may already be gone; teardown is best-effort.
}
}
+47 -1
View File
@@ -1,6 +1,7 @@
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it, vi } from "vitest";
import type { TerminalPtyHandle } from "./pty.js";
import type { TerminalPtyHandle } from "../../process/terminal-pty.js";
import type { TerminalBackend } from "./backend.js";
import { TerminalSessionManager } from "./session-manager.js";
type TerminalOpenRequest = Parameters<TerminalSessionManager["open"]>[0];
@@ -24,6 +25,8 @@ function makeFakePty() {
killed: false,
write: (data) => handle.writes.push(data),
resize: (cols, rows) => handle.resizes.push([cols, rows]),
pause: () => {},
resume: () => {},
onData: (listener) => {
dataListener = listener;
},
@@ -54,6 +57,49 @@ function baseRequest(overrides?: Partial<TerminalOpenRequest>): TerminalOpenRequ
}
describe("TerminalSessionManager", () => {
it("runs relay backends through the same stream, input, resize, and close lifecycle", async () => {
let onData: ((data: string) => void) | undefined;
let onExit:
| ((exit: { exitCode?: number; signal?: number; error?: string }) => void)
| undefined;
const write = vi.fn();
const resize = vi.fn();
const kill = vi.fn();
const backend: TerminalBackend = {
write,
resize,
kill,
onData: (callback) => {
onData = callback;
},
onExit: (callback) => {
onExit = callback;
},
};
const emit = vi.fn();
const manager = new TerminalSessionManager({ emit });
const opened = await manager.open(baseRequest({ createBackend: async () => backend }));
if (!opened.ok) {
throw new Error("expected relay backend open");
}
onData?.("relay output");
expect(emit).toHaveBeenCalledWith("conn-1", TERMINAL_EVENT_DATA, {
sessionId: opened.sessionId,
seq: 0,
data: "relay output",
});
expect(manager.write("conn-1", opened.sessionId, "input")).toBe(true);
expect(write).toHaveBeenCalledWith("input");
expect(manager.resize("conn-1", opened.sessionId, 120, 40)).toBe(true);
expect(resize).toHaveBeenCalledWith(120, 40);
expect(manager.close("conn-1", opened.sessionId)).toBe(true);
expect(kill).toHaveBeenCalledOnce();
onExit?.({ exitCode: 0 });
expect(manager.size).toBe(0);
});
it("opens a session and streams output only to the owning connection", async () => {
const emit = vi.fn();
const fake = makeFakePty();
+37 -61
View File
@@ -1,14 +1,17 @@
// Owns the lifecycle of operator terminal sessions: one PTY per open, bound to
// the connection that opened it, streamed back over the gateway event channel.
import { randomUUID } from "node:crypto";
import { spawnTerminalPty, type TerminalPtyHandle } from "./pty.js";
import {
createLocalTerminalBackend,
type LocalTerminalBackendSpawner,
type TerminalBackend,
} from "./backend.js";
import { TerminalOutputRing } from "./output-ring.js";
/** Emits one terminal event frame to the single owning connection. */
type TerminalEventSink = (connId: string, event: string, payload: unknown) => void;
/** Injectable PTY spawner so tests can drive sessions without a real shell. */
type TerminalSpawner = typeof spawnTerminalPty;
const TERMINAL_EVENT_DATA = "terminal.data" as const;
const TERMINAL_EVENT_EXIT = "terminal.exit" as const;
@@ -21,7 +24,7 @@ type TerminalSession = {
agentId: string;
cwd: string;
shell: string;
pty: TerminalPtyHandle;
backend: TerminalBackend;
seq: number;
closed: boolean;
createdAtMs: number;
@@ -58,46 +61,9 @@ const DEFAULT_MAX_DETACHED_SESSIONS = 8;
/** Default grace period before a detached session is killed (seconds). */
export const DEFAULT_TERMINAL_DETACH_SECONDS = 300;
/**
* Bounded ring of recent PTY output. Raw bytes, not a screen snapshot: after
* head truncation a replay can start mid-escape-sequence; emulators recover on
* the next full repaint (prompt, clear, resize-triggered redraw). A true
* server-side VT snapshot would need a terminal emulator per session and is a
* tracked follow-up.
*/
class TerminalOutputRing {
private chunks: string[] = [];
private total = 0;
constructor(private readonly cap: number) {}
push(chunk: string): void {
if (chunk.length >= this.cap) {
this.chunks = [chunk.slice(chunk.length - this.cap)];
this.total = this.cap;
return;
}
this.chunks.push(chunk);
this.total += chunk.length;
// Evict whole chunks (PTY write granularity) so surviving data keeps its
// original boundaries; the ring may briefly dip below cap, never above.
while (this.total > this.cap && this.chunks.length > 1) {
const head = this.chunks.shift();
if (!head) {
break;
}
this.total -= head.length;
}
}
snapshot(): string {
return this.chunks.join("");
}
}
type TerminalSessionManagerOptions = {
emit: TerminalEventSink;
spawn?: TerminalSpawner;
spawn?: LocalTerminalBackendSpawner;
maxSessions?: number;
env?: NodeJS.ProcessEnv;
/**
@@ -121,6 +87,7 @@ type TerminalOpenRequest = {
cols: number;
rows: number;
env: Record<string, string>;
createBackend?: () => Promise<TerminalBackend>;
};
type TerminalOpenOutcome =
@@ -142,7 +109,7 @@ export class TerminalSessionManager {
// orphan for a dead connection.
private readonly pendingOpens = new Map<string, Set<OpenToken>>();
private readonly emit: TerminalEventSink;
private readonly spawn: TerminalSpawner;
private readonly spawn?: LocalTerminalBackendSpawner;
private readonly maxSessions: number;
private readonly detachGraceMs: number;
private readonly maxDetachedSessions: number;
@@ -153,7 +120,7 @@ export class TerminalSessionManager {
constructor(options: TerminalSessionManagerOptions) {
this.emit = options.emit;
this.spawn = options.spawn ?? spawnTerminalPty;
this.spawn = options.spawn;
this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
this.detachGraceMs = options.detachGraceMs ?? 0;
this.maxDetachedSessions = options.maxDetachedSessions ?? DEFAULT_MAX_DETACHED_SESSIONS;
@@ -178,16 +145,21 @@ export class TerminalSessionManager {
this.opening += 1;
const token: OpenToken = { agentId: request.agentId };
this.trackPendingOpen(request.connId, token);
let pty: TerminalPtyHandle;
let backend: TerminalBackend;
try {
pty = await this.spawn({
file: request.shell,
args: request.args,
cwd: request.cwd,
env: request.env,
cols: request.cols,
rows: request.rows,
});
backend = request.createBackend
? await request.createBackend()
: await createLocalTerminalBackend(
{
file: request.shell,
args: request.args,
cwd: request.cwd,
env: request.env,
cols: request.cols,
rows: request.rows,
},
this.spawn,
);
} catch (err) {
this.opening -= 1;
this.untrackPendingOpen(request.connId, token);
@@ -202,7 +174,7 @@ export class TerminalSessionManager {
// The owning connection disconnected while the shell was spawning; kill it
// now rather than register an orphan no one can reach or close.
try {
pty.kill();
backend.kill();
} catch {
// Best-effort; the process may already be gone.
}
@@ -215,7 +187,7 @@ export class TerminalSessionManager {
agentId: request.agentId,
cwd: request.cwd,
shell: request.shell,
pty,
backend,
seq: 0,
closed: false,
createdAtMs: Date.now(),
@@ -226,7 +198,7 @@ export class TerminalSessionManager {
this.sessions.set(session.id, session);
this.indexByConn(request.connId, session.id);
pty.onData((chunk) => {
backend.onData((chunk) => {
if (session.closed) {
return;
}
@@ -241,9 +213,13 @@ export class TerminalSessionManager {
data: chunk,
});
});
pty.onExit((event) => {
backend.onExit((event) => {
const signal = event.signal && event.signal !== 0 ? event.signal : null;
this.finalize(session, "process_exit", { exitCode: event.exitCode ?? null, signal });
this.finalize(session, event.error ? "error" : "process_exit", {
exitCode: event.exitCode ?? null,
signal,
...(event.error ? { error: event.error } : {}),
});
});
return {
@@ -262,7 +238,7 @@ export class TerminalSessionManager {
return false;
}
try {
session.pty.write(data);
session.backend.write(data);
return true;
} catch {
this.finalize(session, "error", { error: "write failed" });
@@ -277,7 +253,7 @@ export class TerminalSessionManager {
return false;
}
try {
session.pty.resize(cols, rows);
session.backend.resize(cols, rows);
return true;
} catch {
return false;
@@ -517,7 +493,7 @@ export class TerminalSessionManager {
this.byConn.get(session.connId)?.delete(session.id);
}
try {
session.pty.kill();
session.backend.kill();
} catch {
// Process may already be gone; the kill is best-effort teardown.
}
+5
View File
@@ -10,6 +10,11 @@ export const NODE_FS_LIST_DIR_COMMAND = "fs.listDir";
export const NODE_BROWSER_PROXY_COMMAND = "browser.proxy";
export const NODE_MCP_TOOLS_CALL_COMMAND = "mcp.tools.call.v1";
export const NODE_AGENT_CLI_CLAUDE_RUN_COMMAND = "agent.cli.claude.run.v1";
// Node duplex heartbeats must arrive before the Gateway relay declares the
// invoke idle, so both processes share this timeout contract.
export const NODE_DUPLEX_INVOKE_IDLE_TIMEOUT_MS = 30_000;
export const NODE_EXEC_APPROVALS_COMMANDS = [
"system.execApprovals.get",
"system.execApprovals.set",
@@ -1,4 +1,5 @@
import { createExecApprovalPolicySnapshot } from "../infra/exec-approvals.js";
import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
import type { NodeHostClient } from "./client.js";
import {
decodeClaudeCliNodeRunParams,
@@ -17,6 +18,7 @@ export type NodeHostInvokeRuntime = {
claudePath?: string;
handleSystemRun?: typeof handleSystemRunInvoke;
signal?: AbortSignal;
pluginCommandIo?: OpenClawPluginNodeHostCommandIo;
};
type ClaudeCliNodeInvokeDeps = Pick<
@@ -97,6 +97,15 @@ function requireBoundedString(value: unknown, label: string, maxBytes: number):
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");
+17 -113
View File
@@ -6,42 +6,15 @@ import path from "node:path";
import { StringDecoder } from "node:string_decoder";
import { signalProcessTree } from "../process/kill-tree.js";
import { resolveSafeChildProcessInvocation } from "../process/windows-command.js";
import { truncateUtf8Prefix, truncateUtf8Suffix } from "../utils/utf8-truncate.js";
import { truncateUtf8Suffix } from "../utils/utf8-truncate.js";
import type { NodeHostClient } from "./client.js";
import type { ClaudeCliNodeRunParams } from "./invoke-agent-cli-claude-params.js";
import type { NodeInvokeRequestPayload, RunResult } from "./invoke-types.js";
import { createNodeInvokeProgressWriter } from "./node-invoke-progress.js";
const OUTPUT_CAP_BYTES = 200_000;
const STDERR_TAIL_BYTES = 20_000;
const PROGRESS_CHUNK_BYTES = 16 * 1024;
const TERMINAL_EVENT_MAX_BYTES = 1024 * 1024;
const MIN_HEARTBEAT_INTERVAL_MS = 250;
const MAX_HEARTBEAT_INTERVAL_MS = 5_000;
async function sendProgressChunks(
client: NodeHostClient,
frame: NodeInvokeRequestPayload,
startSeq: number,
text: string,
): Promise<number> {
let seq = startSeq;
let remaining = text;
while (remaining) {
const chunk = truncateUtf8Prefix(remaining, PROGRESS_CHUNK_BYTES);
if (!chunk) {
break;
}
await client.request("node.invoke.progress", {
invokeId: frame.id,
nodeId: frame.nodeId,
seq,
chunk,
});
seq += 1;
remaining = remaining.slice(chunk.length);
}
return seq;
}
function isClaudeResultLine(line: string): boolean {
try {
@@ -95,13 +68,6 @@ export async function runClaudeCliNodeCommand(params: {
let truncated = false;
let outputBytes = 0;
let stderr = "";
let progressSeq = 0;
let progressQueue = Promise.resolve();
let progressError: Error | undefined;
let heartbeatQueued = false;
let heartbeatDirty = false;
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
let lastProgressAt = 0;
const decoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
const terminalDecoder = new StringDecoder("utf8");
@@ -133,6 +99,12 @@ export async function runClaudeCliNodeCommand(params: {
// Best effort; close/error settles the result.
}
};
const progress = createNodeInvokeProgressWriter({
client: params.client,
frame: params.frame,
idleTimeoutMs: params.request.idleTimeoutMs,
onError: kill,
});
const abortRun = () => {
cancelled = true;
kill();
@@ -193,57 +165,6 @@ export async function runClaudeCliNodeCommand(params: {
terminalLineTouchesTruncation = false;
}
};
const queueProgressTask = (stream: typeof child.stdout, task: () => Promise<void>): void => {
stream.pause();
progressQueue = progressQueue
.then(task)
.catch((error: unknown) => {
progressError = error instanceof Error ? error : new Error(String(error));
kill();
})
.finally(() => stream.resume());
};
const heartbeatIntervalMs = Math.max(
MIN_HEARTBEAT_INTERVAL_MS,
Math.min(MAX_HEARTBEAT_INTERVAL_MS, Math.floor(params.request.idleTimeoutMs / 2)),
);
const queueHeartbeat = () => {
if (settled) {
return;
}
if (heartbeatQueued) {
heartbeatDirty = true;
return;
}
heartbeatQueued = true;
const delayMs = Math.max(0, heartbeatIntervalMs - (Date.now() - lastProgressAt));
heartbeatTimer = setTimeout(() => {
heartbeatTimer = undefined;
progressQueue = progressQueue
.then(async () => {
await params.client.request("node.invoke.progress", {
invokeId: params.frame.id,
nodeId: params.frame.nodeId,
seq: progressSeq,
chunk: "",
});
progressSeq += 1;
lastProgressAt = Date.now();
})
.catch((error: unknown) => {
progressError = error instanceof Error ? error : new Error(String(error));
kill();
})
.finally(() => {
heartbeatQueued = false;
if (heartbeatDirty && !settled) {
heartbeatDirty = false;
queueHeartbeat();
}
});
}, delayMs);
};
child.stdout.on("data", (raw: Buffer) => {
const retained = retain(raw);
if (retained.length > 0) {
@@ -256,20 +177,17 @@ export async function runClaudeCliNodeCommand(params: {
// keep the node-local kill timer on the same signal to avoid orphan runs.
resetIdleTimer();
if (retained.length === 0) {
queueHeartbeat();
progress.queueHeartbeat();
return;
}
const text = decoder.write(retained);
lastProgressAt = Date.now();
queueProgressTask(child.stdout, async () => {
progressSeq = await sendProgressChunks(params.client, params.frame, progressSeq, text);
});
void progress.write(text, child.stdout);
});
child.stderr.on("data", (raw: Buffer) => {
retain(raw);
stderr = truncateUtf8Suffix(`${stderr}${stderrDecoder.write(raw)}`, STDERR_TAIL_BYTES);
resetIdleTimer();
queueHeartbeat();
progress.queueHeartbeat();
});
child.stdin.on("error", () => {});
child.stdin.end(params.request.stdin ?? "");
@@ -281,19 +199,11 @@ export async function runClaudeCliNodeCommand(params: {
settled = true;
clearTimeout(hardTimer);
clearTimeout(idleTimer);
clearTimeout(heartbeatTimer);
heartbeatDirty = false;
progress.stopHeartbeats();
params.signal?.removeEventListener("abort", abortRun);
const finalText = decoder.end();
if (finalText) {
progressQueue = progressQueue.then(async () => {
progressSeq = await sendProgressChunks(
params.client,
params.frame,
progressSeq,
finalText,
);
});
void progress.write(finalText);
}
const terminalText = terminalDecoder.end();
if (terminalText) {
@@ -311,22 +221,16 @@ export async function runClaudeCliNodeCommand(params: {
terminalResultLine = terminalLineBuffer;
}
if (truncated && terminalResultLine) {
progressQueue = progressQueue.then(async () => {
progressSeq = await sendProgressChunks(
params.client,
params.frame,
progressSeq,
`\n${terminalResultLine}\n`,
);
});
void progress.write(`\n${terminalResultLine}\n`);
}
await progressQueue.catch(() => {});
await progress.flush();
progress.stop();
const timeoutMessage = idleTimedOut
? "Claude CLI produced no output before the idle timeout"
: hardTimedOut
? "Claude CLI exceeded the hard timeout"
: "";
const finalError = progressError ?? error;
const finalError = progress.error ?? error;
const cancelledMessage = cancelled ? "Claude CLI invocation cancelled" : "";
resolve({
exitCode: exitCode ?? (idleTimedOut || hardTimedOut ? 124 : cancelled ? 130 : 1),
+28
View File
@@ -1,5 +1,7 @@
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;
@@ -40,3 +42,29 @@ export function coerceNodeInvokeCancelPayload(
? { 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,
};
}
+4 -4
View File
@@ -64,7 +64,7 @@ import type {
SystemRunParams,
} from "./invoke-types.js";
import { NodeHostMcpError, type NodeHostMcpManager } from "./mcp.js";
import { invokeRegisteredNodeHostCommand } from "./plugin-node-host.js";
import { invokeRegisteredNodeHostCommand as invokePlugin } from "./plugin-node-host.js";
import { resolveNodeHostedSkillDirectory } from "./skills.js";
const OUTPUT_CAP = 200_000;
@@ -708,9 +708,9 @@ async function dispatchInvoke(
}
try {
const pluginNodeHostResult = await invokeRegisteredNodeHostCommand(command, frame.paramsJSON);
if (pluginNodeHostResult !== null) {
await sendRawPayloadResult(client, frame, pluginNodeHostResult);
const pluginResult = await invokePlugin(command, frame.paramsJSON, runtime.pluginCommandIo);
if (pluginResult !== null) {
await sendRawPayloadResult(client, frame, pluginResult);
return;
}
} catch (err) {
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import type { NodeHostClient } from "./client.js";
import {
createNodeInvokeProgressWriter,
resolveNodeInvokeHeartbeatInterval,
} from "./node-invoke-progress.js";
const frame = {
id: "invoke-1",
nodeId: "node-1",
command: "test.duplex",
paramsJSON: null,
timeoutMs: 0,
idempotencyKey: null,
};
describe("node invoke progress writer", () => {
it("chunks output to 16 KiB and pauses its producer for backpressure", async () => {
const request = vi.fn(async () => ({}));
const client = { request } as NodeHostClient;
const pausable = { pause: vi.fn(), resume: vi.fn() };
const writer = createNodeInvokeProgressWriter({
client,
frame,
idleTimeoutMs: 30_000,
onError: vi.fn(),
});
await writer.write("é".repeat(10_000), pausable);
expect(request).toHaveBeenCalledTimes(2);
expect(pausable.pause).toHaveBeenCalledOnce();
expect(pausable.resume).toHaveBeenCalledOnce();
for (const [, params] of request.mock.calls as unknown as Array<[string, { chunk: string }]>) {
expect(Buffer.byteLength(params.chunk, "utf8")).toBeLessThanOrEqual(16 * 1024);
}
});
it("emits idle heartbeats at the clamped half-timeout interval", async () => {
vi.useFakeTimers();
try {
const request = vi.fn(async () => ({}));
const writer = createNodeInvokeProgressWriter({
client: { request } as NodeHostClient,
frame,
idleTimeoutMs: 2_000,
onError: vi.fn(),
});
writer.startHeartbeats();
await vi.advanceTimersByTimeAsync(1_000);
expect(request).toHaveBeenCalledWith("node.invoke.progress", {
invokeId: "invoke-1",
nodeId: "node-1",
seq: 0,
chunk: "",
});
writer.stop();
await writer.flush();
} finally {
vi.useRealTimers();
}
expect(resolveNodeInvokeHeartbeatInterval(100)).toBe(250);
expect(resolveNodeInvokeHeartbeatInterval(60_000)).toBe(5_000);
});
});
+132
View File
@@ -0,0 +1,132 @@
import { truncateUtf8Prefix } from "../utils/utf8-truncate.js";
import type { NodeHostClient } from "./client.js";
import type { NodeInvokeRequestPayload } from "./invoke-types.js";
const PROGRESS_CHUNK_BYTES = 16 * 1024;
const MIN_HEARTBEAT_INTERVAL_MS = 250;
const MAX_HEARTBEAT_INTERVAL_MS = 5_000;
type Pausable = { pause(): void; resume(): void };
export function resolveNodeInvokeHeartbeatInterval(idleTimeoutMs: number): number {
return Math.max(
MIN_HEARTBEAT_INTERVAL_MS,
Math.min(MAX_HEARTBEAT_INTERVAL_MS, Math.floor(idleTimeoutMs / 2)),
);
}
export function createNodeInvokeProgressWriter(params: {
client: NodeHostClient;
frame: NodeInvokeRequestPayload;
idleTimeoutMs: number;
onError: (error: Error) => void;
}) {
let seq = 0;
let queue = Promise.resolve();
let progressError: Error | undefined;
let heartbeatQueued = false;
let heartbeatDirty = false;
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
let recurringHeartbeats = false;
let stopped = false;
let lastProgressAt = 0;
const heartbeatIntervalMs = resolveNodeInvokeHeartbeatInterval(params.idleTimeoutMs);
const recordError = (error: unknown) => {
progressError = error instanceof Error ? error : new Error(String(error));
params.onError(progressError);
};
const enqueue = (task: () => Promise<void>, pausable?: Pausable): Promise<void> => {
pausable?.pause();
queue = queue
.then(task)
.catch(recordError)
.finally(() => pausable?.resume());
return queue;
};
const sendText = async (text: string) => {
let remaining = text;
while (remaining) {
const chunk = truncateUtf8Prefix(remaining, PROGRESS_CHUNK_BYTES);
if (!chunk) {
break;
}
await params.client.request("node.invoke.progress", {
invokeId: params.frame.id,
nodeId: params.frame.nodeId,
seq,
chunk,
});
seq += 1;
remaining = remaining.slice(chunk.length);
}
};
const queueHeartbeat = () => {
if (stopped) {
return;
}
if (heartbeatQueued) {
heartbeatDirty = true;
return;
}
heartbeatQueued = true;
const delayMs = Math.max(0, heartbeatIntervalMs - (Date.now() - lastProgressAt));
heartbeatTimer = setTimeout(() => {
heartbeatTimer = undefined;
void enqueue(async () => {
await params.client.request("node.invoke.progress", {
invokeId: params.frame.id,
nodeId: params.frame.nodeId,
seq,
chunk: "",
});
seq += 1;
lastProgressAt = Date.now();
}).finally(() => {
heartbeatQueued = false;
if ((heartbeatDirty || recurringHeartbeats) && !stopped) {
heartbeatDirty = false;
queueHeartbeat();
}
});
}, delayMs);
};
return {
write(text: string, pausable?: Pausable): Promise<void> {
if (!text || stopped) {
return queue;
}
lastProgressAt = Date.now();
return enqueue(() => sendText(text), pausable);
},
queueHeartbeat,
startHeartbeats(): void {
recurringHeartbeats = true;
queueHeartbeat();
},
stopHeartbeats(): void {
recurringHeartbeats = false;
heartbeatDirty = false;
clearTimeout(heartbeatTimer);
heartbeatTimer = undefined;
heartbeatQueued = false;
},
async flush(): Promise<void> {
await queue.catch(() => {});
},
stop(): void {
stopped = true;
recurringHeartbeats = false;
heartbeatDirty = false;
clearTimeout(heartbeatTimer);
heartbeatTimer = undefined;
},
get error(): Error | undefined {
return progressError;
},
};
}
+35
View File
@@ -184,4 +184,39 @@ describe("plugin node-host registry", () => {
await expect(invokeRegisteredNodeHostCommand("missing.command", null)).resolves.toBeNull();
expect(handle).toHaveBeenCalledWith('{"ok":true}');
});
it("gates duplex commands from embedded-worker manifests and supplies their IO context", async () => {
const handle = vi.fn(async (paramsJSON?: string | null) => paramsJSON ?? "");
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands = [
{
pluginId: "terminal",
pluginName: "Terminal",
command: {
command: "terminal.resume.v1",
cap: "terminal",
duplex: true,
handle,
},
source: "test",
},
];
setActivePluginRegistry(registry);
expect(
listRegisteredNodeHostCapsAndCommands(availabilityContext, { includeDuplex: false }),
).toEqual({ caps: [], commands: [], nodePluginTools: [] });
const io = {
signal: new AbortController().signal,
emitChunk: async () => {},
onInput: () => {},
};
await expect(
invokeRegisteredNodeHostCommand("terminal.resume.v1", '{"threadId":"id"}', io),
).resolves.toBe('{"threadId":"id"}');
expect(handle).toHaveBeenCalledWith('{"threadId":"id"}', io);
await expect(invokeRegisteredNodeHostCommand("terminal.resume.v1", null)).rejects.toThrow(
"requires duplex transport",
);
});
});
+23 -1
View File
@@ -3,7 +3,10 @@ import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/s
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginNodeHostCommandRegistration } from "../plugins/registry-types.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import type { OpenClawPluginNodeHostCommandAvailabilityContext } from "../plugins/types.js";
import type {
OpenClawPluginNodeHostCommandAvailabilityContext,
OpenClawPluginNodeHostCommandIo,
} from "../plugins/types.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
/**
@@ -33,6 +36,7 @@ export async function ensureNodeHostPluginRegistry(params: {
/** List registered node-host capabilities and command ids in deterministic order. */
export function listRegisteredNodeHostCapsAndCommands(
context: OpenClawPluginNodeHostCommandAvailabilityContext,
options: { includeDuplex?: boolean } = {},
): {
caps: string[];
commands: string[];
@@ -43,6 +47,9 @@ export function listRegisteredNodeHostCapsAndCommands(
const commands = new Set<string>();
const nodePluginTools = new Map<string, NodePluginToolDescriptor>();
for (const entry of registry?.nodeHostCommands ?? []) {
if (entry.command.duplex === true && options.includeDuplex === false) {
continue;
}
// Availability belongs to the node-local plugin. Gateway policy still keeps
// the command registered so a differently configured remote node can expose it.
if (entry.command.isAvailable?.(context) === false) {
@@ -113,6 +120,7 @@ function buildNodePluginToolDescriptor(
export async function invokeRegisteredNodeHostCommand(
command: string,
paramsJSON?: string | null,
io?: OpenClawPluginNodeHostCommandIo,
): Promise<string | null> {
const registry = getActivePluginRegistry();
const match = (registry?.nodeHostCommands ?? []).find(
@@ -121,5 +129,19 @@ export async function invokeRegisteredNodeHostCommand(
if (!match) {
return null;
}
if (match.command.duplex === true) {
if (!io) {
throw new Error(`node command requires duplex transport: ${command}`);
}
return await match.command.handle(paramsJSON, io);
}
return await match.command.handle(paramsJSON);
}
export function isRegisteredNodeHostCommandDuplex(command: string): boolean {
const registry = getActivePluginRegistry();
return (
(registry?.nodeHostCommands ?? []).find((entry) => entry.command.command === command)?.command
.duplex === true
);
}
+82
View File
@@ -0,0 +1,82 @@
import os from "node:os";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
import type { TerminalPtyHandle } from "../process/terminal-pty.js";
import { decodeNodePtyResumeParams, runNodePtyCommand } from "./pty-command.js";
describe("node PTY command", () => {
it("validates closed resume params", () => {
const validate = (value: unknown) => {
if (typeof value !== "string" || !value) {
throw new Error("bad thread");
}
return value;
};
expect(decodeNodePtyResumeParams('{"threadId":"id","cols":80,"rows":24}', validate)).toEqual({
threadId: "id",
cols: 80,
rows: 24,
});
expect(() =>
decodeNodePtyResumeParams('{"threadId":"id","cols":80,"rows":24,"argv":["sh"]}', validate),
).toThrow("unknown terminal resume parameter: argv");
});
it("relays output, data, resize, abort, and exit", async () => {
let onData: ((chunk: string) => void) | undefined;
let onExit: ((event: { exitCode: number; signal?: number }) => void) | undefined;
let onInput: ((payloadJSON: string) => void) | undefined;
const pty = {
pid: 42,
write: vi.fn(),
resize: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
kill: vi.fn(),
onData: (callback: (chunk: string) => void) => {
onData = callback;
},
onExit: (callback: (event: { exitCode: number; signal?: number }) => void) => {
onExit = callback;
},
} satisfies TerminalPtyHandle;
const abort = new AbortController();
const emitChunk = vi.fn(async () => {});
const io: OpenClawPluginNodeHostCommandIo = {
signal: abort.signal,
emitChunk,
onInput: (callback) => {
onInput = callback;
},
};
const spawn = vi.fn(async () => pty);
const result = runNodePtyCommand(
{
file: "/usr/bin/codex",
args: ["resume", "id"],
cwd: "/missing/catalog/cwd",
cols: 80,
rows: 24,
},
io,
spawn,
);
await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce());
const spawnCalls = spawn.mock.calls as unknown as Array<[{ cwd?: string }]>;
expect(spawnCalls[0]?.[0].cwd).toBe(os.homedir());
onData?.("output");
await vi.waitFor(() => expect(emitChunk).toHaveBeenCalledWith("output"));
expect(pty.pause).toHaveBeenCalledOnce();
expect(pty.resume).toHaveBeenCalledOnce();
onInput?.(JSON.stringify({ kind: "data", data: "keys" }));
onInput?.(JSON.stringify({ kind: "resize", cols: 100, rows: 30 }));
expect(pty.write).toHaveBeenCalledWith("keys");
expect(pty.resize).toHaveBeenCalledWith(100, 30);
abort.abort();
expect(pty.kill).toHaveBeenCalledOnce();
onExit?.({ exitCode: 130, signal: 15 });
await expect(result).resolves.toEqual({ exitCode: 130, signal: 15 });
});
});
+163
View File
@@ -0,0 +1,163 @@
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 } : {}),
}),
);
});
});
}
+43 -14
View File
@@ -20,6 +20,8 @@ const mocks = vi.hoisted(() => ({
mcpConfiguredServerCount: 0,
mcpDescriptors: [] as Array<Record<string, unknown>>,
nodeSkillDescriptors: [] as Array<Record<string, unknown>>,
runtimeSteps: [] as string[],
normalizedPath: null as string | null,
resolvedExecutables: new Map<string, string>(),
closeMcpManager: vi.fn(async () => undefined),
ensureNodeHostConfig: vi.fn(async () => ({
@@ -85,7 +87,12 @@ vi.mock("../infra/executable-path.js", () => ({
}));
vi.mock("../infra/path-env.js", () => ({
ensureOpenClawCliOnPath: vi.fn(),
ensureOpenClawCliOnPath: vi.fn(() => {
mocks.runtimeSteps.push("path");
if (mocks.normalizedPath) {
process.env.PATH = mocks.normalizedPath;
}
}),
}));
vi.mock("./config.js", () => ({
@@ -95,19 +102,22 @@ vi.mock("./config.js", () => ({
vi.mock("./plugin-node-host.js", () => ({
ensureNodeHostPluginRegistry: vi.fn(async () => undefined),
listRegisteredNodeHostCapsAndCommands: vi.fn(() => ({
caps: [],
commands: [],
nodePluginTools: [
{
pluginId: "test-plugin",
name: "remote_echo",
description: "Echo from node host",
command: "test.echo",
parameters: { type: "object", properties: {} },
},
],
})),
listRegisteredNodeHostCapsAndCommands: vi.fn((context: { env: NodeJS.ProcessEnv }) => {
mocks.runtimeSteps.push(`commands:${context.env.PATH ?? ""}`);
return {
caps: [],
commands: [],
nodePluginTools: [
{
pluginId: "test-plugin",
name: "remote_echo",
description: "Echo from node host",
command: "test.echo",
parameters: { type: "object", properties: {} },
},
],
};
}),
}));
vi.mock("./mcp.js", () => ({
@@ -136,6 +146,8 @@ describe("runNodeHost", () => {
mocks.mcpConfiguredServerCount = 0;
mocks.mcpDescriptors = [];
mocks.nodeSkillDescriptors = [];
mocks.runtimeSteps = [];
mocks.normalizedPath = null;
mocks.resolvedExecutables.clear();
vi.clearAllMocks();
mocks.getRuntimeConfig.mockReturnValue({
@@ -173,6 +185,23 @@ describe("runNodeHost", () => {
expect(mocks.capturedGatewayClients[0]?.request).not.toHaveBeenCalled();
});
it("bootstraps PATH before probing plugin command availability", async () => {
const originalPath = process.env.PATH;
mocks.normalizedPath = "/normalized/node/path";
try {
await expect(
runNodeHost({
gatewayHost: "127.0.0.1",
gatewayPort: 18789,
}),
).rejects.toThrow("event loop readiness timeout");
} finally {
process.env.PATH = originalPath;
}
expect(mocks.runtimeSteps).toEqual(["path", "commands:/normalized/node/path"]);
});
it("keeps a ref'd lifetime handle until a ready foreground host stops", async () => {
mocks.startGatewayClientWhenEventLoopReady.mockResolvedValueOnce({
ready: true,
+12 -1
View File
@@ -16,7 +16,11 @@ import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { VERSION } from "../version.js";
import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js";
import { coerceNodeInvokeCancelPayload, coerceNodeInvokePayload } from "./invoke-payload.js";
import {
coerceNodeInvokeCancelPayload,
coerceNodeInvokeInputPayload,
coerceNodeInvokePayload,
} from "./invoke-payload.js";
import { buildNodeInvokeResultParams } from "./invoke.js";
import { prepareNodeHostRuntime, type NodeHostInventory } from "./runtime.js";
@@ -260,6 +264,13 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
}
return;
}
if (evt.event === "node.invoke.input") {
const payload = coerceNodeInvokeInputPayload(evt.payload);
if (payload) {
activeRuntime.handleInput(payload.invokeId, payload.seq, payload.payloadJSON);
}
return;
}
if (evt.event !== "node.invoke.request") {
return;
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import { dispatchNodeInvokeInput, registerNodeInvokeInputHandler } from "./runtime.js";
function createTarget() {
return {
nextInputSeq: 0,
pendingInput: [] as Array<{ payloadJSON: string; bytes: number }>,
pendingInputBytes: 0,
inputFailed: false,
abort: vi.fn(),
};
}
describe("node-host invoke input dispatch", () => {
it("buffers frames before registration and flushes them in order", () => {
const input = vi.fn();
const target = createTarget();
expect(dispatchNodeInvokeInput(target, 0, "first")).toBe(true);
expect(dispatchNodeInvokeInput(target, 1, "second")).toBe(true);
expect(input).not.toHaveBeenCalled();
registerNodeInvokeInputHandler(target, input);
expect(input.mock.calls).toEqual([["first"], ["second"]]);
});
it("drops duplicate sequence numbers", () => {
const input = vi.fn();
const target = createTarget();
registerNodeInvokeInputHandler(target, input);
expect(dispatchNodeInvokeInput(undefined, 0, "unknown")).toBe(false);
expect(dispatchNodeInvokeInput(target, 0, "first")).toBe(true);
expect(dispatchNodeInvokeInput(target, 0, "duplicate")).toBe(false);
expect(dispatchNodeInvokeInput(target, 1, "second")).toBe(true);
expect(input.mock.calls).toEqual([["first"], ["second"]]);
});
it("aborts without delivering partial input when the pre-spawn buffer overflows", () => {
const input = vi.fn();
const target = createTarget();
const chunk = "x".repeat(16 * 1024 - 1);
for (let seq = 0; seq < 4; seq += 1) {
expect(dispatchNodeInvokeInput(target, seq, `${seq}${chunk}`)).toBe(true);
}
expect(dispatchNodeInvokeInput(target, 4, `4${chunk}`)).toBe(false);
registerNodeInvokeInputHandler(target, input);
expect(input).not.toHaveBeenCalled();
expect(target.pendingInput).toEqual([]);
expect(target.abort).toHaveBeenCalledOnce();
expect(dispatchNodeInvokeInput(target, 5, "continued")).toBe(false);
});
it("tolerates sequence gaps", () => {
const input = vi.fn();
const target = createTarget();
registerNodeInvokeInputHandler(target, input);
expect(dispatchNodeInvokeInput(target, 2, "gap")).toBe(true);
expect(dispatchNodeInvokeInput(target, 3, "next")).toBe(true);
expect(input.mock.calls).toEqual([["gap"], ["next"]]);
});
});
+124 -11
View File
@@ -6,17 +6,22 @@ import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
import { resolveExecutableFromPathEnv } from "../infra/executable-path.js";
import {
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
NODE_DUPLEX_INVOKE_IDLE_TIMEOUT_MS,
NODE_EXEC_APPROVALS_COMMANDS,
NODE_FS_LIST_DIR_COMMAND,
NODE_MCP_TOOLS_CALL_COMMAND,
NODE_SYSTEM_RUN_COMMANDS,
} from "../infra/node-commands.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { logDebug } from "../logger.js";
import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
import type { NodeHostClient } from "./client.js";
import { handleInvoke, type NodeInvokeRequestPayload, type SkillBinsProvider } from "./invoke.js";
import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js";
import { createNodeInvokeProgressWriter } from "./node-invoke-progress.js";
import {
ensureNodeHostPluginRegistry,
isRegisteredNodeHostCommandDuplex,
listRegisteredNodeHostCapsAndCommands,
} from "./plugin-node-host.js";
import { scanNodeHostedSkills } from "./skills.js";
@@ -45,11 +50,69 @@ type PreparedNodeHostRuntime = {
type ActiveNodeHostRuntime = {
invoke(frame: NodeInvokeRequestPayload): Promise<void>;
handleInput(invokeId: string, seq: number, payloadJSON: string): void;
cancel(invokeId: string): void;
cancelAll(): void;
close(): Promise<void>;
};
type NodeInvokeInputTarget = {
nextInputSeq: number;
input?: (payloadJSON: string) => void;
// PTY handlers attach only after async spawn, while Gateway input may arrive immediately.
// Keep that spawn-window input so the sequence cannot wedge before registration.
pendingInput: Array<{ payloadJSON: string; bytes: number }>;
pendingInputBytes: number;
inputFailed: boolean;
abort: (reason: Error) => void;
};
const MAX_PENDING_INVOKE_INPUT_BYTES = 64 * 1024;
export function dispatchNodeInvokeInput(
target: NodeInvokeInputTarget | undefined,
seq: number,
payloadJSON: string,
): boolean {
if (!target || target.inputFailed || seq < target.nextInputSeq) {
return false;
}
if (seq > target.nextInputSeq) {
logDebug(`node-host: input sequence gap: expected ${target.nextInputSeq}, received ${seq}`);
}
target.nextInputSeq = seq + 1;
if (target.input) {
target.input(payloadJSON);
return true;
}
const bytes = Buffer.byteLength(payloadJSON, "utf8");
if (target.pendingInputBytes + bytes > MAX_PENDING_INVOKE_INPUT_BYTES) {
target.pendingInput.length = 0;
target.pendingInputBytes = 0;
target.inputFailed = true;
target.abort(new Error("terminal input exceeded the 64 KiB pre-spawn buffer"));
logDebug("node-host: aborted invoke after buffered input exceeded 64 KiB");
return false;
}
target.pendingInput.push({ payloadJSON, bytes });
target.pendingInputBytes += bytes;
return true;
}
export function registerNodeInvokeInputHandler(
target: NodeInvokeInputTarget,
input: (payloadJSON: string) => void,
): void {
if (target.inputFailed) {
return;
}
target.input = input;
for (const pending of target.pendingInput.splice(0)) {
input(pending.payloadJSON);
}
target.pendingInputBytes = 0;
}
function resolveExecutablePathFromEnv(bin: string, pathEnv: string): string | null {
if (bin.includes("/") || bin.includes("\\")) {
return null;
@@ -162,8 +225,13 @@ export async function prepareNodeHostRuntime(params?: {
const config = params?.config ?? getRuntimeConfig();
const env = params?.env ?? process.env;
await ensureNodeHostPluginRegistry({ config, env });
const pluginNodeHost = listRegisteredNodeHostCapsAndCommands({ config, env });
const pathEnv = ensureNodePathEnv();
env.PATH = pathEnv;
const duplexEnabled = params?.enableAgentRuns === true;
const pluginNodeHost = listRegisteredNodeHostCapsAndCommands(
{ config, env },
{ includeDuplex: duplexEnabled },
);
// Opt-in and binary resolution are node-local enforcement points. A Gateway
// cannot advertise or enable this command on the host's behalf.
const claudePath =
@@ -196,7 +264,10 @@ export async function prepareNodeHostRuntime(params?: {
start({ client, onInventoryChanged }) {
const mcpAbort = new AbortController();
const skillBins = new SkillBinsCache(client, pathEnv);
const activeClaudeRuns = new Map<string, AbortController>();
const activeInvokes = new Map<
string,
NodeInvokeInputTarget & { controller: AbortController }
>();
let manager: NodeHostMcpManager | undefined;
const startup = startNodeHostMcpManager(config.nodeHost?.mcp?.servers, {
signal: mcpAbort.signal,
@@ -213,32 +284,74 @@ export async function prepareNodeHostRuntime(params?: {
});
return {
async invoke(frame) {
const duplexCommand = duplexEnabled && isRegisteredNodeHostCommandDuplex(frame.command);
const controller =
claudePath && frame.command === NODE_AGENT_CLI_CLAUDE_RUN_COMMAND
(claudePath && frame.command === NODE_AGENT_CLI_CLAUDE_RUN_COMMAND) || duplexCommand
? new AbortController()
: undefined;
if (controller) {
activeClaudeRuns.set(frame.id, controller);
const active: (NodeInvokeInputTarget & { controller: AbortController }) | undefined =
controller
? {
controller,
nextInputSeq: 0,
pendingInput: [],
pendingInputBytes: 0,
inputFailed: false,
abort: (reason) => controller.abort(reason),
}
: undefined;
if (active) {
activeInvokes.set(frame.id, active);
}
const progress = duplexCommand
? createNodeInvokeProgressWriter({
client,
frame,
idleTimeoutMs: NODE_DUPLEX_INVOKE_IDLE_TIMEOUT_MS,
onError: () => controller?.abort(),
})
: undefined;
progress?.startHeartbeats();
const pluginCommandIo: OpenClawPluginNodeHostCommandIo | undefined =
controller && active && progress
? {
signal: controller.signal,
emitChunk: async (chunk) => await progress.write(chunk),
onInput: (callback) => {
if (activeInvokes.get(frame.id) === active) {
registerNodeInvokeInputHandler(active, callback);
}
},
}
: undefined;
try {
await handleInvoke(frame, client, skillBins, manager, {
...(claudePath ? { claudePath } : {}),
...(controller ? { signal: controller.signal } : {}),
...(pluginCommandIo ? { pluginCommandIo } : {}),
});
} finally {
if (controller && activeClaudeRuns.get(frame.id) === controller) {
activeClaudeRuns.delete(frame.id);
progress?.stop();
await progress?.flush();
if (active && activeInvokes.get(frame.id) === active) {
activeInvokes.delete(frame.id);
}
}
},
handleInput(invokeId, seq, payloadJSON) {
const active = activeInvokes.get(invokeId);
if (!dispatchNodeInvokeInput(active, seq, payloadJSON)) {
logDebug(`node-host: dropped inactive or duplicate input for invoke ${invokeId}`);
}
},
cancel(invokeId) {
activeClaudeRuns.get(invokeId)?.abort();
activeInvokes.get(invokeId)?.controller.abort();
},
cancelAll() {
for (const controller of activeClaudeRuns.values()) {
controller.abort();
for (const active of activeInvokes.values()) {
active.controller.abort();
}
activeClaudeRuns.clear();
activeInvokes.clear();
},
async close() {
this.cancelAll();
+9
View File
@@ -0,0 +1,9 @@
export {
decodeNodePtyResumeParams,
runNodePtyCommand,
type NodePtyCommandResult,
type NodePtyResumeParams,
} from "../node-host/pty-command.js";
export { validateClaudeSessionId } from "../node-host/invoke-agent-cli-claude-params.js";
export { resolveExecutableFromPathEnv } from "../infra/executable-path.js";
export type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
+1
View File
@@ -4,6 +4,7 @@ export type {
SessionCatalogListProviderParams,
SessionCatalogProvider,
SessionCatalogReadProviderParams,
SessionCatalogTerminalPlan,
} from "../plugins/session-catalog.js";
export type {
SessionCatalog,
+15
View File
@@ -22,6 +22,17 @@ export type SessionCatalogContinueProviderParams = Omit<
};
export type SessionCatalogArchiveProviderParams = Omit<SessionsCatalogArchiveParams, "catalogId">;
export type SessionCatalogTerminalPlan =
| { kind: "local"; argv: string[]; cwd?: string; title?: string }
| {
kind: "node";
nodeId: string;
command: string;
paramsJSON: string;
cwd?: string;
title?: string;
};
export type SessionCatalogCreateTarget = {
model: string;
/** Concrete runtime pinned onto the created session so config reloads cannot retarget it. */
@@ -58,4 +69,8 @@ export type SessionCatalogProvider = {
params: SessionCatalogContinueProviderParams,
) => Promise<SessionCatalogContinueProviderResult>;
archive?: (params: SessionCatalogArchiveProviderParams) => Promise<{ ok: true }>;
openTerminal?: (request: {
hostId: string;
threadId: string;
}) => Promise<SessionCatalogTerminalPlan>;
};
+39
View File
@@ -0,0 +1,39 @@
// Node-host plugin command contracts, including the opt-in duplex transport.
import type { OpenClawConfig } from "../config/types.openclaw.js";
export type OpenClawPluginNodeHostCommandAvailabilityContext = {
/** Node-local configuration used to build this host's Gateway declaration. */
config: OpenClawConfig;
/** Node-host process environment. */
env: NodeJS.ProcessEnv;
};
export type OpenClawPluginNodeHostCommandIo = {
emitChunk(chunk: string): Promise<void>;
onInput(callback: (payloadJSON: string) => void): void;
signal: AbortSignal;
};
type OpenClawPluginNodeHostCommandBase = {
command: string;
cap?: string;
dangerous?: boolean;
/** Return false to omit this command and capability from the node declaration. */
isAvailable?: (context: OpenClawPluginNodeHostCommandAvailabilityContext) => boolean;
agentTool?: {
name: string;
description: string;
parameters?: Record<string, unknown>;
/** Platforms where this tool is allowlisted by default; omit for explicit config only. */
defaultPlatforms?: Array<"ios" | "android" | "macos" | "windows" | "linux" | "unknown">;
mcp?: { server: string; tool: string };
};
};
export type OpenClawPluginNodeHostCommand = OpenClawPluginNodeHostCommandBase & {
// Not a discriminated handle signature: a union of different arities makes
// plain `command.handle(params)` uncallable for consumers holding the union.
// The node host enforces io presence for duplex commands at runtime.
duplex?: boolean;
handle: (paramsJSON?: string | null, io?: OpenClawPluginNodeHostCommandIo) => Promise<string>;
};
+6 -29
View File
@@ -152,6 +152,7 @@ import type {
OpenClawPluginToolFactory,
OpenClawPluginToolOptions,
} from "./tool-types.js";
import type { OpenClawPluginNodeHostCommand } from "./types.node-host.js";
import type { WebFetchProviderPlugin, WebSearchProviderPlugin } from "./web-provider-types.js";
type ModelProviderRequestTransportOverrides =
@@ -2305,35 +2306,11 @@ export type OpenClawPluginReloadRegistration = {
noopPrefixes?: string[];
};
export type OpenClawPluginNodeHostCommandAvailabilityContext = {
/** Node-local configuration used to build this host's Gateway declaration. */
config: OpenClawConfig;
/** Node-host process environment. */
env: NodeJS.ProcessEnv;
};
export type OpenClawPluginNodeHostCommand = {
command: string;
cap?: string;
dangerous?: boolean;
/** Return false to omit this command and capability from the node declaration. */
isAvailable?: (context: OpenClawPluginNodeHostCommandAvailabilityContext) => boolean;
agentTool?: {
name: string;
description: string;
parameters?: Record<string, unknown>;
/**
* Platforms where this node-hosted agent tool should be allowlisted by
* default. Omit to require explicit `gateway.nodes.allowCommands`.
*/
defaultPlatforms?: Array<"ios" | "android" | "macos" | "windows" | "linux" | "unknown">;
mcp?: {
server: string;
tool: string;
};
};
handle: (paramsJSON?: string | null) => Promise<string>;
};
export type {
OpenClawPluginNodeHostCommand,
OpenClawPluginNodeHostCommandAvailabilityContext,
OpenClawPluginNodeHostCommandIo,
} from "./types.node-host.js";
export type OpenClawPluginNodeInvokeTransportResult =
| {
@@ -5,16 +5,18 @@ const mocks = vi.hoisted(() => ({
spawn: vi.fn(),
}));
vi.mock("../../process/kill-tree.js", () => ({ signalProcessTree: mocks.signalProcessTree }));
vi.mock("./kill-tree.js", () => ({ signalProcessTree: mocks.signalProcessTree }));
vi.mock("@lydell/node-pty", () => ({ spawn: mocks.spawn }));
const { spawnTerminalPty } = await import("./pty.js");
const { resolveTerminalPtyInvocation, spawnTerminalPty } = await import("./terminal-pty.js");
function fakePty(pid = 4321) {
return {
pid,
write: vi.fn(),
resize: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
onData: vi.fn(),
onExit: vi.fn(),
kill: vi.fn(),
@@ -59,3 +61,37 @@ describe("terminal PTY teardown", () => {
expect(() => handle.kill()).not.toThrow();
});
});
describe("terminal PTY invocation", () => {
it.each([".cmd", ".bat"])("wraps Windows %s shims through ComSpec", (extension) => {
expect(
resolveTerminalPtyInvocation({
file: `C:\\Program Files\\Codex\\codex${extension}`,
args: ["resume", "thread title"],
platform: "win32",
comSpec: "C:\\Windows\\System32\\cmd.exe",
}),
).toEqual({
file: "C:\\Windows\\System32\\cmd.exe",
args: [
"/d",
"/s",
"/c",
`""C:\\Program Files\\Codex\\codex${extension}" resume "thread title""`,
],
});
});
it("keeps executables and non-Windows commands direct", () => {
expect(
resolveTerminalPtyInvocation({
file: "C:\\tools\\codex.exe",
args: ["resume", "thread"],
platform: "win32",
}),
).toEqual({ file: "C:\\tools\\codex.exe", args: ["resume", "thread"] });
expect(
resolveTerminalPtyInvocation({ file: "/tmp/codex.cmd", args: [], platform: "linux" }),
).toEqual({ file: "/tmp/codex.cmd", args: [] });
});
});
+92
View File
@@ -0,0 +1,92 @@
import type { IPty } from "@lydell/node-pty";
import { signalProcessTree } from "./kill-tree.js";
import {
buildWindowsCmdExeCommandLine,
isWindowsBatchCommand,
resolveTrustedWindowsCmdExe,
} from "./windows-command.js";
/** Live PTY handle shared by gateway terminals and node-host commands. */
export type TerminalPtyHandle = {
pid: number;
write(data: string): void;
resize(cols: number, rows: number): void;
pause(): void;
resume(): void;
onData(listener: (chunk: string) => void): void;
onExit(listener: (event: { exitCode: number; signal?: number }) => void): void;
kill(signal?: string): void;
};
export function resolveTerminalPtyInvocation(params: {
file: string;
args: string[];
platform?: NodeJS.Platform;
comSpec?: string;
}): { file: string; args: string[] } {
const platform = params.platform ?? process.platform;
if (!isWindowsBatchCommand(params.file, platform)) {
return { file: params.file, args: params.args };
}
return {
file: params.comSpec?.trim() || resolveTrustedWindowsCmdExe(platform),
args: ["/d", "/s", "/c", buildWindowsCmdExeCommandLine(params.file, params.args)],
};
}
export async function spawnTerminalPty(params: {
file: string;
args: string[];
cwd?: string;
env: Record<string, string>;
cols: number;
rows: number;
}): Promise<TerminalPtyHandle> {
const { spawn } = await import("@lydell/node-pty");
const comSpec = params.env.ComSpec ?? params.env.COMSPEC;
const invocation = resolveTerminalPtyInvocation({
file: params.file,
args: params.args,
...(comSpec ? { comSpec } : {}),
});
const pty = spawn(invocation.file, invocation.args, {
name: params.env.TERM ?? "xterm-256color",
cols: params.cols,
rows: params.rows,
cwd: params.cwd,
env: params.env,
});
return {
get pid() {
return pty.pid;
},
write: (data) => pty.write(data),
resize: (cols, rows) => pty.resize(cols, rows),
pause: () => pty.pause(),
resume: () => pty.resume(),
onData: (listener) => {
pty.onData(listener);
},
onExit: (listener) => {
pty.onExit(listener);
},
kill: (signal) => killPtyTree(pty, signal),
} satisfies TerminalPtyHandle;
}
// A long-running child of the interactive shell must not survive terminal
// close. Signal the process tree, matching the process supervisor contract.
function killPtyTree(pty: Pick<IPty, "pid" | "kill">, signal?: string): void {
const sig = (signal ?? "SIGKILL") as NodeJS.Signals;
try {
if ((sig === "SIGKILL" || sig === "SIGTERM") && typeof pty.pid === "number" && pty.pid > 0) {
signalProcessTree(pty.pid, sig);
} else if (process.platform === "win32") {
pty.kill();
} else {
pty.kill(sig);
}
} catch {
// Process may already be gone; teardown is best-effort.
}
}
+9 -14
View File
@@ -37,6 +37,7 @@ import { copyToClipboard } from "../lib/clipboard.ts";
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts";
import { searchForSession } from "../lib/sessions/index.ts";
import { isTerminalAvailable } from "../lib/terminal-availability.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
import { findSettingsSearchBlocks } from "../pages/config/settings-search.ts";
@@ -63,7 +64,12 @@ import { navigationSurfaceIsHidden, renderFloatingUpdateCard } from "./navigatio
import { hasOperatorAdminAccess } from "./operator-access.ts";
import { controlUiPublicAssetPath } from "./public-assets.ts";
import { selectRenderedRouteMatch } from "./router-outlet.ts";
import { NAV_WIDTH_MAX, NAV_WIDTH_MIN, loadSettings } from "./settings.ts";
import {
NAV_WIDTH_MAX,
NAV_WIDTH_MIN,
loadSettings,
normalizeCatalogOpenTarget,
} from "./settings.ts";
type ShellRouteState = {
routeId?: RouteId;
@@ -220,19 +226,6 @@ function renderApprovalDocument(runtime: ApplicationRuntime) {
`;
}
function isTerminalAvailable(
snapshot: ApplicationContext["gateway"]["snapshot"],
terminalEnabled: boolean,
): boolean {
if (!snapshot.connected || !terminalEnabled) {
return false;
}
return (
hasOperatorAdminAccess(snapshot.hello?.auth ?? null) &&
isGatewayMethodAdvertised(snapshot, "terminal.open") === true
);
}
function isBrowserPanelAvailable(snapshot: ApplicationContext["gateway"]["snapshot"]): boolean {
if (!snapshot.connected) {
return false;
@@ -1256,6 +1249,8 @@ class OpenClawShell extends OpenClawLightDomElement {
.enabledRouteIds=${this.enabledRouteIds()}
.sessionKey=${this.activeSessionKey}
.connected=${gatewaySnapshot.connected}
.terminalAvailable=${terminalAvailable}
.catalogOpenTarget=${normalizeCatalogOpenTarget(uiSettings.catalogOpenTarget)}
.canPairDevice=${gatewaySnapshot.connected &&
hasOperatorAdminAccess(gatewaySnapshot.hello?.auth ?? null)}
.sidebarPinnedRoutes=${navigationSnapshot.sidebarPinnedRoutes}
+19
View File
@@ -0,0 +1,19 @@
// Persisted settings normalizers shared by the settings storage owner.
/** Unknown shapes fall back to []; stale and duplicate ids are dropped. */
export function normalizePinnedAgentIds(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const pinned: string[] = [];
for (const entry of value) {
if (typeof entry !== "string") {
continue;
}
const agentId = entry.trim();
if (agentId && !pinned.includes(agentId)) {
pinned.push(agentId);
}
}
return pinned;
}
+25
View File
@@ -508,6 +508,31 @@ describe("loadSettings default gateway URL derivation", () => {
expect(loadSettings().chatSendShortcut).toBe("enter");
});
it("persists only the non-default catalog open target", () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
});
const gwUrl = expectedGatewayUrl("");
const scopedKey = `openclaw.control.settings.v1:${gwUrl}`;
expect(loadSettings().catalogOpenTarget).toBe("viewer");
saveSettings({ ...loadSettings(), catalogOpenTarget: "terminal" });
expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}").catalogOpenTarget).toBe("terminal");
expect(loadSettings().catalogOpenTarget).toBe("terminal");
saveSettings({ ...loadSettings(), catalogOpenTarget: "viewer" });
expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}")).not.toHaveProperty(
"catalogOpenTarget",
);
localStorage.setItem(
scopedKey,
JSON.stringify({ gatewayUrl: gwUrl, catalogOpenTarget: "shell" }),
);
expect(loadSettings().catalogOpenTarget).toBe("viewer");
});
it("persists only a normalized realtime Talk microphone id", () => {
setTestLocation({
protocol: "https:",
+20 -28
View File
@@ -42,6 +42,7 @@ import { normalizeChatSplitLayout, type ChatSplitLayout } from "../pages/chat/sp
import { resolveControlUiBasePath } from "./browser.ts";
import { parseImportedCustomTheme, type ImportedCustomTheme } from "./custom-theme.ts";
import { normalizeGatewayTokenScope } from "./gateway-scope.ts";
import { normalizePinnedAgentIds } from "./settings-normalizers.ts";
import { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts";
import { normalizeLocalUserIdentity, type LocalUserIdentity } from "./user-identity.ts";
@@ -51,39 +52,24 @@ export type TextScaleStop = (typeof TEXT_SCALE_STOPS)[number];
const CHAT_SEND_SHORTCUTS = ["enter", "modifier-enter"] as const;
export type ChatSendShortcut = (typeof CHAT_SEND_SHORTCUTS)[number];
export function normalizeChatSendShortcut(value: unknown): ChatSendShortcut {
return CHAT_SEND_SHORTCUTS.includes(value as ChatSendShortcut)
? (value as ChatSendShortcut)
: "enter";
function normalizeChoice<T extends string>(
values: readonly T[],
fallback: T,
): (value: unknown) => T {
return (value) => (values.includes(value as T) ? (value as T) : fallback);
}
export const normalizeChatSendShortcut = normalizeChoice(CHAT_SEND_SHORTCUTS, "enter");
const CATALOG_OPEN_TARGETS = ["viewer", "terminal"] as const;
export type CatalogOpenTarget = (typeof CATALOG_OPEN_TARGETS)[number];
export const normalizeCatalogOpenTarget = normalizeChoice(CATALOG_OPEN_TARGETS, "viewer");
const CHAT_WORKSPACE_DOCKS = ["right", "bottom"] as const;
export type ChatWorkspaceDock = (typeof CHAT_WORKSPACE_DOCKS)[number];
export function normalizeChatWorkspaceDock(value: unknown): ChatWorkspaceDock {
return CHAT_WORKSPACE_DOCKS.includes(value as ChatWorkspaceDock)
? (value as ChatWorkspaceDock)
: "right";
}
/** Normalize a persisted pinned-agent list; unknown shapes fall back to []
and stale/duplicate ids are dropped without a migration. */
function normalizePinnedAgentIds(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const pinned: string[] = [];
for (const entry of value) {
if (typeof entry !== "string") {
continue;
}
const agentId = entry.trim();
if (agentId && !pinned.includes(agentId)) {
pinned.push(agentId);
}
}
return pinned;
}
export const normalizeChatWorkspaceDock = normalizeChoice(CHAT_WORKSPACE_DOCKS, "right");
export function normalizeTextScale(value: unknown, fallback: TextScaleStop = 100): TextScaleStop {
if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -112,6 +98,7 @@ export type UiSettings = {
chatShowToolCalls: boolean;
chatPersistCommentary?: boolean;
chatSendShortcut?: ChatSendShortcut;
catalogOpenTarget?: CatalogOpenTarget;
realtimeTalkInputDeviceId?: string;
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
chatSplitLayout?: ChatSplitLayout;
@@ -346,6 +333,7 @@ export function loadSettings(): UiSettings {
chatShowToolCalls: true,
chatPersistCommentary: false,
chatSendShortcut: "enter",
catalogOpenTarget: "viewer",
splitRatio: 0.6,
navCollapsed: false,
navWidth: NAV_WIDTH_DEFAULT,
@@ -396,6 +384,7 @@ export function loadSettings(): UiSettings {
? parsed.chatPersistCommentary
: defaults.chatPersistCommentary,
chatSendShortcut: normalizeChatSendShortcut(parsed.chatSendShortcut),
catalogOpenTarget: normalizeCatalogOpenTarget(parsed.catalogOpenTarget),
realtimeTalkInputDeviceId: normalizeOptionalString(parsed.realtimeTalkInputDeviceId),
splitRatio:
typeof parsed.splitRatio === "number" &&
@@ -500,6 +489,9 @@ function persistSettings(next: UiSettings, options: { selectGateway?: boolean }
...(normalizeChatSendShortcut(next.chatSendShortcut) === "modifier-enter"
? { chatSendShortcut: "modifier-enter" as const }
: {}),
...(normalizeCatalogOpenTarget(next.catalogOpenTarget) === "terminal"
? { catalogOpenTarget: "terminal" as const }
: {}),
...(normalizeOptionalString(next.realtimeTalkInputDeviceId)
? { realtimeTalkInputDeviceId: normalizeOptionalString(next.realtimeTalkInputDeviceId) }
: {}),
@@ -0,0 +1,77 @@
// Owns catalog-row menu state, actions, focus anchor, and rendering for AppSidebar.
import { html, nothing } from "lit";
import { openCatalogSessionInTerminal } from "../lib/sessions/catalog-terminal.ts";
import type { CatalogSessionMenuRequest } from "./app-sidebar-session-catalogs.ts";
import "./catalog-session-menu.ts";
import type { CatalogSessionMenuAction } from "./catalog-session-menu.ts";
type SidebarCatalogSessionMenuState = CatalogSessionMenuRequest & { x: number; y: number };
export class SidebarCatalogMenuController {
private state: SidebarCatalogSessionMenuState | null = null;
private trigger: HTMLElement | null = null;
constructor(
private readonly hooks: {
beforeOpen: () => void;
requestUpdate: () => void;
terminalAvailable: () => boolean;
navigate: (search: string) => void;
},
) {}
get isOpen(): boolean {
return this.state !== null;
}
open(
request: CatalogSessionMenuRequest,
x: number,
y: number,
trigger: HTMLElement | null = null,
): void {
this.hooks.beforeOpen();
this.trigger = trigger;
this.state = { ...request, x, y };
this.hooks.requestUpdate();
}
close(): void {
if (!this.state && !this.trigger) {
return;
}
this.trigger = null;
this.state = null;
this.hooks.requestUpdate();
}
private handleAction(
menu: SidebarCatalogSessionMenuState,
action: CatalogSessionMenuAction,
): void {
if (action === "terminal") {
if (menu.canOpenTerminal && this.hooks.terminalAvailable()) {
openCatalogSessionInTerminal(menu.key);
}
return;
}
this.hooks.navigate(menu.search);
}
render() {
const menu = this.state;
if (!menu) {
return nothing;
}
return html`
<openclaw-catalog-session-menu
.x=${menu.x}
.y=${menu.y}
.trigger=${this.trigger}
.terminalDisabled=${!menu.canOpenTerminal || !this.hooks.terminalAvailable()}
.onAction=${(action: CatalogSessionMenuAction) => this.handleAction(menu, action)}
.onClose=${() => this.close()}
></openclaw-catalog-session-menu>
`;
}
}
@@ -10,7 +10,10 @@ import { pathForRoute } from "../app-route-paths.ts";
import type { ApplicationNavigationOptions } from "../app/context.ts";
import { t } from "../i18n/index.ts";
import { formatRelativeTimestamp } from "../lib/format.ts";
import type { CatalogSessionContinuedDetail } from "../lib/sessions/catalog-key.ts";
import type {
CatalogSessionContinuedDetail,
CatalogSessionKey,
} from "../lib/sessions/catalog-key.ts";
import { buildCatalogSessionKey } from "../lib/sessions/catalog-key.ts";
import { searchForSession } from "../lib/sessions/index.ts";
import type { NewSessionTarget } from "../pages/new-session/location.ts";
@@ -48,6 +51,12 @@ export type CatalogBackingSessionDisplay = {
title: string;
};
export type CatalogSessionMenuRequest = {
key: CatalogSessionKey;
search: string;
canOpenTerminal: boolean;
};
/** Stamps a freshly adopted session key onto its catalog row so the sidebar
binds it before the next catalog poll confirms the adoption. */
export function bindAdoptedCatalogSession(
@@ -89,6 +98,15 @@ type SessionCatalogGroupsParams = {
onLoadMore: (catalogId: string) => void;
onOpenNewSession?: (agentId: string, target?: NewSessionTarget) => void;
onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void;
catalogOpenTarget: "viewer" | "terminal";
terminalAvailable: boolean;
onOpenTerminal: (key: CatalogSessionKey) => void;
onOpenMenu: (
request: CatalogSessionMenuRequest,
x: number,
y: number,
trigger?: HTMLElement,
) => void;
};
function renderCatalogHeaderStatus(hasActiveRun: boolean, hasUnread: boolean) {
@@ -235,13 +253,12 @@ function renderCatalogSessionRow(
title: `${label} · ${host.label}`,
});
}
const key =
session.openClawSessionKey ??
buildCatalogSessionKey({
catalogId: catalog.id,
hostId: host.hostId,
threadId: session.threadId,
});
const catalogKey = {
catalogId: catalog.id,
hostId: host.hostId,
threadId: session.threadId,
} satisfies CatalogSessionKey;
const key = session.openClawSessionKey ?? buildCatalogSessionKey(catalogKey);
const search = searchForSession(key);
const href = `${pathForRoute("chat", params.basePath)}${search}`;
// The catalog header already names the source; only a paired node's
@@ -253,12 +270,25 @@ function renderCatalogSessionRow(
typeof rawTimestamp === "number" && rawTimestamp < 1_000_000_000_000
? rawTimestamp * 1000
: rawTimestamp;
const canOpenTerminal = session.canOpenTerminal === true && params.terminalAvailable;
const openTerminal = () => params.onOpenTerminal(catalogKey);
const openMenu = (x: number, y: number, trigger?: HTMLElement) =>
params.onOpenMenu(
{ key: catalogKey, search, canOpenTerminal: session.canOpenTerminal === true },
x,
y,
trigger,
);
return html`
<div
class="sidebar-recent-session session-row-host ${active
? "sidebar-recent-session--active"
: ""}"
data-session-key=${key}
@contextmenu=${(event: MouseEvent) => {
event.preventDefault();
openMenu(event.clientX, event.clientY);
}}
>
<a
href=${href}
@@ -271,7 +301,11 @@ function renderCatalogSessionRow(
return;
}
event.preventDefault();
params.onNavigate?.("chat", { search });
if (params.catalogOpenTarget === "terminal" && canOpenTerminal) {
openTerminal();
} else {
params.onNavigate?.("chat", { search });
}
}}
>
<span class="sidebar-recent-session__text">
@@ -282,10 +316,28 @@ function renderCatalogSessionRow(
? html`<span class="sidebar-recent-session__subtitle">${hostSubtitle}</span>`
: nothing}
</span>
<span class="sidebar-recent-session__aside session-row-aside">
<span class="session-row-trail">${formatSidebarTimestamp(timestamp)}</span>
</span>
</a>
<span class="sidebar-recent-session__aside session-row-aside">
<span class="session-row-trail">${formatSidebarTimestamp(timestamp)}</span>
<span class="session-row-actions">
<button
class="session-action"
data-catalog-session-menu="true"
type="button"
title=${t("chat.sidebar.openSessionMenu")}
aria-label=${t("chat.sidebar.openSessionMenu")}
aria-haspopup="menu"
@click=${(event: MouseEvent) => {
event.stopPropagation();
const trigger = event.currentTarget as HTMLElement;
const rect = trigger.getBoundingClientRect();
openMenu(rect.right, rect.bottom + 4, trigger);
}}
>
${icons.moreHorizontal}
</button>
</span>
</span>
</div>
`;
}
+75 -1
View File
@@ -19,12 +19,13 @@ import {
import { CATALOG_SESSION_CONTINUED_EVENT } from "../lib/sessions/catalog-key.ts";
import type { SessionCapability } from "../lib/sessions/index.ts";
import { createStorageMock } from "../test-helpers/storage.ts";
import "./app-sidebar.ts";
import {
LOBSTER_LOGO_VISIT_EVENT,
createLobsterPetLook,
type LobsterLogoVisitDetail,
} from "./lobster-pet.ts";
import "./app-sidebar.ts";
import { TERMINAL_PANEL_TOGGLE_EVENT } from "./panel-toggle-contract.ts";
type SessionGroupMutationResult = Awaited<ReturnType<SessionCapability["groupsRename"]>>;
type SessionDeleteResult = Awaited<ReturnType<SessionCapability["delete"]>>;
@@ -53,6 +54,8 @@ if (!customElements.get(PROVIDER_ELEMENT_NAME)) {
type SidebarLifecycleState = HTMLElement & {
connected: boolean;
terminalAvailable: boolean;
catalogOpenTarget: "viewer" | "terminal";
canPairDevice: boolean;
pinnedAgentIds: readonly string[];
sessionKey: string;
@@ -2686,6 +2689,77 @@ describe("AppSidebar catalog session rows", () => {
}
});
it("routes terminal-preferred clicks to a typed terminal toggle", async () => {
vi.useFakeTimers();
try {
const { sidebar } = await mountWithCatalog(
catalogList([{ threadId: "thread-1", name: "Resume me", canOpenTerminal: true }]),
["agent:main:main"],
);
sidebar.catalogOpenTarget = "terminal";
sidebar.terminalAvailable = true;
const navigate = vi.fn();
sidebar.onNavigate = navigate;
let detail: unknown;
const listener = (event: Event) => {
detail = (event as CustomEvent).detail;
};
window.addEventListener(TERMINAL_PANEL_TOGGLE_EVENT, listener);
try {
await sidebar.updateComplete;
(sidebar.querySelector('[data-session-key*="thread-1"] a') as HTMLElement).click();
} finally {
window.removeEventListener(TERMINAL_PANEL_TOGGLE_EVENT, listener);
}
expect(detail).toEqual({
open: true,
catalog: { catalogId: "codex", hostId: "gateway:local", threadId: "thread-1" },
});
expect(navigate).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("falls back to the viewer and disables the terminal menu item when ineligible", async () => {
vi.useFakeTimers();
try {
const { sidebar } = await mountWithCatalog(
catalogList([{ threadId: "thread-1", name: "View me", canOpenTerminal: false }]),
["agent:main:main"],
);
sidebar.catalogOpenTarget = "terminal";
sidebar.terminalAvailable = true;
const navigate = vi.fn();
sidebar.onNavigate = navigate;
await sidebar.updateComplete;
const row = sidebar.querySelector('[data-session-key*="thread-1"]') as HTMLElement;
(row.querySelector("a") as HTMLElement).click();
expect(navigate).toHaveBeenCalledWith("chat", {
search: "?session=catalog%3Acodex%3Agateway%253Alocal%3Athread-1",
});
row.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: 20,
clientY: 30,
}),
);
await sidebar.updateComplete;
const menu = sidebar.querySelector("openclaw-catalog-session-menu") as HTMLElement & {
updateComplete: Promise<boolean>;
};
await menu.updateComplete;
const items = menu.querySelectorAll<HTMLElement & { disabled: boolean }>("wa-dropdown-item");
expect(items).toHaveLength(2);
expect(items[1]?.disabled).toBe(true);
expect(row.querySelector("[data-catalog-session-menu]")).not.toBeNull();
} finally {
vi.useRealTimers();
}
});
it("marks the routed catalog session row active without a phantom chat row", async () => {
vi.useFakeTimers();
try {
+26 -26
View File
@@ -25,6 +25,7 @@ import {
} from "../app/context.ts";
import { beginNativeWindowDragFromTopInset } from "../app/native-window-drag.ts";
import { controlUiPublicAssetPath } from "../app/public-assets.ts";
import type { CatalogOpenTarget } from "../app/settings.ts";
import type { ThemeMode } from "../app/theme.ts";
import { t } from "../i18n/index.ts";
import "./menu-surface.ts";
@@ -50,6 +51,7 @@ import {
CATALOG_SESSION_CONTINUED_EVENT,
type CatalogSessionContinuedDetail,
} from "../lib/sessions/catalog-key.ts";
import { openCatalogSessionInTerminal } from "../lib/sessions/catalog-terminal.ts";
import { reorderSessionCustomGroups } from "../lib/sessions/custom-groups.ts";
import {
readSessionDragData,
@@ -85,6 +87,7 @@ import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
import type { NewSessionTarget } from "../pages/new-session/location.ts";
import { renderSidebarAgentMenu } from "./app-sidebar-agent-menu.ts";
import { SidebarCatalogMenuController } from "./app-sidebar-catalog-menu.ts";
import {
isSidebarRouteActive,
renderSidebarCustomizeMenu,
@@ -247,6 +250,8 @@ class AppSidebar extends OpenClawLightDomContentsElement {
@property({ attribute: false }) activePluginTabId = "";
@property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[];
@property({ attribute: false }) connected = false;
@property({ attribute: false }) terminalAvailable = false;
@property({ attribute: false }) catalogOpenTarget: CatalogOpenTarget = "viewer";
@property({ attribute: false }) canPairDevice = false;
@property({ attribute: false }) sessionKey = "";
@property({ attribute: false }) sidebarPinnedRoutes: readonly SidebarNavRoute[] =
@@ -312,6 +317,14 @@ class AppSidebar extends OpenClawLightDomContentsElement {
private customizeMenuTrigger: HTMLElement | null = null;
private moreMenuTrigger: HTMLElement | null = null;
private sessionMenuTrigger: HTMLElement | null = null;
private readonly catalogMenu = new SidebarCatalogMenuController({
// Closing every transient menu (catalog included; it is not open yet) keeps
// one popover at a time without re-listing each sibling close here.
beforeOpen: () => void this.dismissTransientMenus(),
requestUpdate: () => this.requestUpdate(),
terminalAvailable: () => this.terminalAvailable,
navigate: (search) => this.onNavigate?.("chat", { search }),
});
// Guards the async work fetch: a menu reopened for another session must not
// adopt a stale response.
private sessionMenuWorkVersion = 0;
@@ -721,6 +734,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
this.customizeMenuPosition ||
this.moreMenuPosition ||
this.sessionMenu ||
this.catalogMenu.isOpen ||
this.sessionGroupMenu ||
this.sessionSortMenuPosition ||
this.agentMenuPosition,
@@ -728,6 +742,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
this.closeCustomizeMenu();
this.closeMoreMenu();
this.closeSessionMenu();
this.catalogMenu.close();
this.closeSessionGroupMenu();
this.closeSessionSortMenu();
this.closeAgentMenu();
@@ -1369,11 +1384,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
// Clamp so the fixed-position menu never overflows the viewport.
const menuWidth = 240;
const menuMaxHeight = 420;
this.closeMoreMenu();
this.closeSessionMenu();
this.closeSessionGroupMenu();
this.closeSessionSortMenu();
this.closeAgentMenu();
this.dismissTransientMenus();
this.customizeMenuTrigger = trigger;
this.customizeMenuPosition = {
x: Math.max(8, Math.min(x, window.innerWidth - menuWidth - 8)),
@@ -1399,11 +1410,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
const menuWidth = 240;
const menuMaxHeight = 420;
const rect = trigger.getBoundingClientRect();
this.closeCustomizeMenu();
this.closeSessionMenu();
this.closeSessionGroupMenu();
this.closeSessionSortMenu();
this.closeAgentMenu();
this.dismissTransientMenus();
this.moreMenuTrigger = trigger;
this.moreMenuPosition = {
x: Math.max(8, Math.min(rect.left, window.innerWidth - menuWidth - 8)),
@@ -1440,11 +1447,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
y: number,
trigger: HTMLElement | null = null,
) {
this.closeCustomizeMenu();
this.closeMoreMenu();
this.closeSessionGroupMenu();
this.closeSessionSortMenu();
this.closeAgentMenu();
this.dismissTransientMenus();
this.sessionMenuTrigger = trigger;
this.sessionMenu = { session, x, y };
this.loadSessionMenuWork(session);
@@ -1489,11 +1492,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
private openSessionGroupMenu(group: string, x: number, y: number, trigger: HTMLElement | null) {
const menuWidth = 224;
const menuMaxHeight = 160;
this.closeCustomizeMenu();
this.closeMoreMenu();
this.closeSessionMenu();
this.closeSessionSortMenu();
this.closeAgentMenu();
this.dismissTransientMenus();
this.sessionGroupMenuTrigger = trigger;
this.sessionGroupMenu = {
group,
@@ -1519,11 +1518,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
const menuWidth = 200;
const menuMaxHeight = 280;
const rect = trigger.getBoundingClientRect();
this.closeCustomizeMenu();
this.closeMoreMenu();
this.closeSessionMenu();
this.closeSessionGroupMenu();
this.closeAgentMenu();
this.dismissTransientMenus();
this.sessionSortMenuTrigger = trigger;
this.sessionSortMenuPosition = {
x: Math.max(8, Math.min(rect.right, window.innerWidth - menuWidth - 8)),
@@ -2891,6 +2886,10 @@ class AppSidebar extends OpenClawLightDomContentsElement {
onLoadMore: (catalogId) => void this.loadMoreSessionCatalog(catalogId),
onOpenNewSession: this.onOpenNewSession,
onNavigate: this.onNavigate,
catalogOpenTarget: this.catalogOpenTarget,
terminalAvailable: this.terminalAvailable,
onOpenTerminal: (key) => openCatalogSessionInTerminal(key),
onOpenMenu: (request, x, y, trigger) => this.catalogMenu.open(request, x, y, trigger),
});
}
@@ -3038,7 +3037,8 @@ class AppSidebar extends OpenClawLightDomContentsElement {
</div>
</div>
${this.renderCustomizeMenu()} ${this.renderMoreMenu()} ${this.renderAgentMenu()}
${this.renderSessionMenu()} ${this.renderSessionGroupMenu()} ${this.renderSessionSortMenu()}
${this.renderSessionMenu()} ${this.catalogMenu.render()} ${this.renderSessionGroupMenu()}
${this.renderSessionSortMenu()}
</aside>
`;
}
@@ -0,0 +1,89 @@
/* @vitest-environment jsdom */
import { html, render } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import "./catalog-session-menu.ts";
import type { CatalogSessionMenuAction } from "./catalog-session-menu.ts";
type CatalogMenuElement = HTMLElement & { updateComplete: Promise<boolean> };
type CatalogMenuItem = HTMLElement & { disabled: boolean; updateComplete: Promise<unknown> };
const containers: HTMLElement[] = [];
afterEach(() => {
for (const container of containers.splice(0)) {
container.remove();
}
});
describe("catalog session menu", () => {
it("renders an anchored Web Awesome dropdown and focuses the first item", async () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
render(html`<openclaw-catalog-session-menu></openclaw-catalog-session-menu>`, container);
const menu = container.querySelector("openclaw-catalog-session-menu") as CatalogMenuElement;
await menu.updateComplete;
const dropdown = menu.querySelector<HTMLElement & { open: boolean }>("wa-dropdown");
const items = [...menu.querySelectorAll<CatalogMenuItem>("wa-dropdown-item")];
await Promise.resolve();
expect(dropdown?.open).toBe(true);
expect(document.activeElement).toBe(items[0]);
expect(items.map((item) => item.getAttribute("value"))).toEqual(["viewer", "terminal"]);
});
it.each([
[0, "viewer"],
[1, "terminal"],
] as const)("dispatches item %s before synchronous close", async (index, expected) => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
let backingState: { open: true } | null = { open: true };
const order: string[] = [];
const onAction = vi.fn((action: CatalogSessionMenuAction) => {
order.push(backingState ? action : "cleared");
});
render(
html`<openclaw-catalog-session-menu
.onAction=${onAction}
.onClose=${() => {
backingState = null;
order.push("close");
}}
></openclaw-catalog-session-menu>`,
container,
);
const menu = container.querySelector("openclaw-catalog-session-menu") as CatalogMenuElement;
await menu.updateComplete;
menu.querySelectorAll<CatalogMenuItem>("wa-dropdown-item")[index]?.click();
expect(onAction).toHaveBeenCalledWith(expected);
expect(order).toEqual([expected, "close"]);
expect(backingState).toBeNull();
});
it("disables terminal selection with the unavailable reason", async () => {
const onAction = vi.fn();
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
render(
html`<openclaw-catalog-session-menu
.terminalDisabled=${true}
.onAction=${onAction}
></openclaw-catalog-session-menu>`,
container,
);
const menu = container.querySelector("openclaw-catalog-session-menu") as CatalogMenuElement;
await menu.updateComplete;
const terminal = menu.querySelector<CatalogMenuItem>('wa-dropdown-item[value="terminal"]');
expect(terminal?.disabled).toBe(true);
expect(terminal?.title).toBe("Terminal opening is unavailable for this session.");
terminal?.click();
expect(onAction).not.toHaveBeenCalled();
});
});
+116
View File
@@ -0,0 +1,116 @@
import { html } from "lit";
import { property } from "lit/decorators.js";
import { t } from "../i18n/index.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { icons } from "./icons.ts";
import { promoteToPopoverTopLayer } from "./menu-surface.ts";
import "./web-awesome.ts";
export type CatalogSessionMenuAction = "viewer" | "terminal";
class CatalogSessionMenu extends OpenClawLightDomElement {
@property({ attribute: false }) x = 0;
@property({ attribute: false }) y = 0;
@property({ attribute: false }) trigger: HTMLElement | null = null;
@property({ attribute: false }) terminalDisabled = false;
@property({ attribute: false }) onAction: (action: CatalogSessionMenuAction) => void = () => {};
@property({ attribute: false }) onClose: () => void = () => {};
override connectedCallback() {
super.connectedCallback();
document.addEventListener("keydown", this.handleDocumentKeydown, true);
promoteToPopoverTopLayer(this);
}
override disconnectedCallback() {
document.removeEventListener("keydown", this.handleDocumentKeydown, true);
super.disconnectedCallback();
}
protected override firstUpdated(): void {
const dropdown = this.querySelector<HTMLElement & { updateComplete?: Promise<unknown> }>(
"wa-dropdown",
);
void Promise.resolve(dropdown?.updateComplete).then(() => {
this.querySelector<HTMLElement>("wa-dropdown-item:not([disabled])")?.focus();
});
}
private readonly handleDocumentKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.trigger?.focus();
this.onClose();
}
};
private run(action: CatalogSessionMenuAction) {
// Dispatch while the controller still owns the menu snapshot; close clears it synchronously.
this.onAction(action);
this.onClose();
}
private readonly handleSelect = (
event: CustomEvent<{ item: { value?: CatalogSessionMenuAction } }>,
) => {
event.preventDefault();
const action = event.detail.item.value;
if (action) {
this.run(action);
}
};
private readonly handleAfterHide = (event: Event) => {
if (event.currentTarget instanceof Node && event.currentTarget.isConnected) {
this.onClose();
}
};
override render() {
const menuWidth = 240;
const menuMaxHeight = 112;
const x = Math.max(8, Math.min(this.x, window.innerWidth - menuWidth - 8));
const y = Math.max(8, Math.min(this.y, window.innerHeight - menuMaxHeight - 8));
const menuLabel = t("chat.catalog.sessionMenu");
return html`
<wa-dropdown
class="session-menu"
.open=${true}
placement="bottom-start"
.distance=${0}
aria-label=${menuLabel}
@wa-select=${this.handleSelect}
@wa-after-hide=${this.handleAfterHide}
>
<button
slot="trigger"
type="button"
tabindex="-1"
aria-hidden="true"
aria-label=${menuLabel}
style="position: fixed; left: ${x}px; top: ${y}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
></button>
<wa-dropdown-item class="session-menu__item" value="viewer">
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.messageSquare}</span
>
<span class="session-menu__text">${t("chat.catalog.openInOpenClaw")}</span>
</wa-dropdown-item>
<wa-dropdown-item
class="session-menu__item"
value="terminal"
title=${this.terminalDisabled ? t("chat.catalog.terminalUnavailable") : ""}
?disabled=${this.terminalDisabled}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.terminal}</span>
<span class="session-menu__text">${t("chat.catalog.openInTerminal")}</span>
</wa-dropdown-item>
</wa-dropdown>
`;
}
}
if (!customElements.get("openclaw-catalog-session-menu")) {
customElements.define("openclaw-catalog-session-menu", CatalogSessionMenu);
}
@@ -4,6 +4,11 @@ export const BROWSER_PANEL_TOGGLE_EVENT = "openclaw:browser-toggle";
export type TerminalPanelToggleDetail = {
dock?: "bottom" | "right";
open?: boolean;
catalog?: {
catalogId: string;
hostId: string;
threadId: string;
};
};
export type BrowserPanelToggleDetail = {
@@ -69,6 +69,30 @@ describe("TerminalConnection", () => {
});
});
it("forwards a typed catalog reference and preserves the returned title", async () => {
const client = makeFakeClient();
client.nextResponse = {
sessionId: "s1",
agentId: "main",
shell: "/bin/zsh",
cwd: "/work",
confined: false,
title: "codex resume 0d5c…",
};
const conn = new TerminalConnection(client);
const catalog = { catalogId: "codex", hostId: "node:mac", threadId: "thread" };
const result = await conn.open(
{ cols: 100, rows: 30, catalog },
{ onData: () => {}, onExit: () => {} },
);
expect(client.requests[0]).toEqual({
method: "terminal.open",
params: { cols: 100, rows: 30, catalog },
});
expect(result.title).toBe("codex resume 0d5c…");
});
it("does not deliver data to the wrong session", async () => {
const client = makeFakeClient();
const conn = new TerminalConnection(client);
@@ -14,6 +14,13 @@ type TerminalOpenResult = {
shell: string;
cwd: string;
confined: boolean;
title?: string;
};
type TerminalCatalogReference = {
catalogId: string;
hostId: string;
threadId: string;
};
type TerminalAttachResult = TerminalOpenResult & {
@@ -118,7 +125,7 @@ export class TerminalConnection {
/** Opens a session and registers its output/exit sinks before returning. */
async open(
params: { agentId?: string; cols: number; rows: number },
params: { agentId?: string; cols: number; rows: number; catalog?: TerminalCatalogReference },
sink: SessionSink,
): Promise<TerminalOpenResult> {
const result = await this.requestWhileHoldingStream(() =>
@@ -2,6 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { i18n } from "../../i18n/index.ts";
import { createStorageMock } from "../../test-helpers/storage.ts";
import type { TerminalGatewayClient } from "./terminal-connection.ts";
type CreateOptions = {
@@ -69,6 +70,8 @@ customElements.define(TERMINAL_PANEL_ELEMENT_NAME, TestTerminalPanel);
describe("OpenClawTerminalPanel", () => {
beforeEach(async () => {
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("sessionStorage", createStorageMock());
await i18n.setLocale("en");
});
@@ -77,6 +80,7 @@ describe("OpenClawTerminalPanel", () => {
localStorage.clear();
sessionStorage.clear();
createGhosttyTerminalMock.mockReset();
vi.unstubAllGlobals();
await i18n.setLocale("en");
});
@@ -156,6 +160,128 @@ describe("OpenClawTerminalPanel", () => {
});
});
it("opens a new titled tab for a catalog toggle request", async () => {
createGhosttyTerminalMock.mockImplementation(async () => createTerminalController());
const requests: Array<{ method: string; params: unknown }> = [];
const client: TerminalGatewayClient = {
request: async <T>(method: string, params?: unknown) => {
requests.push({ method, params });
return {
...terminalOpenResult("catalog-terminal-1"),
title: "codex resume 0d5c…",
} as T;
},
addEventListener: () => () => {},
};
const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel;
panel.client = client;
panel.available = true;
document.body.append(panel);
const catalog = { catalogId: "codex", hostId: "node:mac", threadId: "thread" };
panel.handleToggleRequest(new CustomEvent("openclaw:terminal-toggle", { detail: { catalog } }));
await vi.waitFor(() => {
expect(requests).toContainEqual({
method: "terminal.open",
params: { agentId: undefined, cols: 100, rows: 30, catalog },
});
});
await panel.updateComplete;
expect(panel.renderRoot.querySelector(".tp-tab")?.textContent).toContain("codex resume 0d5c…");
});
it("reattaches persisted sessions before opening a catalog tab", async () => {
sessionStorage.setItem("openclaw.terminal.sessions.v1", JSON.stringify(["persisted-1"]));
createGhosttyTerminalMock
.mockResolvedValueOnce(createTerminalController())
.mockResolvedValueOnce(createTerminalController());
const requests: Array<{ method: string; params: unknown }> = [];
const client: TerminalGatewayClient = {
request: async <T>(method: string, params?: unknown) => {
requests.push({ method, params });
if (method === "terminal.list") {
return {
sessions: [
{
...terminalOpenResult("persisted-1"),
attached: false,
createdAtMs: 1,
},
],
} as T;
}
if (method === "terminal.attach") {
return { ...terminalOpenResult("persisted-1"), buffer: "persisted output" } as T;
}
if (method === "terminal.open") {
return {
...terminalOpenResult("catalog-terminal-1"),
title: "codex resume thread",
} as T;
}
return {} as T;
},
addEventListener: () => () => {},
};
const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel;
panel.client = client;
panel.available = true;
document.body.append(panel);
const catalog = { catalogId: "codex", hostId: "node:mac", threadId: "thread" };
panel.handleToggleRequest(new CustomEvent("openclaw:terminal-toggle", { detail: { catalog } }));
await vi.waitFor(() => {
expect(requests.filter((entry) => entry.method === "terminal.attach")).toHaveLength(1);
expect(requests.filter((entry) => entry.method === "terminal.open")).toHaveLength(1);
});
expect(requests.findIndex((entry) => entry.method === "terminal.attach")).toBeLessThan(
requests.findIndex((entry) => entry.method === "terminal.open"),
);
expect(sessionStorage.getItem("openclaw.terminal.sessions.v1")).toBe(
JSON.stringify(["persisted-1", "catalog-terminal-1"]),
);
});
it("queues a catalog toggle that arrives during another terminal boot", async () => {
const firstBoot = deferred<ReturnType<typeof createTerminalController>>();
createGhosttyTerminalMock
.mockReturnValueOnce(firstBoot.promise)
.mockResolvedValueOnce(createTerminalController());
const requests: Array<{ method: string; params: unknown }> = [];
let openCount = 0;
const client: TerminalGatewayClient = {
request: async <T>(method: string, params?: unknown) => {
requests.push({ method, params });
if (method === "terminal.open") {
openCount += 1;
return terminalOpenResult(`session-${openCount}`) as T;
}
return {} as T;
},
addEventListener: () => () => {},
};
const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel;
panel.client = client;
panel.available = true;
document.body.append(panel);
panel.toggle();
await vi.waitFor(() => expect(createGhosttyTerminalMock).toHaveBeenCalledOnce());
const catalog = { catalogId: "codex", hostId: "node:mac", threadId: "thread" };
panel.handleToggleRequest(new CustomEvent("openclaw:terminal-toggle", { detail: { catalog } }));
firstBoot.resolve(createTerminalController());
await vi.waitFor(() => {
expect(requests).toContainEqual({
method: "terminal.open",
params: { agentId: undefined, cols: 100, rows: 30, catalog },
});
});
expect(requests.filter((entry) => entry.method === "terminal.open")).toHaveLength(2);
});
it("fullscreen mode auto-opens without dock chrome and survives last-tab close", async () => {
createGhosttyTerminalMock.mockImplementation(async () => createTerminalController());
const requests: Array<{ method: string; params: unknown }> = [];
@@ -343,11 +469,15 @@ describe("OpenClawTerminalPanel", () => {
panel.remove();
document.body.append(panel);
await panel.updateComplete;
expect(createGhosttyTerminalMock).toHaveBeenCalledOnce();
expect(requests.filter((method) => method === "terminal.open")).toHaveLength(0);
staleBoot.resolve(staleController);
await vi.waitFor(() => {
expect(createGhosttyTerminalMock).toHaveBeenCalledTimes(2);
expect(requests.filter((method) => method === "terminal.open")).toHaveLength(1);
});
staleBoot.resolve(staleController);
await vi.waitFor(() => {
expect(staleController.dispose).toHaveBeenCalledOnce();
+36 -37
View File
@@ -18,6 +18,11 @@ import {
import { TerminalConnection, type TerminalGatewayClient } from "./terminal-connection.ts";
import { renderTerminalPanelTabs, type TerminalPanelTab } from "./terminal-panel-tabs.ts";
import { createIsolatedGhosttyTerminal } from "./terminal-runtime.ts";
import {
loadPersistedTerminalSessionIds,
persistTerminalSessionIds,
} from "./terminal-session-storage.ts";
import { TerminalTaskQueue } from "./terminal-task-queue.ts";
import { terminalTheme } from "./terminal-theme.ts";
// Inline icon set (self-contained; the Control UI blocks external asset loads).
@@ -54,32 +59,11 @@ const panelLayout = createDockPanelLayout({
defaultHeight: 320,
defaultWidth: 520,
});
// Session ids for reattach after a reload/reconnect. Deliberately
// sessionStorage, not localStorage: attach is take-over, and a shared
// per-origin key would make multiple Control UI windows clobber each other's
// ids and steal each other's live shells. Per-tab storage survives exactly the
// cases reattach is for (reload, laptop sleep, transient disconnect).
const SESSIONS_KEY = "openclaw.terminal.sessions.v1";
const TERMINAL_FONT_FAMILY =
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Symbols Nerd Font Mono", "MesloLGLDZ Nerd Font Mono", "JetBrainsMono Nerd Font Mono", "Liberation Mono", monospace';
const TERMINAL_INPUT_DECODER = new TextDecoder();
const TERMINAL_OUTPUT_ENCODER = new TextEncoder();
function loadPersistedSessionIds(): string[] {
try {
const raw = globalThis.sessionStorage?.getItem(SESSIONS_KEY);
if (!raw) {
return [];
}
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((id): id is string => typeof id === "string" && id.length > 0)
: [];
} catch {
return [];
}
}
/** `<openclaw-terminal-panel>` — the dockable Control UI shell surface. */
export class OpenClawTerminalPanel extends OpenClawLitElement {
/** Gateway client used for terminal.* RPCs; null until connected. */
@@ -113,6 +97,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
private lifecycleSyncToken = 0;
private resizeCleanup: (() => void) | null = null;
private tabSeq = 0;
private readonly bootQueue = new TerminalTaskQueue();
protected createTerminal = createIsolatedGhosttyTerminal;
private readonly onGlobalKeyDown = (event: KeyboardEvent) => this.handleGlobalKey(event);
private readonly onToggleRequest = (event: Event) => this.handleToggleRequest(event);
@@ -300,14 +285,14 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
if (dock) {
this.dock = dock;
}
if (detail?.open === true) {
if (detail?.catalog || detail?.open === true) {
if (!this.available) {
return;
}
this.open = true;
this.syncLayoutReservation();
this.persistLayout();
void this.restoreSessions();
void (detail.catalog ? this.openCatalogSession(detail.catalog) : this.restoreSessions());
return;
}
this.toggle();
@@ -332,11 +317,25 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
* the gateway still has them, otherwise fall back to one fresh session.
*/
private async restoreSessions(): Promise<void> {
await this.bootQueue.enqueueSteps(
() => this.reattachPersistedSessions(),
() => this.ensureInitialSession(),
);
}
private async openCatalogSession(catalog: NonNullable<TerminalPanelToggleDetail["catalog"]>) {
await this.bootQueue.enqueueSteps(
() => this.reattachPersistedSessions(),
() => this.openSessionNow(catalog),
);
}
private async reattachPersistedSessions(): Promise<void> {
const operation = this.captureTerminalOperation();
if (!operation || this.booting || this.tabs.length > 0) {
if (!operation || this.tabs.length > 0) {
return;
}
const persisted = loadPersistedSessionIds();
const persisted = loadPersistedTerminalSessionIds();
if (persisted.length > 0) {
this.booting = true;
try {
@@ -369,12 +368,11 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
// Prune ids the gateway no longer knows (reaped or externally closed).
this.persistLiveSessions();
}
await this.ensureInitialSession();
}
private async ensureInitialSession(): Promise<void> {
if (this.tabs.length === 0 && !this.booting) {
await this.openSession();
await this.openSessionNow();
}
}
@@ -477,10 +475,10 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
/** Binds a freshly opened or attached gateway session to its tab. */
private adoptSession(
tab: TerminalTabState,
result: { sessionId: string; shell: string; agentId: string; cwd: string },
result: { sessionId: string; shell: string; agentId: string; cwd: string; title?: string },
): void {
tab.gatewaySessionId = result.sessionId;
tab.shellName = shellBasename(result.shell);
tab.shellName = result.title ?? shellBasename(result.shell);
tab.agentId = result.agentId;
tab.cwd = result.cwd;
// Libterminal observes layout before the Gateway session exists. Resync the
@@ -501,9 +499,13 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
}
}
private async openSession(): Promise<void> {
private async openSession(catalog?: TerminalPanelToggleDetail["catalog"]): Promise<void> {
await this.bootQueue.enqueue(() => this.openSessionNow(catalog));
}
private async openSessionNow(catalog?: TerminalPanelToggleDetail["catalog"]): Promise<void> {
const operation = this.captureTerminalOperation();
if (!operation || this.booting) {
if (!operation) {
return;
}
this.booting = true;
@@ -516,7 +518,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
const boot = await this.bootTab(operation);
createdTab = boot.tab;
const result = await boot.connection.open(
{ agentId, cols: boot.cols, rows: boot.rows },
{ agentId, cols: boot.cols, rows: boot.rows, ...(catalog ? { catalog } : {}) },
this.tabSink(boot.tab),
);
if (!this.isTerminalOperationCurrent(operation) || boot.tab.cancelled) {
@@ -679,6 +681,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
this.lifecycleGeneration += 1;
this.lifecycleAbortController.abort();
this.lifecycleAbortController = new AbortController();
this.bootQueue.reset();
this.booting = false;
this.clearResizeListeners();
for (const tab of this.tabs) {
@@ -721,11 +724,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
const ids = this.tabs
.filter((tab) => tab.status === "live" && tab.gatewaySessionId)
.map((tab) => tab.gatewaySessionId);
try {
globalThis.sessionStorage?.setItem(SESSIONS_KEY, JSON.stringify(ids));
} catch {
// Storage may be unavailable (private mode); reattach just won't work.
}
persistTerminalSessionIds(ids);
}
private persistLayout(): void {
@@ -0,0 +1,27 @@
// Session ids deliberately use per-tab storage: attach is a takeover, so shared
// local storage could let one Control UI window steal another window's shells.
const TERMINAL_SESSIONS_KEY = "openclaw.terminal.sessions.v1";
export function loadPersistedTerminalSessionIds(): string[] {
try {
const raw = globalThis.sessionStorage?.getItem(TERMINAL_SESSIONS_KEY);
if (!raw) {
return [];
}
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((id): id is string => typeof id === "string" && id.length > 0)
: [];
} catch {
return [];
}
}
export function persistTerminalSessionIds(ids: readonly string[]): void {
try {
globalThis.sessionStorage?.setItem(TERMINAL_SESSIONS_KEY, JSON.stringify(ids));
} catch {
// Storage may be unavailable (private mode); reattach just won't work.
}
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { TerminalTaskQueue } from "./terminal-task-queue.ts";
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((next) => {
resolve = next;
});
return { promise, resolve };
}
describe("TerminalTaskQueue", () => {
it("drains superseded work before starting the next generation", async () => {
const queue = new TerminalTaskQueue();
const release = deferred();
const events: string[] = [];
const stale = queue.enqueue(async (isCurrent) => {
events.push("stale:start");
await release.promise;
events.push(isCurrent() ? "stale:current" : "stale:cancelled");
});
await Promise.resolve();
queue.reset();
const current = queue.enqueue((isCurrent) => {
events.push(isCurrent() ? "current:start" : "current:cancelled");
return Promise.resolve();
});
await Promise.resolve();
expect(events).toEqual(["stale:start"]);
release.resolve();
await Promise.all([stale, current]);
expect(events).toEqual(["stale:start", "stale:cancelled", "current:start"]);
});
});
@@ -0,0 +1,35 @@
// Terminal boot ownership: serialize open and reattach work so requests that
// arrive during an async boot are delayed instead of silently discarded.
async function runTerminalTaskSteps(
isCurrent: () => boolean,
steps: Array<() => Promise<void>>,
): Promise<void> {
for (const step of steps) {
await step();
if (!isCurrent()) {
return;
}
}
}
export class TerminalTaskQueue {
private tail: Promise<void> = Promise.resolve();
private generation = 0;
enqueue(task: (isCurrent: () => boolean) => Promise<void>): Promise<void> {
const generation = this.generation;
const isCurrent = () => generation === this.generation;
const run = () => (isCurrent() ? task(isCurrent) : Promise.resolve());
const next = this.tail.then(run, run);
this.tail = next.catch(() => {});
return next;
}
enqueueSteps(...steps: Array<() => Promise<void>>): Promise<void> {
return this.enqueue((isCurrent) => runTerminalTaskSteps(isCurrent, steps));
}
reset(): void {
this.generation += 1;
}
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-14T04:54:00.093Z",
"generatedAt": "2026-07-14T05:17:23.793Z",
"locale": "ar",
"model": "gpt-5.6-sol",
"provider": "openai",
"sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020",
"totalKeys": 3325,
"translatedKeys": 3325,
"sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240",
"totalKeys": 3332,
"translatedKeys": 3332,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-14T04:53:11.297Z",
"generatedAt": "2026-07-14T05:17:22.410Z",
"locale": "de",
"model": "gpt-5.6-sol",
"provider": "openai",
"sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020",
"totalKeys": 3325,
"translatedKeys": 3325,
"sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240",
"totalKeys": 3332,
"translatedKeys": 3332,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-14T04:53:16.889Z",
"generatedAt": "2026-07-14T05:17:22.632Z",
"locale": "es",
"model": "gpt-5.6-sol",
"provider": "openai",
"sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020",
"totalKeys": 3325,
"translatedKeys": 3325,
"sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240",
"totalKeys": 3332,
"translatedKeys": 3332,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-14T04:55:20.722Z",
"generatedAt": "2026-07-14T05:17:26.027Z",
"locale": "fa",
"model": "gpt-5.6-sol",
"provider": "openai",
"sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020",
"totalKeys": 3325,
"translatedKeys": 3325,
"sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240",
"totalKeys": 3332,
"translatedKeys": 3332,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-14T04:53:41.406Z",
"generatedAt": "2026-07-14T05:17:23.308Z",
"locale": "fr",
"model": "gpt-5.6-sol",
"provider": "openai",
"sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020",
"totalKeys": 3325,
"translatedKeys": 3325,
"sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240",
"totalKeys": 3332,
"translatedKeys": 3332,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-14T04:53:53.592Z",
"generatedAt": "2026-07-14T05:17:23.547Z",
"locale": "hi",
"model": "gpt-5.6-sol",
"provider": "openai",
"sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020",
"totalKeys": 3325,
"translatedKeys": 3325,
"sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240",
"totalKeys": 3332,
"translatedKeys": 3332,
"workflow": 1
}
+1
View File
@@ -263,6 +263,7 @@
{"cache_key":"1440f57edb258f75c86d35c9edaca337b0db54f95d3d30ed7ba7ce60474ea30f","model":"gpt-5.5","provider":"openai","segment_id":"workboard.agentFilter","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Filter by agent","text_hash":"94dc2569edb014636216936280f2287350bad6aabf16dbb026bbcac567027550","tgt_lang":"hi","translated":"एजेंट के अनुसार फ़िल्टर करें","updated_at":"2026-06-26T21:32:18.718Z"}
{"cache_key":"14649a21c099ef95c7d07f6aab6222e5e0903b030f092820c1bd5274132377c4","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.detailPanel.nextMatch","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Next match","text_hash":"825e5abd0762be6e7083ca449c61fc73862d150d429cc9351b8cfae2a05cecc8","tgt_lang":"hi","translated":"अगला मिलान","updated_at":"2026-07-12T06:43:01.834Z"}
{"cache_key":"146bea1e6d806555e8add89e60668fb17ca007ec1a000069dc0d02c54792c55e","model":"gpt-5.5","provider":"openai","segment_id":"cron.form.webhookUrl","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Webhook URL","text_hash":"84805a7574a82052bdd5b3b98119cfd838d04036ec4bd3d667a95698e7097ad6","tgt_lang":"hi","translated":"Webhook URL","updated_at":"2026-06-26T21:37:57.526Z"}
{"cache_key":"14838c7a335d257641b3caca37872822a99e41b3c5269c823ddffa85e6403d12","model":"gpt-5.5","provider":"openai","segment_id":"chat.catalogOpenTargetTerminal","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Terminal","text_hash":"e0926fdac700b09497b5f0218ea3dd54fa13c0bdeaee6caa7b85e50b852aa05f","tgt_lang":"hi","translated":"टर्मिनल","updated_at":"2026-06-26T21:33:40.484Z"}
{"cache_key":"1486ef91145bc01bba3c7fa6cee8b038232c867a0478cbb841990127a7da31b2","model":"gpt-5.5","provider":"openai","segment_id":"usage.details.conversation","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Conversation","text_hash":"ccca1817575365871461752f3229dd59ede742ae69e350e20fd00a6ce3d149e3","tgt_lang":"hi","translated":"बातचीत","updated_at":"2026-06-26T21:35:26.182Z"}
{"cache_key":"14cc9cb1d9a90015b67a9421d15477488260b17ff0d9de07df8a003ca7f0d958","model":"gpt-5.5","provider":"openai","segment_id":"cron.jobs.schedule","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Schedule","text_hash":"f4830a1dae2980447c716bd4b5779b7013575ef09f70ef4731457218792487b3","tgt_lang":"hi","translated":"शेड्यूल","updated_at":"2026-06-26T21:37:06.965Z"}
{"cache_key":"14ccee0722198fc3d3ea65ce5db46481716eea46e029af116d2c5507c9ff2d73","model":"gpt-5.5","provider":"openai","segment_id":"claudeSessions.transcript.item","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Claude item","text_hash":"325d97a33be5a63ad70e4e8d6113714b8f8d8472e337db0ba459abf85b1b1d48","tgt_lang":"hi","translated":"Claude आइटम","updated_at":"2026-07-11T13:50:48.473Z"}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-14T04:54:35.208Z",
"generatedAt": "2026-07-14T05:17:24.718Z",
"locale": "id",
"model": "gpt-5.6-sol",
"provider": "openai",
"sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020",
"totalKeys": 3325,
"translatedKeys": 3325,
"sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240",
"totalKeys": 3332,
"translatedKeys": 3332,
"workflow": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-14T04:54:09.261Z",
"generatedAt": "2026-07-14T05:17:24.028Z",
"locale": "it",
"model": "gpt-5.6-sol",
"provider": "openai",
"sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020",
"totalKeys": 3325,
"translatedKeys": 3325,
"sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240",
"totalKeys": 3332,
"translatedKeys": 3332,
"workflow": 1
}

Some files were not shown because too many files have changed in this diff Show More