mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 11:51:15 +00:00
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.
65 lines
2.0 KiB
TypeScript
65 lines
2.0 KiB
TypeScript
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);
|
|
});
|
|
});
|