Files
fusion/packages/dashboard/src/__tests__/cli-session-ws.test.ts

626 lines
21 KiB
TypeScript

// @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("does not leak an OSC 52 split across the scrollback→live seam", async () => {
const ESC = "\x1b";
const BEL = "\x07";
// The OSC 52 introducer lands at the very TAIL of scrollback (unterminated);
// its terminator arrives in the first LIVE chunk. The carry must thread
// across the seam so the neutralizer sees the full sequence and strips it —
// the scrollback frame must NOT flush the held introducer verbatim.
h = await startHarness();
h.manager.scrollbackById.set("cli-1", `prior-output${ESC}]52;c;ZXZ`);
const t = await mintTicket("cli-1");
const ws = connect(h.port, { sessionId: "cli-1", ticket: t });
const scrollback = await nextMessage(ws, (m) => m.type === "scrollback");
const scrollText = decode(scrollback.data);
// Normal scrollback still renders; the unterminated tail is withheld (carry).
expect(scrollText).toContain("prior-output");
expect(scrollText).not.toContain("52;");
expect(scrollText).not.toContain(`${ESC}]`);
// Deliver the terminator in the first live chunk.
h.manager.broadcast("cli-1", `pbA==${BEL}after`);
let collected = scrollText;
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("after")) {
ws.off("message", onMsg);
resolve();
}
};
ws.on("message", onMsg);
setTimeout(() => reject(new Error("timeout")), 1000);
}),
);
// The full sequence, reassembled across the seam, was neutralized.
expect(collected).not.toContain("52;");
expect(collected).not.toContain(`${ESC}]52`);
expect(collected).toContain("prior-output");
expect(collected).toContain("after");
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");
});
});