Merge U10: cli-agent session transport (WS attach, tickets, SSE state, output hardening)

# Conflicts:
#	packages/engine/src/index.ts
This commit is contained in:
gsxdsm
2026-06-04 23:46:23 -07:00
11 changed files with 2176 additions and 1 deletions

View File

@@ -0,0 +1,14 @@
---
"@runfusion/fusion": minor
---
CLI agent session transport (U10): authenticated cli-sessions REST routes
(list, single-use session-scoped attach tickets, inject, confirm-advance), a
distinct `/api/cli-sessions/ws` WebSocket attach handler (daemon-token + Origin
allowlist + single-use ticket gate, scrollback replay then live byte frames,
ACK-credit flow control driving engine pause/resume, latest-active-client
resize, server-side read-only enforcement, input-source attribution), a
streaming-safe outbound output filter (`neutralizeTerminalOutput`) that strips
OSC 52 clipboard writes, non-http(s) OSC 8 hyperlink URIs, and device-status /
query sequences, and a throttled `cli:session:state` SSE event with
Last-Event-ID replay.

View File

@@ -0,0 +1,134 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import {
neutralizeTerminalOutput,
flushTerminalOutput,
MAX_CARRY_LENGTH,
} from "../cli-session-output-filter.js";
const ESC = "\x1b";
const BEL = "\x07";
const ST = "\x1b\\";
/** Run a single full chunk and return the output (asserting no carry leftover). */
function run(chunk: string): string {
const { output, carry } = neutralizeTerminalOutput(chunk, "");
return output + flushTerminalOutput(carry);
}
describe("neutralizeTerminalOutput", () => {
it("passes plain text through untouched", () => {
expect(run("hello world\n")).toBe("hello world\n");
});
it("strips OSC 52 clipboard-write sequences (BEL-terminated)", () => {
const evil = `before${ESC}]52;c;ZXZpbA==${BEL}after`;
const out = run(evil);
expect(out).toBe("beforeafter");
expect(out).not.toContain("52;");
});
it("strips OSC 52 clipboard-write sequences (ST-terminated)", () => {
const evil = `x${ESC}]52;c;ZGF0YQ==${ST}y`;
expect(run(evil)).toBe("xy");
});
it("strips the URI of an OSC 8 javascript: hyperlink but keeps the text", () => {
const link = `${ESC}]8;;javascript:alert(1)${BEL}Click me${ESC}]8;;${BEL}`;
const out = run(link);
expect(out).not.toContain("javascript:");
expect(out).toContain("Click me");
// The opening link should be present but with an empty URI.
expect(out).toContain(`${ESC}]8;;`);
});
it("passes through OSC 8 https hyperlinks verbatim", () => {
const link = `${ESC}]8;;https://example.com${BEL}Link${ESC}]8;;${BEL}`;
const out = run(link);
expect(out).toContain("https://example.com");
expect(out).toContain("Link");
});
it("strips DSR (device status report) query sequences", () => {
// ESC [ 6 n is a cursor-position report query; the terminal would answer it.
const out = run(`a${ESC}[6nb`);
expect(out).toBe("ab");
});
it("strips DA (device attributes) query sequences", () => {
expect(run(`a${ESC}[cb`)).toBe("ab");
expect(run(`a${ESC}[>cb`)).toBe("ab");
});
it("strips DECRQSS (DCS query) sequences", () => {
const out = run(`a${ESC}P$qm${ST}b`);
expect(out).toBe("ab");
});
it("preserves benign CSI sequences like SGR color", () => {
const colored = `${ESC}[31mred${ESC}[0m`;
expect(run(colored)).toBe(colored);
});
it("preserves cursor movement CSI (not a query)", () => {
const moved = `${ESC}[2J${ESC}[H`;
expect(run(moved)).toBe(moved);
});
it("handles an OSC 52 sequence split across two chunks", () => {
const first = `before${ESC}]52;c;ZXZ`;
const second = `pbA==${BEL}after`;
const r1 = neutralizeTerminalOutput(first, "");
// The unterminated OSC should be withheld in carry, not emitted.
expect(r1.output).toBe("before");
expect(r1.carry).toContain("52;");
const r2 = neutralizeTerminalOutput(second, r1.carry);
expect(r2.output).toBe("after");
expect(r2.output + flushTerminalOutput(r2.carry)).not.toContain("52;");
});
it("handles a DSR query split across two chunks", () => {
const r1 = neutralizeTerminalOutput(`a${ESC}[6`, "");
expect(r1.output).toBe("a");
const r2 = neutralizeTerminalOutput(`nb`, r1.carry);
expect(r2.output).toBe("b");
});
it("withholds a lone trailing ESC as carry", () => {
const r1 = neutralizeTerminalOutput(`hi${ESC}`, "");
expect(r1.output).toBe("hi");
expect(r1.carry).toBe(ESC);
const r2 = neutralizeTerminalOutput(`[31mred`, r1.carry);
expect(r2.output).toBe(`${ESC}[31mred`);
});
it("flushes an unterminated sequence at stream end (no infinite withhold)", () => {
const r = neutralizeTerminalOutput(`text${ESC}]52;c;partial`, "");
expect(r.output).toBe("text");
// flush emits the residual literally rather than losing it forever.
expect(flushTerminalOutput(r.carry)).toContain("partial");
});
it("bounds the carry so an unterminated sequence cannot grow unbounded", () => {
const huge = `${ESC}]52;c;` + "A".repeat(MAX_CARRY_LENGTH + 100);
const r = neutralizeTerminalOutput(huge, "");
expect(r.carry.length).toBeLessThanOrEqual(MAX_CARRY_LENGTH);
});
it("neutralizes a stream with OSC 52, OSC 8 js link, and a DSR query together", () => {
const stream =
`start${ESC}]52;c;ZXZpbA==${BEL}` +
`${ESC}]8;;javascript:x${BEL}danger${ESC}]8;;${BEL}` +
`${ESC}[6n` +
`end`;
const out = run(stream);
expect(out).not.toContain("52;");
expect(out).not.toContain("javascript:");
expect(out).toContain("start");
expect(out).toContain("danger");
expect(out).toContain("end");
// No bare query passthrough.
expect(out).not.toContain(`${ESC}[6n`);
});
});

View File

@@ -0,0 +1,580 @@
// @vitest-environment node
import http from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws";
import type { CliSession } from "@fusion/core";
import type { CliSessionAttachment } from "@fusion/engine";
import {
setupCliSessionWebSocket,
CLI_SESSION_WS_PATH,
} from "../cli-session-ws.js";
import {
AttachTicketStore,
CliInputAttributionLog,
bridgeCliStateToSse,
type CliSessionManagerLike,
} from "../cli-session-transport.js";
import {
emitCliSessionStateSseEvent,
getCliSessionStateEventsSince,
resetCliSessionStateBufferForTests,
} from "../sse.js";
import { CliSessionStateMachine } from "@fusion/engine";
const DAEMON_TOKEN = "test-daemon-token";
// ── A fake PTY-backed session (no real node-pty) ─────────────────────────────
class FakeAttachment implements CliSessionAttachment {
scrollback: Uint8Array;
private queue: Uint8Array[] = [];
private waiters: ((r: IteratorResult<Uint8Array>) => void)[] = [];
private closed = false;
writes: string[] = [];
resizes: { cols: number; rows: number }[] = [];
detached = false;
constructor(
scrollback: string,
private readonly onWrite: (data: string) => void,
private readonly onResize: (cols: number, rows: number) => void,
) {
this.scrollback = Buffer.from(scrollback, "utf8");
}
pushLive(text: string): void {
const chunk = new Uint8Array(Buffer.from(text, "utf8"));
const waiter = this.waiters.shift();
if (waiter) waiter({ value: chunk, done: false });
else this.queue.push(chunk);
}
close(): void {
if (this.closed) return;
this.closed = true;
while (this.waiters.length) this.waiters.shift()!({ value: undefined, done: true });
}
get stream(): AsyncIterable<Uint8Array> {
return {
[Symbol.asyncIterator]: () => ({
next: (): Promise<IteratorResult<Uint8Array>> => {
const q = this.queue.shift();
if (q !== undefined) return Promise.resolve({ value: q, done: false });
if (this.closed) return Promise.resolve({ value: undefined, done: true });
return new Promise((resolve) => this.waiters.push(resolve));
},
return: (): Promise<IteratorResult<Uint8Array>> => {
this.close();
return Promise.resolve({ value: undefined, done: true });
},
}),
};
}
write(data: string): void {
this.onWrite(data);
this.writes.push(data);
}
resize(cols: number, rows: number): void {
this.onResize(cols, rows);
this.resizes.push({ cols, rows });
}
detach(): void {
this.detached = true;
this.close();
}
}
class FakeManager implements CliSessionManagerLike {
attachments = new Map<string, FakeAttachment[]>();
live = new Set<string>();
ptyInput: string[] = [];
ptyResizes: { cols: number; rows: number }[] = [];
paused = 0;
resumed = 0;
constructor(liveIds: string[]) {
for (const id of liveIds) this.live.add(id);
}
isLive(id: string): boolean {
return this.live.has(id);
}
makeAttachment(id: string, scrollback: string): FakeAttachment {
const att = new FakeAttachment(
scrollback,
(data) => this.ptyInput.push(data),
(cols, rows) => this.ptyResizes.push({ cols, rows }),
);
const list = this.attachments.get(id) ?? [];
list.push(att);
this.attachments.set(id, list);
return att;
}
attach(id: string): CliSessionAttachment {
// Each attach gets its own attachment, but they share the PTY input sink.
return this.makeAttachment(id, this.scrollbackFor(id));
}
private scrollbackFor(id: string): string {
return this.scrollbackById.get(id) ?? "";
}
scrollbackById = new Map<string, string>();
/** Broadcast live bytes to every attachment of a session. */
broadcast(id: string, text: string): void {
for (const att of this.attachments.get(id) ?? []) att.pushLive(text);
}
requestPause(): void {
this.paused += 1;
}
requestResume(): void {
this.resumed += 1;
}
}
function makeSession(overrides: Partial<CliSession> = {}): CliSession {
return {
id: "cli-1",
taskId: "FN-1",
chatSessionId: null,
purpose: "execute",
projectId: "proj-a",
adapterId: "claude-code",
agentState: "busy",
terminationReason: null,
nativeSessionId: null,
resumeAttempts: 0,
autonomyPosture: null,
worktreePath: "/tmp/wt",
createdAt: "2026-06-04T00:00:00.000Z",
updatedAt: "2026-06-04T00:00:00.000Z",
...overrides,
};
}
function makeStore(sessions: CliSession[]) {
const map = new Map(sessions.map((s) => [s.id, s]));
return {
getSession: (id: string) => map.get(id),
listSessions: () => [...map.values()],
};
}
// ── Harness: a real http.Server on an EPHEMERAL port (0 — never 4040) ────────
interface Harness {
port: number;
manager: FakeManager;
ticketStore: AttachTicketStore;
attributionLog: CliInputAttributionLog;
store: ReturnType<typeof makeStore>;
close: () => Promise<void>;
}
async function startHarness(opts?: {
sessions?: CliSession[];
liveIds?: string[];
highWatermarkBytes?: number;
lowWatermarkBytes?: number;
noAuth?: boolean;
}): Promise<Harness> {
const sessions = opts?.sessions ?? [makeSession()];
const store = makeStore(sessions);
const manager = new FakeManager(opts?.liveIds ?? sessions.map((s) => s.id));
const ticketStore = new AttachTicketStore();
const attributionLog = new CliInputAttributionLog();
const server = http.createServer((_req, res) => {
res.statusCode = 426;
res.end();
});
setupCliSessionWebSocket(server, {
manager,
store,
ticketStore,
attributionLog,
daemonToken: DAEMON_TOKEN,
noAuth: opts?.noAuth ?? false,
highWatermarkBytes: opts?.highWatermarkBytes,
lowWatermarkBytes: opts?.lowWatermarkBytes,
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
expect(port).not.toBe(4040);
return {
port,
manager,
ticketStore,
attributionLog,
store,
close: () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
};
}
function wsUrl(
port: number,
params: Record<string, string>,
): string {
const qs = new URLSearchParams(params).toString();
return `ws://127.0.0.1:${port}${CLI_SESSION_WS_PATH}?${qs}`;
}
/** Connect a WS, supplying an Origin (same-host by default) and token via query. */
function connect(
port: number,
params: Record<string, string>,
headers: Record<string, string> = {},
): WebSocket {
return new WebSocket(wsUrl(port, { fn_token: DAEMON_TOKEN, ...params }), {
headers: { origin: `http://127.0.0.1:${port}`, ...headers },
});
}
function nextMessage(ws: WebSocket, predicate?: (m: any) => boolean): Promise<any> {
return new Promise((resolve, reject) => {
const onMsg = (raw: Buffer) => {
const msg = JSON.parse(raw.toString());
if (!predicate || predicate(msg)) {
ws.off("message", onMsg);
resolve(msg);
}
};
ws.on("message", onMsg);
ws.once("error", reject);
});
}
function waitClose(ws: WebSocket): Promise<{ code: number; reason: string }> {
return new Promise((resolve) => {
ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() }));
});
}
function decode(b64: string): string {
return Buffer.from(b64, "base64").toString("utf8");
}
describe("cli-session WS attach", () => {
let h: Harness;
afterEach(async () => {
if (h) await h.close();
});
async function mintTicket(sessionId: string): Promise<string> {
const { ticket } = h.ticketStore.mint({
sessionId,
projectId: h.store.getSession(sessionId)!.projectId,
readOnly: false,
});
return ticket;
}
it("two concurrent attaches both receive live bytes; input from either reaches the PTY; detach of one keeps the session", async () => {
h = await startHarness();
const t1 = await mintTicket("cli-1");
const t2 = await mintTicket("cli-1");
const a = connect(h.port, { sessionId: "cli-1", ticket: t1 });
const b = connect(h.port, { sessionId: "cli-1", ticket: t2 });
await Promise.all([
nextMessage(a, (m) => m.type === "scrollback"),
nextMessage(b, (m) => m.type === "scrollback"),
]);
const aData = nextMessage(a, (m) => m.type === "data");
const bData = nextMessage(b, (m) => m.type === "data");
h.manager.broadcast("cli-1", "live-output");
expect(decode((await aData).data)).toBe("live-output");
expect(decode((await bData).data)).toBe("live-output");
a.send(JSON.stringify({ type: "input", data: "from-a" }));
b.send(JSON.stringify({ type: "input", data: "from-b" }));
await vi.waitFor(() => {
expect(h.manager.ptyInput).toContain("from-a");
expect(h.manager.ptyInput).toContain("from-b");
});
// Detach a — session must stay live and b keeps receiving.
a.close();
await waitClose(a);
expect(h.manager.isLive("cli-1")).toBe(true);
const bData2 = nextMessage(b, (m) => m.type === "data");
h.manager.broadcast("cli-1", "still-here");
expect(decode((await bData2).data)).toBe("still-here");
b.close();
});
it("rejects upgrade without a daemon token", async () => {
h = await startHarness();
const t = await mintTicket("cli-1");
const ws = new WebSocket(wsUrl(h.port, { sessionId: "cli-1", ticket: t }), {
headers: { origin: `http://127.0.0.1:${h.port}` },
});
const err = await new Promise<Error>((resolve) => ws.once("error", resolve));
expect(err.message).toMatch(/401/);
});
it("rejects a foreign Origin", async () => {
h = await startHarness();
const t = await mintTicket("cli-1");
const ws = connect(
h.port,
{ sessionId: "cli-1", ticket: t },
{ origin: "http://evil.example.com" },
);
const err = await new Promise<Error>((resolve) => ws.once("error", resolve));
expect(err.message).toMatch(/403/);
});
it("rejects an absent browser Origin (Sec-Fetch-Site present)", async () => {
h = await startHarness();
const t = await mintTicket("cli-1");
const ws = new WebSocket(wsUrl(h.port, { fn_token: DAEMON_TOKEN, sessionId: "cli-1", ticket: t }), {
headers: { "sec-fetch-site": "cross-site" },
});
const err = await new Promise<Error>((resolve) => ws.once("error", resolve));
expect(err.message).toMatch(/403/);
});
it("rejects a replayed / consumed ticket", async () => {
h = await startHarness();
const t = await mintTicket("cli-1");
const first = connect(h.port, { sessionId: "cli-1", ticket: t });
await nextMessage(first, (m) => m.type === "scrollback");
// Reuse the same ticket on a new connection — must be rejected.
const second = connect(h.port, { sessionId: "cli-1", ticket: t });
const closed = await waitClose(second);
expect(closed.code).toBe(4401);
first.close();
});
it("rejects a ticket for session A used to attach session B", async () => {
h = await startHarness({
sessions: [makeSession(), makeSession({ id: "cli-2", taskId: "FN-2" })],
});
const tA = await mintTicket("cli-1");
const ws = connect(h.port, { sessionId: "cli-2", ticket: tA });
const closed = await waitClose(ws);
expect(closed.code).toBe(4401);
});
it("rejects a cross-project session id (ticket project mismatch handled; unknown session)", async () => {
h = await startHarness();
const t = await mintTicket("cli-1");
const ws = connect(h.port, { sessionId: "cli-unknown", ticket: t });
const closed = await waitClose(ws);
expect(closed.code).toBe(4004);
});
it("late attacher gets scrollback then live with no duplication", async () => {
h = await startHarness();
h.manager.scrollbackById.set("cli-1", "PRIOR-OUTPUT");
const t = await mintTicket("cli-1");
const ws = connect(h.port, { sessionId: "cli-1", ticket: t });
const sb = await nextMessage(ws, (m) => m.type === "scrollback");
expect(decode(sb.data)).toBe("PRIOR-OUTPUT");
const live = nextMessage(ws, (m) => m.type === "data");
h.manager.broadcast("cli-1", "NEW");
expect(decode((await live).data)).toBe("NEW");
ws.close();
});
it("flow control: slow consumer (no acks) triggers pause at high watermark; acks resume at low watermark", async () => {
h = await startHarness({ highWatermarkBytes: 20, lowWatermarkBytes: 10 });
const t = await mintTicket("cli-1");
const ws = connect(h.port, { sessionId: "cli-1", ticket: t });
await nextMessage(ws, (m) => m.type === "scrollback");
// Push 25 bytes without acking — crosses the 20-byte high watermark.
const d1 = nextMessage(ws, (m) => m.type === "data");
h.manager.broadcast("cli-1", "x".repeat(25));
await d1;
await vi.waitFor(() => expect(h.manager.paused).toBe(1));
expect(h.manager.resumed).toBe(0);
// Ack 20 bytes — outstanding drops to 5 (<= low watermark 10) → resume.
ws.send(JSON.stringify({ type: "ack", bytes: 20 }));
await vi.waitFor(() => expect(h.manager.resumed).toBe(1));
ws.close();
});
it("resize is forwarded (latest-active-client) to the manager", async () => {
h = await startHarness();
const t = await mintTicket("cli-1");
const ws = connect(h.port, { sessionId: "cli-1", ticket: t });
await nextMessage(ws, (m) => m.type === "scrollback");
ws.send(JSON.stringify({ type: "resize", cols: 120, rows: 40 }));
await vi.waitFor(() =>
expect(h.manager.ptyResizes).toContainEqual({ cols: 120, rows: 40 }),
);
ws.close();
});
it("output hardening: OSC 52, OSC 8 javascript: link, and a DSR query are neutralized (incl. split across chunks)", async () => {
h = await startHarness();
const t = await mintTicket("cli-1");
const ws = connect(h.port, { sessionId: "cli-1", ticket: t });
await nextMessage(ws, (m) => m.type === "scrollback");
const ESC = "\x1b";
const BEL = "\x07";
// OSC 52 + OSC 8 js link + DSR — all in one chunk.
const d1 = nextMessage(ws, (m) => m.type === "data");
h.manager.broadcast(
"cli-1",
`A${ESC}]52;c;ZXZpbA==${BEL}${ESC}]8;;javascript:x${BEL}T${ESC}]8;;${BEL}${ESC}[6nB`,
);
const out1 = decode((await d1).data);
expect(out1).not.toContain("52;");
expect(out1).not.toContain("javascript:");
expect(out1).not.toContain(`${ESC}[6n`);
expect(out1).toContain("A");
expect(out1).toContain("T");
expect(out1).toContain("B");
// A sequence split across two broadcasts (chunks).
h.manager.broadcast("cli-1", `C${ESC}]52;c;ZXZ`);
h.manager.broadcast("cli-1", `pbA==${BEL}D`);
// Collect data frames until we see the trailing "D".
let collected = "";
await vi.waitFor(
() =>
new Promise<void>((resolve, reject) => {
const onMsg = (raw: Buffer) => {
const m = JSON.parse(raw.toString());
if (m.type === "data") collected += decode(m.data);
if (collected.includes("D")) {
ws.off("message", onMsg);
resolve();
}
};
ws.on("message", onMsg);
setTimeout(() => reject(new Error("timeout")), 1000);
}),
);
expect(collected).not.toContain("52;");
expect(collected).toContain("C");
expect(collected).toContain("D");
ws.close();
});
it("read-only session rejects input with an error frame (server-side)", async () => {
h = await startHarness({
sessions: [makeSession({ id: "cli-ro", purpose: "validator" })],
});
const t = await mintTicket("cli-ro");
const ws = connect(h.port, { sessionId: "cli-ro", ticket: t });
await nextMessage(ws, (m) => m.type === "scrollback");
const errFrame = nextMessage(ws, (m) => m.type === "error");
ws.send(JSON.stringify({ type: "input", data: "should-be-blocked" }));
const err = await errFrame;
expect(err.code).toBe("READ_ONLY");
expect(h.manager.ptyInput).not.toContain("should-be-blocked");
ws.close();
});
});
// ── SSE cli:session:state ─────────────────────────────────────────────────────
/** Minimal in-memory store satisfying what the state machine + bridge touch. */
function makeStateStore(session: CliSession) {
let current = { ...session };
return {
getSession: (id: string) => (id === current.id ? current : undefined),
listSessions: () => [current],
updateSession: (id: string, input: Partial<CliSession>) => {
if (id !== current.id) return undefined;
current = { ...current, ...input } as CliSession;
return current;
},
};
}
describe("cli:session:state SSE bridge", () => {
beforeEach(() => resetCliSessionStateBufferForTests());
it("forwards a state transition as one event carrying owning entity + redacted preview", () => {
const session = makeSession({
id: "s-1",
taskId: "FN-9",
projectId: "proj-a",
agentState: "starting",
});
const store = makeStateStore(session);
const machine = new CliSessionStateMachine({
sessionId: "s-1",
store: store as never,
});
const captured: { id: number; payload: any; projectId?: string }[] = [];
const unbridge = bridgeCliStateToSse(machine, {
store: store as never,
getRecentOutput: () => "bearer sk-secret-1234567890 \x1b[31mfoo\x1b[0m",
});
// Seed machine into busy via the legal path, then trigger a transition.
machine.markReady(); // starting → ready
machine.injectPrompt(); // ready → busy
const before = getCliSessionStateEventsSince(0).length;
machine.signalDone(); // busy → done
const events = getCliSessionStateEventsSince(0);
expect(events.length).toBeGreaterThan(before);
const last = events[events.length - 1];
expect(last.payload.sessionId).toBe("s-1");
expect(last.payload.taskId).toBe("FN-9");
expect(last.payload.state).toBe("done");
expect(last.projectId).toBe("proj-a");
// Preview is ANSI-stripped and bounded.
expect(last.payload.lastOutputPreview).not.toContain("\x1b");
unbridge();
void captured;
});
it("engine throttle coalesces rapid transitions into fewer emitted events", async () => {
const session = makeSession({ id: "s-2", projectId: "proj-a", agentState: "starting" });
const store = makeStateStore(session);
const machine = new CliSessionStateMachine({
sessionId: "s-2",
store: store as never,
stateChangeThrottleMs: 500,
});
bridgeCliStateToSse(machine, { store: store as never });
machine.markReady();
machine.injectPrompt();
// Rapid waiting↔busy churn within the throttle window.
machine.signalWaitingOnInput();
machine.signalBusy();
machine.signalWaitingOnInput();
machine.signalBusy();
const emitted = getCliSessionStateEventsSince(0).length;
// Leading-edge + coalesced trailing means far fewer than the ~6 raw transitions.
expect(emitted).toBeLessThan(6);
expect(emitted).toBeGreaterThanOrEqual(1);
});
it("getCliSessionStateEventsSince replays only transitions after a lastEventId", () => {
const a = emitCliSessionStateSseEvent(
{ sessionId: "x", taskId: null, chatSessionId: null, state: "ready", at: "t0" },
"proj-a",
);
const b = emitCliSessionStateSseEvent(
{ sessionId: "x", taskId: null, chatSessionId: null, state: "busy", at: "t1" },
"proj-a",
);
const replay = getCliSessionStateEventsSince(a);
expect(replay.map((e) => e.id)).toEqual([b]);
expect(replay[0].payload.state).toBe("busy");
});
});

View File

@@ -0,0 +1,211 @@
// @vitest-environment node
import express from "express";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { request } from "../test-request.js";
import type { CliSession } from "@fusion/core";
type App = (req: import("http").IncomingMessage, res: import("http").ServerResponse) => void;
function getJson(app: App, path: string) {
return request(app, "GET", path);
}
function postJson(app: App, path: string, body: unknown) {
return request(app, "POST", path, JSON.stringify(body), {
"content-type": "application/json",
});
}
import { createCliSessionsRouter } from "../routes/cli-sessions.js";
import {
AttachTicketStore,
CliInputAttributionLog,
CliConfirmAdvanceRegistry,
type CliSessionManagerLike,
} from "../cli-session-transport.js";
function makeSession(overrides: Partial<CliSession> = {}): CliSession {
return {
id: "cli-1",
taskId: "FN-1",
chatSessionId: null,
purpose: "execute",
projectId: "proj-a",
adapterId: "claude-code",
agentState: "busy",
terminationReason: null,
nativeSessionId: null,
resumeAttempts: 0,
autonomyPosture: null,
worktreePath: "/tmp/wt",
createdAt: "2026-06-04T00:00:00.000Z",
updatedAt: "2026-06-04T00:00:00.000Z",
...overrides,
};
}
function makeStore(sessions: CliSession[]) {
const map = new Map(sessions.map((s) => [s.id, s]));
return {
getSession: (id: string) => map.get(id),
listSessions: (opts?: {
projectId?: string;
taskId?: string;
chatSessionId?: string;
}) =>
[...map.values()].filter(
(s) =>
(opts?.projectId === undefined || s.projectId === opts.projectId) &&
(opts?.taskId === undefined || s.taskId === opts.taskId) &&
(opts?.chatSessionId === undefined || s.chatSessionId === opts.chatSessionId),
),
_map: map,
};
}
function buildApp(opts: {
store: ReturnType<typeof makeStore>;
manager: CliSessionManagerLike;
ticketStore: AttachTicketStore;
attributionLog: CliInputAttributionLog;
confirmAdvance: CliConfirmAdvanceRegistry;
}): express.Express {
const app = express();
app.use(express.json());
app.use(
"/api/cli-sessions",
createCliSessionsRouter({
store: opts.store,
manager: opts.manager,
ticketStore: opts.ticketStore,
attributionLog: opts.attributionLog,
confirmAdvance: opts.confirmAdvance,
}),
);
return app;
}
describe("cli-sessions routes", () => {
let store: ReturnType<typeof makeStore>;
let manager: CliSessionManagerLike;
let injectSpy: ReturnType<typeof vi.fn>;
let ticketStore: AttachTicketStore;
let attributionLog: CliInputAttributionLog;
let confirmAdvance: CliConfirmAdvanceRegistry;
let app: express.Express;
beforeEach(() => {
store = makeStore([
makeSession(),
makeSession({ id: "cli-2", projectId: "proj-b", taskId: "FN-2" }),
makeSession({ id: "cli-ro", purpose: "validator", projectId: "proj-a", taskId: "FN-3" }),
]);
injectSpy = vi.fn().mockResolvedValue(undefined);
manager = {
isLive: () => true,
attach: () => {
throw new Error("not used in route tests");
},
inject: injectSpy,
requestPause: vi.fn(),
requestResume: vi.fn(),
};
ticketStore = new AttachTicketStore();
attributionLog = new CliInputAttributionLog();
confirmAdvance = new CliConfirmAdvanceRegistry();
app = buildApp({ store, manager, ticketStore, attributionLog, confirmAdvance });
});
afterEach(() => {
vi.restoreAllMocks();
});
it("lists sessions filtered by project / task / chat", async () => {
const res = await getJson(app, "/api/cli-sessions?projectId=proj-a");
expect(res.status).toBe(200);
const ids = res.body.sessions.map((s: CliSession) => s.id).sort();
expect(ids).toEqual(["cli-1", "cli-ro"]);
const byTask = await getJson(app, "/api/cli-sessions?taskId=FN-2");
expect(byTask.body.sessions.map((s: CliSession) => s.id)).toEqual(["cli-2"]);
});
it("returns a single session record", async () => {
const res = await getJson(app, "/api/cli-sessions/cli-1");
expect(res.status).toBe(200);
expect(res.body.session.id).toBe("cli-1");
});
it("404s for unknown session", async () => {
const res = await getJson(app, "/api/cli-sessions/nope");
expect(res.status).toBe(404);
});
it("rejects cross-project access (project scope check)", async () => {
const res = await getJson(app, "/api/cli-sessions/cli-1?projectId=proj-b");
expect(res.status).toBe(403);
});
it("mints a single-use attach ticket with expiry", async () => {
const res = await postJson(app, "/api/cli-sessions/cli-1/attach-ticket", {});
expect(res.status).toBe(200);
expect(typeof res.body.ticket).toBe("string");
expect(res.body.ticket.length).toBeGreaterThan(20);
expect(new Date(res.body.expiresAt).getTime()).toBeGreaterThan(Date.now());
expect(res.body.readOnly).toBe(false);
// The ticket consumes exactly once and is bound to its session.
const entry = ticketStore.consume(res.body.ticket, "cli-1");
expect(entry).not.toBeNull();
expect(ticketStore.consume(res.body.ticket, "cli-1")).toBeNull(); // single-use
});
it("marks a read-only (validator) session's ticket as readOnly", async () => {
const res = await postJson(app, "/api/cli-sessions/cli-ro/attach-ticket", {});
expect(res.body.readOnly).toBe(true);
});
it("injects text and records attribution", async () => {
const res = await postJson(app, "/api/cli-sessions/cli-1/inject", { text: "hello agent" });
expect(res.status).toBe(200);
expect(injectSpy).toHaveBeenCalledWith("cli-1", "hello agent");
const log = attributionLog.list("cli-1");
expect(log).toHaveLength(1);
expect(log[0].source).toBe("inject");
});
it("rejects inject on a read-only session (server-side enforcement)", async () => {
const res = await postJson(app, "/api/cli-sessions/cli-ro/inject", { text: "nope" });
expect(res.status).toBe(403);
expect(injectSpy).not.toHaveBeenCalled();
});
it("rejects empty inject body", async () => {
const res = await postJson(app, "/api/cli-sessions/cli-1/inject", { text: "" });
expect(res.status).toBe(400);
});
it("409s inject when session is not live", async () => {
manager.isLive = () => false;
const res = await postJson(app, "/api/cli-sessions/cli-1/inject", { text: "hi" });
expect(res.status).toBe(409);
});
it("records a confirm-advance decision and emits an event", async () => {
const seen: string[] = [];
confirmAdvance.on((info) => seen.push(`${info.sessionId}:${info.decision}`));
const res = await postJson(app, "/api/cli-sessions/cli-1/confirm-advance", {
decision: "advance",
});
expect(res.status).toBe(200);
expect(res.body.decision).toBe("advance");
expect(confirmAdvance.getLatest("cli-1")).toBe("advance");
expect(seen).toContain("cli-1:advance");
});
it("rejects an invalid confirm-advance decision", async () => {
const res = await postJson(app, "/api/cli-sessions/cli-1/confirm-advance", {
decision: "maybe",
});
expect(res.status).toBe(400);
});
});

View File

@@ -0,0 +1,244 @@
/**
* cli-session-output-filter — outbound terminal-output hardening
* (CLI Agent Executor, U10).
*
* The CLI agent's terminal byte stream is UNTRUSTED: the agent (or anything it
* runs) controls the bytes, and a co-driving surface (browser xterm, TUI host
* TTY) honors escape sequences. Some sequences let an attacker exfiltrate or
* forge input, so the server-side WS bridge neutralizes them BEFORE forwarding
* a data frame. `neutralizeTerminalOutput` is the single, pure, streaming-safe
* implementation; the TUI (U14) re-imports it so every passthrough applies the
* identical set.
*
* The neutralized set (KTD — output hardening):
* - OSC 52 (clipboard write): `ESC ] 52 ; … BEL|ST` — a remote write to the
* user's clipboard. Stripped entirely.
* - OSC 8 hyperlinks with a non-http/https scheme (e.g. `javascript:`,
* `file:`): `ESC ] 8 ; params ; URI BEL|ST`. The URI is stripped (replaced
* with an empty URI so the hyperlink is closed/neutral) while the visible
* link TEXT that follows is kept. http/https links pass through untouched.
* - Device-status / query sequences whose auto-responses would forge INPUT back
* into the PTY: DSR (`ESC [ … n`), DA1/DA2 (`ESC [ c`, `ESC [ > c`), and
* DECRQSS (`ESC P $ q … ESC \`). A terminal answers these by writing bytes to
* stdin — which converge on the agent's input FIFO — so a malicious stream
* could smuggle keystrokes. Stripped.
*
* Streaming-safe: a sequence may be split across two chunks. The function takes
* (and returns) a small carry buffer holding a trailing partial sequence; the
* caller threads the carry across calls. `flush` emits any residual carry on
* stream end. The carry is bounded so a never-terminated sequence can't grow
* without limit.
*/
const ESC = "\x1b";
const BEL = "\x07";
const ST = "\x1b\\"; // String Terminator (ESC \)
/**
* Maximum bytes held in the carry buffer for an in-progress sequence. A
* sequence longer than this is almost certainly malformed/hostile (a real OSC /
* CSI is short); once exceeded we flush the carry as literal text rather than
* buffer unboundedly.
*/
export const MAX_CARRY_LENGTH = 8 * 1024;
/** Allowed URI schemes for OSC 8 hyperlinks. Everything else has its URI stripped. */
const SAFE_LINK_SCHEME = /^(https?):/i;
export interface NeutralizeResult {
/** The sanitized, safe-to-forward text. */
output: string;
/**
* Trailing bytes withheld because they may be the prefix of a sequence that
* continues in the next chunk. Pass this back in as the next call's `carry`.
*/
carry: string;
}
/**
* Does `text` (already known to start at an ESC) look like it COULD be the start
* of a longer sequence we care about, if more bytes arrive? Used to decide
* whether to withhold a trailing partial as carry. Returns true when the buffer
* is a strict, still-growing prefix of a recognized-but-unterminated sequence.
*/
function isIncompleteSequence(buf: string): boolean {
if (buf === ESC) return true; // lone ESC — could begin anything
// OSC: ESC ] … (terminated by BEL or ST). Incomplete until terminator seen.
if (buf.startsWith(`${ESC}]`)) {
return !buf.includes(BEL) && !buf.includes(ST);
}
// DCS (DECRQSS uses DCS): ESC P … ESC \ (terminated by ST).
if (buf.startsWith(`${ESC}P`)) {
return !buf.includes(ST);
}
// CSI: ESC [ params/intermediates, terminated by a final byte @-~.
if (buf.startsWith(`${ESC}[`)) {
// Final byte is in 0x40–0x7e. Incomplete while we've not seen one yet.
return !/[@-~]/.test(buf.slice(2));
}
// ESC followed by exactly nothing-meaningful-yet: ESC + a single byte that
// could still become "]" / "[" / "P". `${ESC}` alone handled above; a 2-char
// ESC + intermediate is its own complete 2-byte sequence, not incomplete.
if (buf.length === 1) return true;
return false;
}
/**
* Process a fully-terminated OSC body (the text between `ESC ]` and its
* terminator, terminator excluded). Returns the replacement text to emit in
* place of the WHOLE OSC sequence (including terminator handling done by the
* caller).
*/
function neutralizeOsc(body: string): string {
// OSC 52 (clipboard): drop entirely.
if (body.startsWith("52;") || body === "52") {
return "";
}
// OSC 8 hyperlink: ESC ] 8 ; params ; URI
if (body.startsWith("8;")) {
const rest = body.slice(2); // params ; URI
const sep = rest.indexOf(";");
if (sep === -1) {
// Malformed — strip the whole thing to be safe.
return "";
}
const params = rest.slice(0, sep);
const uri = rest.slice(sep + 1);
// The closing OSC 8 (empty URI) is always safe — keep it so link state is
// balanced.
if (uri === "") {
return `${ESC}]8;${params};${ST}`;
}
if (SAFE_LINK_SCHEME.test(uri)) {
// Safe scheme — pass the hyperlink through verbatim.
return `${ESC}]8;${params};${uri}${ST}`;
}
// Unsafe scheme — strip the URI (emit an opening link with empty URI so the
// following visible text still renders, just not as a live link).
return `${ESC}]8;${params};${ST}`;
}
// Any other OSC — pass through verbatim (re-add ST terminator).
return `${ESC}]${body}${ST}`;
}
/**
* Is this a CSI device-status / query sequence whose auto-response forges input?
* `seq` is the full CSI starting at `ESC [` and ending at its final byte.
*/
function isForgingQueryCsi(seq: string): boolean {
const body = seq.slice(2); // params + intermediates + final
const final = body.slice(-1);
const params = body.slice(0, -1);
// DSR — Device Status Report: ESC [ … n (e.g. 5n, 6n cursor-position report).
if (final === "n") return true;
// DA1 — Primary Device Attributes: ESC [ c or ESC [ 0 c
if (final === "c" && !params.startsWith(">") && !params.startsWith("=")) return true;
// DA2/DA3 — Secondary/Tertiary DA: ESC [ > c , ESC [ = c
if (final === "c" && (params.startsWith(">") || params.startsWith("="))) return true;
return false;
}
/**
* Neutralize one chunk of (possibly mid-sequence) terminal output, threading a
* carry buffer so sequences split across chunks are handled. Pure: no I/O, no
* shared state.
*/
export function neutralizeTerminalOutput(chunk: string, carry = ""): NeutralizeResult {
let input = carry + chunk;
let out = "";
let i = 0;
while (i < input.length) {
const ch = input[i];
if (ch !== ESC) {
out += ch;
i += 1;
continue;
}
// We're at an ESC. Examine the remainder.
const rest = input.slice(i);
// ── OSC: ESC ] … BEL|ST ──
if (rest.startsWith(`${ESC}]`)) {
const belIdx = rest.indexOf(BEL);
const stIdx = rest.indexOf(ST);
let endIdx = -1;
let termLen = 0;
if (belIdx !== -1 && (stIdx === -1 || belIdx < stIdx)) {
endIdx = belIdx;
termLen = BEL.length;
} else if (stIdx !== -1) {
endIdx = stIdx;
termLen = ST.length;
}
if (endIdx === -1) {
// Unterminated — withhold as carry (bounded).
break;
}
const body = rest.slice(2, endIdx);
out += neutralizeOsc(body);
i += endIdx + termLen;
continue;
}
// ── DCS (DECRQSS): ESC P … ESC \ ──
if (rest.startsWith(`${ESC}P`)) {
const stIdx = rest.indexOf(ST, 2);
if (stIdx === -1) {
break; // unterminated — carry
}
// DECRQSS is a query (ESC P $ q …). Strip the whole DCS.
i += stIdx + ST.length;
continue;
}
// ── CSI: ESC [ … final(@-~) ──
if (rest.startsWith(`${ESC}[`)) {
const finalMatch = rest.slice(2).search(/[@-~]/);
if (finalMatch === -1) {
break; // unterminated — carry
}
const seq = rest.slice(0, 2 + finalMatch + 1);
if (isForgingQueryCsi(seq)) {
// Strip the query.
} else {
out += seq;
}
i += seq.length;
continue;
}
// ── Lone ESC at end of buffer: could begin a sequence next chunk. ──
if (rest.length === 1) {
break; // carry the ESC
}
// ── Some other 2-byte ESC sequence (ESC <byte>) — pass through. ──
out += rest.slice(0, 2);
i += 2;
}
let newCarry = input.slice(i);
// Bound the carry: if a "sequence" never terminates, don't buffer forever —
// flush it as literal output (it isn't a recognized hazard if it's this long).
if (newCarry.length > MAX_CARRY_LENGTH) {
out += newCarry;
newCarry = "";
} else if (newCarry.length > 0 && !isIncompleteSequence(newCarry)) {
// The residual isn't actually a growing prefix — emit it.
out += newCarry;
newCarry = "";
}
return { output: out, carry: newCarry };
}
/**
* Flush a residual carry at stream end. The held bytes were an unterminated
* sequence; emit them as literal text (we never got a terminator, so there's no
* safe interpretation to apply — but withholding forever would lose output).
*/
export function flushTerminalOutput(carry: string): string {
return carry;
}

View File

@@ -0,0 +1,343 @@
/**
* cli-session-transport — shared transport primitives for CLI agent sessions
* (CLI Agent Executor, U10).
*
* Holds the pieces the REST router (cli-sessions.ts) and the WS attach handler
* (server.ts wiring) both depend on:
* - the narrow manager/store/hub interfaces the transport needs (so tests can
* supply a fake CliSessionManager without a real PTY),
* - the short-lived, single-use, session-scoped ATTACH TICKET store,
* - input-source ATTRIBUTION bookkeeping (ticket id → session input log),
* - the generic-tier CONFIRM-ADVANCE flag/event seam,
* - the ORIGIN allowlist check for the WS upgrade.
*
* Attach auth (KTD): the long-lived daemon token alone never authorizes PTY
* WRITE access. A surface must first call the authenticated attach-ticket route
* (daemon-token gated), then present the single-use ticket on the WS upgrade —
* which ALSO re-checks the daemon token and an Origin allowlist. Keystroke
* injection into a privileged agent PTY warrants the stronger posture.
*/
import { randomBytes } from "node:crypto";
import type { CliSession, CliSessionStore } from "@fusion/core";
import {
stripAnsiControl,
type CliSessionAttachment,
type CliStateChange,
} from "@fusion/engine";
import { emitCliSessionStateSseEvent } from "./sse.js";
// ── Narrow interfaces the transport needs ────────────────────────────────────
/**
* The subset of the engine `CliSessionManager` the transport touches. The real
* manager satisfies this; WS tests supply a fake (no PTY) implementing exactly
* these members.
*/
export interface CliSessionManagerLike {
/** Whether a session id is currently live (attachable). */
isLive(sessionId: string): boolean;
/** Attach a client: scrollback + live stream + write/resize/detach. */
attach(sessionId: string): CliSessionAttachment;
/** Inject a composed/engine prompt (neutralized) onto the shared FIFO. */
inject(sessionId: string, text: string): Promise<void>;
/** High-watermark backpressure: pause the PTY. */
requestPause(sessionId: string): void;
/** Low-watermark backpressure release: resume the PTY. */
requestResume(sessionId: string): void;
}
/** Dependencies the transport binds to (engine-owned, supplied at setup). */
export interface CliSessionTransportDeps {
manager: CliSessionManagerLike;
store: Pick<CliSessionStore, "getSession" | "listSessions">;
}
// ── Attach tickets ───────────────────────────────────────────────────────────
/** Default attach-ticket TTL (ms). Short-lived: enough to open a WS, no more. */
export const DEFAULT_ATTACH_TICKET_TTL_MS = 60_000;
interface AttachTicketEntry {
ticket: string;
sessionId: string;
projectId: string;
/** Whether write (input/inject over WS) is permitted (read-only sessions: false). */
readOnly: boolean;
expiresAt: number;
consumed: boolean;
}
/**
* In-memory, single-use, session-scoped attach-ticket store. A ticket is minted
* by the authenticated REST route, then consumed exactly once on the WS upgrade.
* Expired/consumed tickets never validate, and a ticket for session A can never
* attach session B (the session id is bound into the ticket).
*/
export class AttachTicketStore {
private readonly tickets = new Map<string, AttachTicketEntry>();
private readonly ttlMs: number;
private readonly now: () => number;
constructor(opts?: { ttlMs?: number; now?: () => number }) {
this.ttlMs = opts?.ttlMs ?? DEFAULT_ATTACH_TICKET_TTL_MS;
this.now = opts?.now ?? (() => Date.now());
}
/** Mint a ticket for a session. Returns the opaque ticket + its expiry. */
mint(input: {
sessionId: string;
projectId: string;
readOnly: boolean;
}): { ticket: string; expiresAt: number } {
const ticket = randomBytes(24).toString("hex");
const expiresAt = this.now() + this.ttlMs;
this.tickets.set(ticket, {
ticket,
sessionId: input.sessionId,
projectId: input.projectId,
readOnly: input.readOnly,
expiresAt,
consumed: false,
});
return { ticket, expiresAt };
}
/**
* Consume a ticket for a specific session (single-use). Returns the entry on
* success, or null when: unknown, already consumed, expired, or bound to a
* DIFFERENT session than `sessionId`.
*/
consume(ticket: string | null | undefined, sessionId: string): AttachTicketEntry | null {
if (!ticket) return null;
const entry = this.tickets.get(ticket);
if (!entry) return null;
if (entry.consumed) return null;
if (this.now() > entry.expiresAt) {
this.tickets.delete(ticket);
return null;
}
if (entry.sessionId !== sessionId) return null;
entry.consumed = true;
// Keep briefly for attribution, but it can never be reused.
return entry;
}
/** Sweep expired tickets (best-effort housekeeping). */
sweepExpired(): void {
const now = this.now();
for (const [ticket, entry] of this.tickets) {
if (now > entry.expiresAt) this.tickets.delete(ticket);
}
}
}
// ── Input-source attribution ─────────────────────────────────────────────────
export interface CliInputAttributionEntry {
/** The ticket id the input arrived under (the accountability floor in v1). */
ticketId: string;
/** "ws" for WS input frames, "inject" for the REST inject route. */
source: "ws" | "inject";
/** Byte length of the input (not the content — content is not retained). */
byteLength: number;
at: string;
}
/**
* In-memory per-session input-attribution log. v1 has no per-user arbitration,
* so logging which ticket each input frame arrived under is the accountability
* floor for post-incident attribution.
*/
export class CliInputAttributionLog {
private readonly bySession = new Map<string, CliInputAttributionEntry[]>();
private readonly cap: number;
constructor(opts?: { capPerSession?: number }) {
this.cap = opts?.capPerSession ?? 1000;
}
record(sessionId: string, entry: CliInputAttributionEntry): void {
let list = this.bySession.get(sessionId);
if (!list) {
list = [];
this.bySession.set(sessionId, list);
}
list.push(entry);
if (list.length > this.cap) list.splice(0, list.length - this.cap);
}
list(sessionId: string): CliInputAttributionEntry[] {
return [...(this.bySession.get(sessionId) ?? [])];
}
}
// ── Confirm-advance (generic-tier R20 affordance) ────────────────────────────
export type CliConfirmAdvanceListener = (info: {
sessionId: string;
projectId: string;
decision: "advance" | "not-yet";
}) => void;
/**
* The generic-tier "this session looks idle — advance to review?" affordance.
* The engine pipeline layer acts on the event later; for now the transport
* persists the latest decision per session and emits to subscribers (the engine
* seam wires a listener in a later unit).
*/
export class CliConfirmAdvanceRegistry {
private readonly latest = new Map<string, "advance" | "not-yet">();
private readonly listeners = new Set<CliConfirmAdvanceListener>();
on(listener: CliConfirmAdvanceListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
record(sessionId: string, projectId: string, decision: "advance" | "not-yet"): void {
this.latest.set(sessionId, decision);
for (const listener of this.listeners) {
listener({ sessionId, projectId, decision });
}
}
getLatest(sessionId: string): "advance" | "not-yet" | undefined {
return this.latest.get(sessionId);
}
}
// ── Read-only enforcement ────────────────────────────────────────────────────
/**
* Whether a session is read-only (one-shot validator/planning, U9). Server-side
* enforcement (not just client) — input/inject is rejected for these.
*
* U9's one-shot sessions are not yet wired, so the current signal is the
* autonomy posture `readOnly` flag (forward-compatible) plus validator/planning
* purposes which are inherently read-only.
*/
export function isReadOnlySession(session: CliSession): boolean {
if (session.autonomyPosture && session.autonomyPosture.readOnly === true) return true;
return session.purpose === "validator" || session.purpose === "planning";
}
// ── Origin allowlist ─────────────────────────────────────────────────────────
/**
* Origin allowlist check for the WS upgrade. Rejects a foreign or absent Origin
* (a privileged keystroke channel must not be CSRF-attachable from another
* page). Allowed:
* - no Origin header AND not a browser context (native clients: TUI, our own WS
* client set no Origin) — these are allowed because they aren't subject to the
* browser same-origin model and authenticate via the daemon token + ticket.
* We detect "browser context" by the presence of `Sec-Fetch-Site` / a
* `User-Agent` claiming a browser; absent those, an absent Origin is a native
* client.
* - an Origin whose host matches the request Host (same-host), or
* - an Origin in the configured extras allowlist.
*
* Per the plan, a FOREIGN or ABSENT Origin from a browser is rejected.
*/
export interface OriginCheckInput {
origin: string | undefined;
host: string | undefined;
/** Sec-Fetch-Site header (present on browser-issued requests). */
secFetchSite?: string | undefined;
/** Extra allowed origins (exact, scheme+host[:port]) from config. */
extraAllowedOrigins?: string[];
}
// ── SSE state bridge ─────────────────────────────────────────────────────────
/** Default cap (chars) on the SSE last-output preview. */
export const DEFAULT_OUTPUT_PREVIEW_CHARS = 200;
/**
* Bound + sanitize a last-output preview for the SSE `cli:session:state` event:
* ANSI/control-stripped, redacted, and capped (~200 chars). The text is already
* expected to be recent output (e.g. the scrollback tail); we strip first, then
* the engine's redaction is applied by the supplier — but we strip here defensively.
*/
export function buildOutputPreview(
raw: string | undefined,
maxChars = DEFAULT_OUTPUT_PREVIEW_CHARS,
): string | undefined {
if (!raw) return undefined;
const stripped = stripAnsiControl(raw).replace(/\s+/g, " ").trim();
if (stripped.length === 0) return undefined;
return stripped.length > maxChars ? stripped.slice(-maxChars) : stripped;
}
/**
* Subscribe to an engine state machine's throttled `onStateChange` and forward
* each transition onto the SSE bus as a `cli:session:state` event. The engine
* applies the ~500ms throttle (via `stateChangeThrottleMs`); this bridge only
* shapes the payload (owning entity + bounded redacted preview) and routes it
* project-scoped.
*
* Returns an unsubscribe handle.
*/
export function bridgeCliStateToSse(
machine: { onStateChange(listener: (change: CliStateChange) => void): () => void },
deps: {
store: Pick<CliSessionStore, "getSession">;
/** Recent (raw) output supplier for the bounded preview (e.g. scrollback tail). */
getRecentOutput?: (sessionId: string) => string | undefined;
},
): () => void {
return machine.onStateChange((change) => {
const session = deps.store.getSession(change.sessionId);
const preview = buildOutputPreview(deps.getRecentOutput?.(change.sessionId));
emitCliSessionStateSseEvent(
{
sessionId: change.sessionId,
taskId: session?.taskId ?? null,
chatSessionId: session?.chatSessionId ?? null,
state: change.state,
terminationReason: change.terminationReason,
lastOutputPreview: preview,
at: change.at,
},
session?.projectId,
);
});
}
export function isOriginAllowed(input: OriginCheckInput): boolean {
const { origin, host, secFetchSite, extraAllowedOrigins } = input;
// No Origin header.
if (!origin) {
// A browser ALWAYS sends Origin on a cross-site WS and sets Sec-Fetch-Site;
// an absent Origin together with a browser signal is suspicious → reject.
if (secFetchSite && secFetchSite !== "none" && secFetchSite !== "same-origin") {
return false;
}
// Native client (TUI / our WS client) — allowed (token + ticket gated).
return true;
}
// Parse the Origin.
let originUrl: URL;
try {
originUrl = new URL(origin);
} catch {
return false; // malformed Origin → reject
}
// Same-host: the Origin's host:port matches the request Host header.
if (host && originUrl.host === host) {
return true;
}
// Extras allowlist (exact scheme+host[:port]).
if (extraAllowedOrigins && extraAllowedOrigins.length > 0) {
const normalized = `${originUrl.protocol}//${originUrl.host}`;
if (extraAllowedOrigins.some((o) => o === normalized || o === origin)) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,325 @@
/**
* cli-session-ws — distinct WebSocket attach handler for CLI agent sessions
* (CLI Agent Executor, U10).
*
* This is a SEPARATE connection handler from the existing terminal WS
* (`/api/terminal/ws`): it shares only the upgrade gate shape (daemon-token
* auth) and the JSON frame protocol style. The connection body resolves the
* session from the engine's `CliSessionManager` (the explicit async attach
* interface), never the dashboard-local terminal service.
*
* Path: /api/cli-sessions/ws?sessionId=<id>&ticket=<ticket>[&fn_token=<token>]
*
* Upgrade gate (stronger than the terminal WS — this channel injects keystrokes
* into privileged agent PTYs):
* 1. daemon-token auth (authenticateUpgradeRequest), AND
* 2. an Origin allowlist check (reject foreign/absent browser Origin), AND
* 3. a valid, unconsumed, single-use ticket bound to this exact session.
*
* Frame protocol (JSON, mirrors the terminal WS shape):
* server→client: {type:"scrollback", data} (base64 bytes) — once, on connect
* {type:"data", data} (base64 bytes) — live, neutralized
* {type:"state", ...} (optional state hints)
* {type:"error", message} (e.g. read-only input rejected)
* client→server: {type:"input", data} (utf8 keystrokes → PTY write)
* {type:"resize", cols, rows} (latest-active-client resize)
* {type:"ack", bytes} (ACK-credit flow control)
*
* Flow control: the client ACKs bytes it has consumed; the server tracks
* outstanding unacked bytes and, above the high watermark, calls
* `manager.requestPause`; back below the low watermark, `manager.requestResume`.
* The server never buffers PTY bytes — the engine owns the ring + pause/resume.
*
* Output hardening: every live `data` frame is passed through
* `neutralizeTerminalOutput` (carry threaded across chunks) BEFORE send.
*/
import { WebSocketServer, type WebSocket } from "ws";
import type { IncomingMessage, Server as HttpServer } from "node:http";
import { authenticateUpgradeRequest } from "./auth-middleware.js";
import { neutralizeTerminalOutput, flushTerminalOutput } from "./cli-session-output-filter.js";
import {
isOriginAllowed,
isReadOnlySession,
type AttachTicketStore,
type CliInputAttributionLog,
type CliSessionTransportDeps,
} from "./cli-session-transport.js";
/** Default high/low watermark (bytes) for ACK-credit backpressure. */
export const DEFAULT_HIGH_WATERMARK_BYTES = 128 * 1024;
export const DEFAULT_LOW_WATERMARK_BYTES = 16 * 1024;
export const CLI_SESSION_WS_PATH = "/api/cli-sessions/ws";
export interface CliSessionWebSocketOptions extends CliSessionTransportDeps {
ticketStore: AttachTicketStore;
attributionLog: CliInputAttributionLog;
/** Daemon token; when set (and not noAuth) the upgrade requires it. */
daemonToken?: string;
noAuth?: boolean;
/** Extra allowed WS Origins (scheme+host[:port]). */
extraAllowedOrigins?: string[];
highWatermarkBytes?: number;
lowWatermarkBytes?: number;
}
/**
* Attach the CLI-session WebSocket handler to an HTTP server. Adds its OWN
* `upgrade` listener filtered to the cli-sessions path, so it coexists with the
* terminal/badge WS upgrade listeners (each ignores non-matching paths).
*/
export function setupCliSessionWebSocket(
server: HttpServer,
options: CliSessionWebSocketOptions,
): WebSocketServer {
const wss = new WebSocketServer({ noServer: true });
const highWatermark = options.highWatermarkBytes ?? DEFAULT_HIGH_WATERMARK_BYTES;
const lowWatermark = options.lowWatermarkBytes ?? DEFAULT_LOW_WATERMARK_BYTES;
server.on("upgrade", (req, socket, head) => {
const pathname = new URL(req.url || "", `http://${req.headers.host}`).pathname;
if (pathname !== CLI_SESSION_WS_PATH) return;
const reject = (code: number, reason: string) => {
socket.write(`HTTP/1.1 ${code} ${reason}\r\nConnection: close\r\n\r\n`);
socket.destroy();
};
// 1. Daemon-token auth at the upgrade.
if (options.daemonToken && !options.noAuth) {
if (!authenticateUpgradeRequest(options.daemonToken, req)) {
reject(401, "Unauthorized");
return;
}
}
// 2. Origin allowlist.
const originOk = isOriginAllowed({
origin: headerStr(req, "origin"),
host: headerStr(req, "host"),
secFetchSite: headerStr(req, "sec-fetch-site"),
extraAllowedOrigins: options.extraAllowedOrigins,
});
if (!originOk) {
reject(403, "Forbidden");
return;
}
wss.handleUpgrade(req, socket, head, (upgraded) => {
wss.emit("connection", upgraded, req);
});
});
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
const url = new URL(req.url || "", `http://${req.headers.host}`);
const sessionId = url.searchParams.get("sessionId");
const ticket = url.searchParams.get("ticket");
if (!sessionId) {
ws.close(4000, "Missing sessionId");
return;
}
const session = options.store.getSession(sessionId);
if (!session) {
ws.close(4004, "Session not found");
return;
}
// 3. Single-use, session-scoped ticket. Consume here (after upgrade) so a
// replayed/consumed ticket, or a ticket for a different session, is rejected.
const consumed = options.ticketStore.consume(ticket, sessionId);
if (!consumed) {
ws.close(4401, "Invalid or expired attach ticket");
return;
}
if (consumed.projectId !== session.projectId) {
ws.close(4503, "Ticket does not match session project");
return;
}
if (!options.manager.isLive(sessionId)) {
ws.close(4409, "Session is not live");
return;
}
const readOnly = isReadOnlySession(session) || consumed.readOnly;
const ticketId = consumed.ticket;
// Attach to the engine manager.
let attachment;
try {
attachment = options.manager.attach(sessionId);
} catch {
ws.close(4500, "Failed to attach");
return;
}
// ── Flow control state ──
let unacked = 0;
let paused = false;
const onSentBytes = (n: number) => {
unacked += n;
if (!paused && unacked >= highWatermark) {
paused = true;
try {
options.manager.requestPause(sessionId);
} catch {
/* ignore */
}
}
};
const onAck = (n: number) => {
if (!Number.isFinite(n) || n <= 0) return;
unacked = Math.max(0, unacked - n);
if (paused && unacked <= lowWatermark) {
paused = false;
try {
options.manager.requestResume(sessionId);
} catch {
/* ignore */
}
}
};
// ── Outbound: scrollback (neutralized) then live stream (neutralized) ──
let carry = "";
const sendData = (bytes: Uint8Array) => {
if (ws.readyState !== ws.OPEN) return;
const text = Buffer.from(bytes).toString("utf8");
const result = neutralizeTerminalOutput(text, carry);
carry = result.carry;
if (result.output.length === 0) return;
const payload = Buffer.from(result.output, "utf8");
try {
ws.send(
JSON.stringify({ type: "data", data: payload.toString("base64") }),
);
onSentBytes(payload.byteLength);
} catch {
/* socket closing */
}
};
// Scrollback replay (run through the same neutralizer so a hostile sequence
// recorded in scrollback is also stripped). Sent as its own frame so the
// client can clear before replay.
{
const scrollText = Buffer.from(attachment.scrollback).toString("utf8");
const result = neutralizeTerminalOutput(scrollText, "");
// Flush carry into the scrollback frame (replay is a complete snapshot).
const full = result.output + flushTerminalOutput(result.carry);
try {
ws.send(
JSON.stringify({
type: "scrollback",
data: Buffer.from(full, "utf8").toString("base64"),
}),
);
} catch {
/* ignore */
}
}
ws.send(JSON.stringify({ type: "state", state: session.agentState, readOnly }));
// Pump the live byte stream.
let streamClosed = false;
(async () => {
try {
for await (const chunk of attachment.stream) {
if (streamClosed || ws.readyState !== ws.OPEN) break;
sendData(chunk);
}
} catch {
/* stream error — close below */
}
if (!streamClosed && ws.readyState === ws.OPEN) {
try {
ws.send(JSON.stringify({ type: "exit" }));
} catch {
/* ignore */
}
}
})();
// ── Inbound frames ──
ws.on("message", (raw: Buffer) => {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(raw.toString());
} catch {
return; // ignore malformed
}
switch (msg.type) {
case "input": {
if (typeof msg.data !== "string") return;
if (readOnly) {
try {
ws.send(
JSON.stringify({
type: "error",
code: "READ_ONLY",
message: "Session is read-only — input is not permitted",
}),
);
} catch {
/* ignore */
}
return;
}
attachment.write(msg.data);
options.attributionLog.record(sessionId, {
ticketId,
source: "ws",
byteLength: Buffer.byteLength(msg.data, "utf8"),
at: new Date().toISOString(),
});
break;
}
case "resize": {
if (typeof msg.cols === "number" && typeof msg.rows === "number") {
// Latest-active-client policy is enforced by the manager (latest wins).
attachment.resize(msg.cols, msg.rows);
}
break;
}
case "ack": {
if (typeof msg.bytes === "number") onAck(msg.bytes);
break;
}
}
});
const teardown = () => {
if (streamClosed) return;
streamClosed = true;
try {
attachment.detach(); // NEVER kills the session (other clients keep it).
} catch {
/* ignore */
}
// Release backpressure so a remaining client isn't stuck paused.
if (paused) {
try {
options.manager.requestResume(sessionId);
} catch {
/* ignore */
}
}
};
ws.on("close", teardown);
ws.on("error", teardown);
});
return wss;
}
function headerStr(req: IncomingMessage, name: string): string | undefined {
const v = req.headers[name];
if (typeof v === "string") return v;
if (Array.isArray(v)) return v[0];
return undefined;
}

View File

@@ -0,0 +1,162 @@
/**
* cli-sessions routes — authenticated REST surface for CLI agent sessions
* (CLI Agent Executor, U10).
*
* Daemon-token gated like every other /api route (the caller mounts this router
* behind the auth middleware). Routes:
* - GET /api/cli-sessions list (by project/task/chat)
* - GET /api/cli-sessions/:id one session record
* - POST /api/cli-sessions/:id/attach-ticket mint a short-lived single-use
* session-scoped attach ticket
* - POST /api/cli-sessions/:id/inject inject text onto the session FIFO
* - POST /api/cli-sessions/:id/confirm-advance generic-tier R20 affordance
*
* Attach tickets (KTD — attach auth): the long-lived daemon token never
* authorizes PTY write access by itself. A surface mints a ticket here (gated by
* the daemon token), then presents it on the WS upgrade (which re-checks the
* token AND an Origin allowlist). Tickets are single-use, ~60s TTL, and bound to
* their session id.
*/
import { Router, type Request, type Response } from "express";
import { badRequest, notFound, ApiError, catchHandler } from "../api-error.js";
import {
type AttachTicketStore,
type CliInputAttributionLog,
type CliConfirmAdvanceRegistry,
type CliSessionTransportDeps,
isReadOnlySession,
} from "../cli-session-transport.js";
export interface CliSessionRoutesOptions extends CliSessionTransportDeps {
ticketStore: AttachTicketStore;
attributionLog: CliInputAttributionLog;
confirmAdvance: CliConfirmAdvanceRegistry;
/** Max inject body length (chars). Bounds a hostile body. */
maxInjectChars?: number;
}
const DEFAULT_MAX_INJECT_CHARS = 64 * 1024;
/** Coerce a route param (may be string | string[]) to a single string. */
function paramId(value: unknown): string {
return Array.isArray(value) ? String(value[0]) : String(value);
}
/** Optional project scoping: when a `projectId` is provided it must match. */
function assertProjectScope(sessionProjectId: string, requested: unknown): void {
if (requested === undefined || requested === null || requested === "") return;
if (typeof requested !== "string" || requested !== sessionProjectId) {
// Cross-project access is a hard rejection (the session id is not enough).
throw new ApiError(403, "Session does not belong to this project");
}
}
export function createCliSessionsRouter(options: CliSessionRoutesOptions): Router {
const { manager, store, ticketStore, attributionLog, confirmAdvance } = options;
const maxInjectChars = options.maxInjectChars ?? DEFAULT_MAX_INJECT_CHARS;
const router = Router();
// ── List ────────────────────────────────────────────────────────────────
router.get(
"/",
catchHandler(async (req: Request, res: Response) => {
const { projectId, taskId, chatSessionId } = req.query;
const sessions = store.listSessions({
projectId: typeof projectId === "string" ? projectId : undefined,
taskId: typeof taskId === "string" ? taskId : undefined,
chatSessionId: typeof chatSessionId === "string" ? chatSessionId : undefined,
});
res.json({ sessions });
}),
);
// ── One ─────────────────────────────────────────────────────────────────
router.get(
"/:id",
catchHandler(async (req: Request, res: Response) => {
const session = store.getSession(paramId(req.params.id));
if (!session) throw notFound("Session not found");
assertProjectScope(session.projectId, req.query.projectId);
res.json({ session });
}),
);
// ── Attach ticket ─────────────────────────────────────────────────────────
router.post(
"/:id/attach-ticket",
catchHandler(async (req: Request, res: Response) => {
const session = store.getSession(paramId(req.params.id));
if (!session) throw notFound("Session not found");
assertProjectScope(session.projectId, req.body?.projectId ?? req.query.projectId);
const readOnly = isReadOnlySession(session);
const { ticket, expiresAt } = ticketStore.mint({
sessionId: session.id,
projectId: session.projectId,
readOnly,
});
res.json({
ticket,
expiresAt: new Date(expiresAt).toISOString(),
readOnly,
});
}),
);
// ── Inject ────────────────────────────────────────────────────────────────
router.post(
"/:id/inject",
catchHandler(async (req: Request, res: Response) => {
const session = store.getSession(paramId(req.params.id));
if (!session) throw notFound("Session not found");
assertProjectScope(session.projectId, req.body?.projectId ?? req.query.projectId);
const text = req.body?.text;
if (typeof text !== "string" || text.length === 0) {
throw badRequest("Missing or empty `text`");
}
if (text.length > maxInjectChars) {
throw badRequest(`\`text\` exceeds max length (${maxInjectChars})`);
}
// Server-side read-only enforcement (not just client).
if (isReadOnlySession(session)) {
throw new ApiError(403, "Session is read-only — input is not permitted");
}
if (!manager.isLive(session.id)) {
throw new ApiError(409, "Session is not live");
}
await manager.inject(session.id, text);
// Attribution: record the input source on the session's input log. The
// REST inject path has no attach ticket; attribute it to the daemon token.
attributionLog.record(session.id, {
ticketId: "rest:inject",
source: "inject",
byteLength: Buffer.byteLength(text, "utf8"),
at: new Date().toISOString(),
});
res.json({ ok: true });
}),
);
// ── Confirm-advance (generic-tier R20 affordance) ──────────────────────────
router.post(
"/:id/confirm-advance",
catchHandler(async (req: Request, res: Response) => {
const session = store.getSession(paramId(req.params.id));
if (!session) throw notFound("Session not found");
assertProjectScope(session.projectId, req.body?.projectId ?? req.query.projectId);
const decisionRaw = req.body?.decision ?? "advance";
if (decisionRaw !== "advance" && decisionRaw !== "not-yet") {
throw badRequest("`decision` must be 'advance' or 'not-yet'");
}
confirmAdvance.record(session.id, session.projectId, decisionRaw);
res.json({ ok: true, decision: decisionRaw });
}),
);
return router;
}

View File

@@ -59,6 +59,8 @@ import { ChatManager } from "./chat.js";
import { stopAllDevServers } from "./dev-server-routes.js";
import type { SkillsAdapter } from "./skills-adapter.js";
import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js";
import { setupCliSessionWebSocket } from "./cli-session-ws.js";
import { createCliSessionsRouter } from "./routes/cli-sessions.js";
import { validateRemoteAuthToken } from "./remote-auth.js";
import { getCliPackageVersion } from "./cli-package-version.js";
import {
@@ -235,6 +237,19 @@ export interface ServerOptions {
};
/** Optional AiSessionStore — if not provided, one is created from the default store's database */
aiSessionStore?: AiSessionStore;
/**
* Optional CLI agent session transport dependencies (CLI Agent Executor, U10).
* When provided, the server mounts the cli-sessions REST routes and the
* distinct `/api/cli-sessions/ws` attach handler. Wiring the engine-owned
* CliSessionManager/store into this dep happens in a later unit; until then
* the transport is inert unless explicitly supplied (e.g. in tests).
*/
cliSessionTransport?: import("./cli-session-transport.js").CliSessionTransportDeps & {
ticketStore: import("./cli-session-transport.js").AttachTicketStore;
attributionLog: import("./cli-session-transport.js").CliInputAttributionLog;
confirmAdvance: import("./cli-session-transport.js").CliConfirmAdvanceRegistry;
extraAllowedOrigins?: string[];
};
/** Optional MissionAutopilot for autonomous mission progression */
missionAutopilot?: {
watchMission(missionId: string): void;
@@ -1447,6 +1462,21 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
});
app.use("/api", apiRouter);
// CLI agent session REST routes (U10). Daemon-token gated by the app-level
// auth middleware. Mounted only when transport deps are supplied.
if (options?.cliSessionTransport) {
app.use(
"/api/cli-sessions",
createCliSessionsRouter({
manager: options.cliSessionTransport.manager,
store: options.cliSessionTransport.store,
ticketStore: options.cliSessionTransport.ticketStore,
attributionLog: options.cliSessionTransport.attributionLog,
confirmAdvance: options.cliSessionTransport.confirmAdvance,
}),
);
}
// API 404 Handler - Return JSON for unmatched API routes (instead of falling through to SPA)
app.use("/api", (_req: express.Request, res: express.Response) => {
sendErrorResponse(res, 404, "Not found");
@@ -1560,6 +1590,20 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
const websocketOptions = { ...options, runtimeLogger };
setupTerminalWebSocket(dashboardApp, server as HttpServer, store, websocketOptions);
setupBadgeWebSocket(dashboardApp, server as HttpServer, store, websocketOptions);
// CLI agent session attach WS (U10) — distinct handler, shares only the
// upgrade-gate shape with the terminal WS. Mounted only when transport
// deps are supplied (engine wiring lands in a later unit).
if (options?.cliSessionTransport) {
setupCliSessionWebSocket(server as HttpServer, {
manager: options.cliSessionTransport.manager,
store: options.cliSessionTransport.store,
ticketStore: options.cliSessionTransport.ticketStore,
attributionLog: options.cliSessionTransport.attributionLog,
daemonToken: getDaemonToken(options),
noAuth: options?.noAuth,
extraAllowedOrigins: options.cliSessionTransport.extraAllowedOrigins,
});
}
}
return server as HttpServer;

View File

@@ -281,6 +281,72 @@ export function emitPluginCustomSseEvent(
}
}
/**
* CLI agent session state transitions (CLI Agent Executor, U10). The engine
* state machine's `onStateChange` (throttled ~500ms in the engine) is bridged
* into this seam by `setupCliSessionTransport`; every open SSE stream forwards
* a matching (project-scoped) `cli:session:state` event so cards / banners
* update without touching the byte stream.
*
* Events are also appended to a small module-level ring buffer with monotonic
* ids so a client reconnecting with `Last-Event-ID` replays the transitions it
* missed (the byte stream is on a separate WS channel; this is state only).
*/
export interface CliSessionStateSsePayload {
sessionId: string;
taskId: string | null;
chatSessionId: string | null;
/** Machine state (may be the transient "resuming"). */
state: string;
terminationReason?: string | null;
/** Bounded (~200 chars), ANSI-stripped, redacted preview of recent output. */
lastOutputPreview?: string;
at: string;
}
type CliSessionStateSseListener = (
id: number,
payload: CliSessionStateSsePayload,
projectId?: string,
) => void;
const cliSessionStateSseListeners = new Set<CliSessionStateSseListener>();
/** Module-level ring buffer of cli-session-state events for lastEventId replay. */
const cliSessionStateBuffer: { id: number; payload: CliSessionStateSsePayload; projectId?: string }[] =
[];
let cliSessionStateNextId = 1;
const CLI_SESSION_STATE_BUFFER_CAP = 200;
export function emitCliSessionStateSseEvent(
payload: CliSessionStateSsePayload,
projectId?: string,
): number {
const id = cliSessionStateNextId++;
cliSessionStateBuffer.push({ id, payload, projectId });
if (cliSessionStateBuffer.length > CLI_SESSION_STATE_BUFFER_CAP) {
cliSessionStateBuffer.splice(0, cliSessionStateBuffer.length - CLI_SESSION_STATE_BUFFER_CAP);
}
for (const listener of cliSessionStateSseListeners) {
listener(id, payload, projectId);
}
return id;
}
/** Buffered cli-session-state events with id > lastEventId (for reconnect replay). */
export function getCliSessionStateEventsSince(
lastEventId: number,
): { id: number; payload: CliSessionStateSsePayload; projectId?: string }[] {
if (!Number.isFinite(lastEventId)) return [...cliSessionStateBuffer];
return cliSessionStateBuffer.filter((entry) => entry.id > lastEventId);
}
/** Test seam: reset the cli-session-state buffer between tests. */
export function resetCliSessionStateBufferForTests(): void {
cliSessionStateBuffer.length = 0;
cliSessionStateNextId = 1;
}
/**
* Normalized plugin lifecycle payload emitted via SSE.
* This is the stable contract the UI can reconcile.
@@ -650,6 +716,11 @@ export function createSSE(
send(`event: plugin:custom\ndata: ${JSON.stringify({ pluginId, event, payload })}\n\n`);
};
const onCliSessionStateEvent: CliSessionStateSseListener = (id, payload, eventProjectId) => {
if (projectId && eventProjectId && eventProjectId !== projectId) return;
send(`id: ${id}\nevent: cli:session:state\ndata: ${JSON.stringify(payload)}\n\n`);
};
// --- Chat store event handlers ---
const onChatSessionCreated = (session: unknown) => {
send(`event: chat:session:created\ndata: ${JSON.stringify(session)}\n\n`);
@@ -801,6 +872,7 @@ export function createSSE(
approvalSseListeners.delete(onApprovalEvent);
workflowSseListeners.delete(onWorkflowEvent);
pluginCustomSseListeners.delete(onPluginCustomEvent);
cliSessionStateSseListeners.delete(onCliSessionStateEvent);
if (chatStore) {
chatStore.off("chat:session:created", onChatSessionCreated);
chatStore.off("chat:session:updated", onChatSessionUpdated);
@@ -949,6 +1021,30 @@ export function createSSE(
approvalSseListeners.add(onApprovalEvent);
workflowSseListeners.add(onWorkflowEvent);
pluginCustomSseListeners.add(onPluginCustomEvent);
cliSessionStateSseListeners.add(onCliSessionStateEvent);
// Replay any cli-session-state transitions missed since the client's
// Last-Event-ID (reconnect recovery — the byte stream is on a separate WS
// channel, so only state events are replayed here).
{
const headerVal = _req.headers?.["last-event-id"];
const lastEventIdRaw =
(typeof headerVal === "string"
? headerVal
: Array.isArray(headerVal)
? headerVal[0]
: undefined) ??
(typeof _req.query?.lastEventId === "string" ? _req.query.lastEventId : undefined);
const lastEventId = lastEventIdRaw !== undefined ? Number(lastEventIdRaw) : NaN;
if (Number.isFinite(lastEventId)) {
for (const entry of getCliSessionStateEventsSince(lastEventId)) {
if (projectId && entry.projectId && entry.projectId !== projectId) continue;
send(
`id: ${entry.id}\nevent: cli:session:state\ndata: ${JSON.stringify(entry.payload)}\n\n`,
);
}
}
}
registerManagedConnection({
id: connectionId,

View File

@@ -623,7 +623,19 @@ export {
getNativePrebuildName,
resetPtyModuleCacheForTests,
} from "./pty-native.js";
// CLI Agent Executor — telemetry hub (U3) consumed by the dashboard hook route (U17).
// CLI agent executor — session manager (U2), telemetry hub (U3), state machine (U3),
// hook scripts (U17); consumed by the dashboard hook route (U17) and transport (U10).
export {
CliSessionManager,
CliConcurrencyLimitError,
UnknownCliSessionError,
neutralizeInjection,
DEFAULT_SCROLLBACK_BYTES,
DEFAULT_CONCURRENCY_CEILING,
type CliSessionAttachment,
type CliSessionManagerOptions,
type SpawnCliSessionOptions,
} from "./cli-agent/session-manager.js";
export {
TelemetryHub,
stripAnsiControl,
@@ -636,6 +648,16 @@ export {
type SanitizedTelemetryEvent,
type NotificationDispatch,
} from "./cli-agent/telemetry-hub.js";
export {
CliSessionStateMachine,
classifyTermination,
isResumeEligible,
toPersistedState,
type CliMachineState,
type CliStateChange,
type CliStateChangeListener,
type CliStateMachineOptions,
} from "./cli-agent/state-machine.js";
// CLI Agent Executor — per-session hook scripts / notify shim (U17).
export {
writeSessionHookScripts,