diff --git a/.changeset/cli-agent-session-transport.md b/.changeset/cli-agent-session-transport.md new file mode 100644 index 0000000000..c284bed218 --- /dev/null +++ b/.changeset/cli-agent-session-transport.md @@ -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. diff --git a/packages/dashboard/src/__tests__/cli-session-output-filter.test.ts b/packages/dashboard/src/__tests__/cli-session-output-filter.test.ts new file mode 100644 index 0000000000..8aaafba4b0 --- /dev/null +++ b/packages/dashboard/src/__tests__/cli-session-output-filter.test.ts @@ -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`); + }); +}); diff --git a/packages/dashboard/src/__tests__/cli-session-ws.test.ts b/packages/dashboard/src/__tests__/cli-session-ws.test.ts new file mode 100644 index 0000000000..bea912955c --- /dev/null +++ b/packages/dashboard/src/__tests__/cli-session-ws.test.ts @@ -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) => 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 { + return { + [Symbol.asyncIterator]: () => ({ + next: (): Promise> => { + 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> => { + 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(); + live = new Set(); + 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(); + + /** 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 { + 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; + close: () => Promise; +} + +async function startHarness(opts?: { + sessions?: CliSession[]; + liveIds?: string[]; + highWatermarkBytes?: number; + lowWatermarkBytes?: number; + noAuth?: boolean; +}): Promise { + 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((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((resolve) => { + server.close(() => resolve()); + }), + }; +} + +function wsUrl( + port: number, + params: Record, +): 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, + headers: Record = {}, +): 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 { + 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 { + 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((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((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((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((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) => { + 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"); + }); +}); diff --git a/packages/dashboard/src/__tests__/cli-sessions-routes.test.ts b/packages/dashboard/src/__tests__/cli-sessions-routes.test.ts new file mode 100644 index 0000000000..72275d25cb --- /dev/null +++ b/packages/dashboard/src/__tests__/cli-sessions-routes.test.ts @@ -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 { + 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; + 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; + let manager: CliSessionManagerLike; + let injectSpy: ReturnType; + 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); + }); +}); diff --git a/packages/dashboard/src/cli-session-output-filter.ts b/packages/dashboard/src/cli-session-output-filter.ts new file mode 100644 index 0000000000..c39391f1ec --- /dev/null +++ b/packages/dashboard/src/cli-session-output-filter.ts @@ -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 ) — 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; +} diff --git a/packages/dashboard/src/cli-session-transport.ts b/packages/dashboard/src/cli-session-transport.ts new file mode 100644 index 0000000000..9c12b102ec --- /dev/null +++ b/packages/dashboard/src/cli-session-transport.ts @@ -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; + /** 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; +} + +// ── 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(); + 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(); + 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(); + private readonly listeners = new Set(); + + 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; + /** 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; +} diff --git a/packages/dashboard/src/cli-session-ws.ts b/packages/dashboard/src/cli-session-ws.ts new file mode 100644 index 0000000000..ab578223dc --- /dev/null +++ b/packages/dashboard/src/cli-session-ws.ts @@ -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=&ticket=[&fn_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; + 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; +} diff --git a/packages/dashboard/src/routes/cli-sessions.ts b/packages/dashboard/src/routes/cli-sessions.ts new file mode 100644 index 0000000000..39abdba010 --- /dev/null +++ b/packages/dashboard/src/routes/cli-sessions.ts @@ -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; +} diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 23eda9a4fa..601b54161a 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -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 { @@ -223,6 +225,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; @@ -1435,6 +1450,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"); @@ -1548,6 +1578,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; diff --git a/packages/dashboard/src/sse.ts b/packages/dashboard/src/sse.ts index 7b1409ab32..9c51e40a86 100644 --- a/packages/dashboard/src/sse.ts +++ b/packages/dashboard/src/sse.ts @@ -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(); + +/** 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, diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index addb8e5e78..e6a631e63e 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -623,3 +623,33 @@ export { getNativePrebuildName, resetPtyModuleCacheForTests, } from "./pty-native.js"; +// CLI agent executor (U2/U3) — transport surfaces (U10) consume these. +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, + type TelemetryHubOptions, + type TelemetryEvent, + type TelemetryEventKind, + type SanitizedTelemetryEvent, +} 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";