Files
openclaw/src/node-host/runtime.test.ts
T
Peter Steinberger d8d2f83cc1 feat(terminal): open Codex/Claude catalog sessions in a terminal on their owning host
Catalog session rows (sidebar context menu + click), the built-in viewer
header, and a new "Open Codex/Claude sessions in" preference can launch the
native CLI (codex resume / claude --resume) in the operator terminal on the
machine that owns the session.

- Gateway-local sessions spawn through the existing terminal launch policy
  (sandbox/enabled gates preserved) with the resume command in the session cwd.
- Paired-node sessions run through a new seq-ordered node PTY relay: a
  duplex node-host command streams PTY output via node.invoke.progress and
  receives keystrokes/resize via a new node.invoke.input event, behind the
  unchanged terminal.* client protocol (TerminalSessionManager gains a backend
  abstraction; node relay reuses the streaming-invoke controller).
- Owner boundary: each plugin owns its resume command and builds argv from a
  validated thread id; the gateway routes node opens through the node command
  allowlist and plugin invoke policy (no advertisement-only trust), and nodes
  re-verify session eligibility before spawning.
- UI setting catalogOpenTarget + canOpenTerminal capability gate every entry
  point; capability requires the owning host to actually have the CLI.

Node PATH is normalized before command-availability probes, Windows .cmd/.bat
shims spawn via ComSpec, and catalog terminal opens reattach persisted tabs
before opening the new tab.
2026-07-13 22:20:50 -07:00

65 lines
2.3 KiB
TypeScript

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"]]);
});
});