merge: main (CLI agent interface #1446) — renumber workflow_settings migration to 112 behind main's cli_sessions(110)/adapter(111), full-workspace literal sweep, i18n union
This commit is contained in:
@@ -69,7 +69,8 @@
|
||||
"multer": "^2.1.1",
|
||||
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
|
||||
"react": "^19.2.0",
|
||||
"react-i18next": "^17.0.8"
|
||||
"react-i18next": "^17.0.8",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-ai": "*",
|
||||
@@ -96,6 +97,7 @@
|
||||
"@fusion/pi-llama-cpp": "workspace:*",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"@vitest/coverage-v8": "^3.1.0",
|
||||
"cross-env": "^7.0.0",
|
||||
"esbuild": "^0.25.12",
|
||||
|
||||
@@ -495,6 +495,10 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
||||
return { stop: vi.fn() };
|
||||
}
|
||||
|
||||
getCliAgentRuntime(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async onMerge(taskId: string): Promise<unknown> {
|
||||
return aiMergeTask(this.store, this.cwd, taskId, {
|
||||
pool: this.pool,
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
attachTerminalSession,
|
||||
buildWsUrl,
|
||||
fetchAttachTicket,
|
||||
DETACH_CHORD_BYTE,
|
||||
ALT_SCREEN_ENTER,
|
||||
ALT_SCREEN_LEAVE,
|
||||
WS_OPEN,
|
||||
type TerminalWebSocket,
|
||||
type AttachStdin,
|
||||
type AttachStdout,
|
||||
} from "../terminal-attach.js";
|
||||
|
||||
// ── Fakes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** In-memory WS-like transport. Never opens a real socket / never port 4040. */
|
||||
class FakeWs implements TerminalWebSocket {
|
||||
readyState = WS_OPEN;
|
||||
sent: string[] = [];
|
||||
private handlers = new Map<string, ((...args: unknown[]) => void)[]>();
|
||||
closed = false;
|
||||
|
||||
on(event: string, listener: (...args: unknown[]) => void): void {
|
||||
const list = this.handlers.get(event) ?? [];
|
||||
list.push(listener);
|
||||
this.handlers.set(event, list);
|
||||
}
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.readyState = 3; // CLOSED
|
||||
this.emit("close");
|
||||
}
|
||||
emit(event: string, ...args: unknown[]): void {
|
||||
for (const l of this.handlers.get(event) ?? []) l(...args);
|
||||
}
|
||||
/** Simulate the server delivering a (JSON) message frame. */
|
||||
deliver(frame: unknown): void {
|
||||
this.emit("message", Buffer.from(JSON.stringify(frame), "utf8"));
|
||||
}
|
||||
/** Parsed client→server frames. */
|
||||
parsedSent(): Array<Record<string, unknown>> {
|
||||
return this.sent.map((s) => JSON.parse(s));
|
||||
}
|
||||
}
|
||||
|
||||
class FakeStdin implements AttachStdin {
|
||||
isTTY = true;
|
||||
isRaw = false;
|
||||
private listeners: ((chunk: Buffer | string) => void)[] = [];
|
||||
rawCalls: boolean[] = [];
|
||||
resumed = false;
|
||||
on(_event: "data", listener: (chunk: Buffer | string) => void): void {
|
||||
this.listeners.push(listener);
|
||||
}
|
||||
off(_event: "data", listener: (chunk: Buffer | string) => void): void {
|
||||
this.listeners = this.listeners.filter((l) => l !== listener);
|
||||
}
|
||||
setRawMode(mode: boolean): void {
|
||||
this.rawCalls.push(mode);
|
||||
this.isRaw = mode;
|
||||
}
|
||||
resume(): void {
|
||||
this.resumed = true;
|
||||
}
|
||||
/** Simulate a user keystroke chunk. */
|
||||
feed(chunk: Buffer | string): void {
|
||||
for (const l of [...this.listeners]) l(chunk);
|
||||
}
|
||||
listenerCount(): number {
|
||||
return this.listeners.length;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeStdout implements AttachStdout {
|
||||
columns = 80;
|
||||
rows = 24;
|
||||
writes: string[] = [];
|
||||
private resizeListeners: (() => void)[] = [];
|
||||
write(chunk: string): void {
|
||||
this.writes.push(chunk);
|
||||
}
|
||||
on(_event: "resize", listener: () => void): void {
|
||||
this.resizeListeners.push(listener);
|
||||
}
|
||||
off(_event: "resize", listener: () => void): void {
|
||||
this.resizeListeners = this.resizeListeners.filter((l) => l !== listener);
|
||||
}
|
||||
fireResize(cols: number, rows: number): void {
|
||||
this.columns = cols;
|
||||
this.rows = rows;
|
||||
for (const l of [...this.resizeListeners]) l();
|
||||
}
|
||||
resizeListenerCount(): number {
|
||||
return this.resizeListeners.length;
|
||||
}
|
||||
all(): string {
|
||||
return this.writes.join("");
|
||||
}
|
||||
}
|
||||
|
||||
/** A fetchImpl that always returns a ticket. */
|
||||
function okTicketFetch(ticket = "TICKET-1"): typeof fetch {
|
||||
return vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ticket, expiresAt: new Date().toISOString(), readOnly: false }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function b64(s: string): string {
|
||||
return Buffer.from(s, "utf8").toString("base64");
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
ws: FakeWs;
|
||||
stdin: FakeStdin;
|
||||
stdout: FakeStdout;
|
||||
onDetach: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an attach, drive the WS `open`, and return the harness + handle.
|
||||
* `await tick()` lets the async ticket fetch resolve.
|
||||
*/
|
||||
async function startAttach(
|
||||
overrides: Partial<Parameters<typeof attachTerminalSession>[0]> = {},
|
||||
): Promise<Harness & { handle: ReturnType<typeof attachTerminalSession> }> {
|
||||
const ws = new FakeWs();
|
||||
const stdin = new FakeStdin();
|
||||
const stdout = new FakeStdout();
|
||||
const onDetach = vi.fn();
|
||||
|
||||
const handle = attachTerminalSession({
|
||||
baseUrl: "http://127.0.0.1:9999",
|
||||
token: "daemon-tok",
|
||||
sessionId: "sess-1",
|
||||
stdin,
|
||||
stdout,
|
||||
onDetach,
|
||||
fetchImpl: okTicketFetch(),
|
||||
wsFactory: () => ws,
|
||||
ackThresholdBytes: 64,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Let the ticket fetch resolve, then open the socket.
|
||||
await tick();
|
||||
ws.emit("open");
|
||||
|
||||
return { ws, stdin, stdout, onDetach, handle };
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── URL / ticket helpers ──────────────────────────────────────────────────────
|
||||
|
||||
describe("buildWsUrl", () => {
|
||||
it("derives ws:// from http:// and sets sessionId + ticket", () => {
|
||||
const url = buildWsUrl({ baseUrl: "http://127.0.0.1:4040", sessionId: "s1", ticket: "t1" });
|
||||
expect(url).toBe("ws://127.0.0.1:4040/api/cli-sessions/ws?sessionId=s1&ticket=t1");
|
||||
});
|
||||
it("derives wss:// from https://", () => {
|
||||
const url = buildWsUrl({ baseUrl: "https://host", sessionId: "s", ticket: "t" });
|
||||
expect(url.startsWith("wss://host/api/cli-sessions/ws")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAttachTicket", () => {
|
||||
it("POSTs to the attach-ticket route with bearer auth and returns the ticket", async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ticket: "TK", readOnly: false }), { status: 200 }),
|
||||
) as unknown as typeof fetch;
|
||||
const res = await fetchAttachTicket({
|
||||
baseUrl: "http://h",
|
||||
token: "tok",
|
||||
sessionId: "s 1",
|
||||
fetchImpl,
|
||||
});
|
||||
expect(res.ticket).toBe("TK");
|
||||
const [url, init] = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[0];
|
||||
expect(url).toBe("http://h/api/cli-sessions/s%201/attach-ticket");
|
||||
expect((init as RequestInit).method).toBe("POST");
|
||||
expect((init as RequestInit).headers).toMatchObject({ authorization: "Bearer tok" });
|
||||
});
|
||||
|
||||
it("throws on non-2xx", async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response("nope", { status: 404, statusText: "Not Found" })) as unknown as typeof fetch;
|
||||
await expect(
|
||||
fetchAttachTicket({ baseUrl: "http://h", sessionId: "s", fetchImpl }),
|
||||
).rejects.toThrow(/HTTP 404/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Passthrough loop ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("attachTerminalSession passthrough", () => {
|
||||
it("enters alt-screen + raw mode on open and sends an initial resize", async () => {
|
||||
const { stdin, stdout, ws } = await startAttach();
|
||||
expect(stdout.all()).toContain(ALT_SCREEN_ENTER);
|
||||
expect(stdin.rawCalls).toContain(true);
|
||||
expect(stdin.resumed).toBe(true);
|
||||
const resize = ws.parsedSent().find((f) => f.type === "resize");
|
||||
expect(resize).toMatchObject({ type: "resize", cols: 80, rows: 24 });
|
||||
});
|
||||
|
||||
it("frames stdin bytes into input messages (base64)", async () => {
|
||||
const { stdin, ws } = await startAttach();
|
||||
stdin.feed(Buffer.from("ls -la\r", "utf8"));
|
||||
const input = ws.parsedSent().find((f) => f.type === "input");
|
||||
expect(input).toBeDefined();
|
||||
expect(Buffer.from(input!.data as string, "base64").toString("utf8")).toBe("ls -la\r");
|
||||
});
|
||||
|
||||
it("writes data frames to stdout verbatim", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
const payload = "hello \x1b[31mworld\x1b[0m\n";
|
||||
ws.deliver({ type: "data", data: b64(payload) });
|
||||
expect(stdout.all()).toContain(payload);
|
||||
});
|
||||
|
||||
it("passes CJK / double-width bytes through verbatim", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
const payload = "日本語 ❤ 한국어";
|
||||
ws.deliver({ type: "data", data: b64(payload) });
|
||||
expect(stdout.all()).toContain(payload);
|
||||
});
|
||||
|
||||
it("writes scrollback frames to stdout", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
ws.deliver({ type: "scrollback", data: b64("prior output\n") });
|
||||
expect(stdout.all()).toContain("prior output\n");
|
||||
});
|
||||
|
||||
it("propagates host resize as a resize frame", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
stdout.fireResize(120, 40);
|
||||
const resizes = ws.parsedSent().filter((f) => f.type === "resize");
|
||||
expect(resizes.at(-1)).toMatchObject({ cols: 120, rows: 40 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Detach chord ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("detach chord (Ctrl-])", () => {
|
||||
it("restores state: leaves alt-screen, restores raw mode, closes WS, calls onDetach", async () => {
|
||||
const { stdin, stdout, ws, onDetach, handle } = await startAttach();
|
||||
stdin.feed(Buffer.from([DETACH_CHORD_BYTE]));
|
||||
await handle.done;
|
||||
|
||||
expect(stdout.all()).toContain(ALT_SCREEN_LEAVE);
|
||||
expect(stdin.rawCalls.at(-1)).toBe(false); // restored to prior (false)
|
||||
expect(ws.closed).toBe(true);
|
||||
expect(onDetach).toHaveBeenCalledTimes(1);
|
||||
expect(onDetach).toHaveBeenCalledWith(undefined);
|
||||
// Listeners removed (refcount back to baseline).
|
||||
expect(stdin.listenerCount()).toBe(0);
|
||||
expect(stdout.resizeListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("flushes bytes before the chord, then detaches", async () => {
|
||||
const { stdin, ws, handle } = await startAttach();
|
||||
stdin.feed(Buffer.from([0x61, 0x62, DETACH_CHORD_BYTE, 0x63])); // "ab" Ctrl-] "c"
|
||||
await handle.done;
|
||||
const inputs = ws.parsedSent().filter((f) => f.type === "input");
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(Buffer.from(inputs[0].data as string, "base64").toString("utf8")).toBe("ab");
|
||||
});
|
||||
|
||||
it("is idempotent — detach() after a chord does not re-fire onDetach", async () => {
|
||||
const { stdin, onDetach, handle } = await startAttach();
|
||||
stdin.feed(Buffer.from([DETACH_CHORD_BYTE]));
|
||||
await handle.done;
|
||||
handle.detach();
|
||||
expect(onDetach).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error / drop paths ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("WS close mid-attach surfaces error", () => {
|
||||
it("close before exit → onDetach(error) and terminal restored", async () => {
|
||||
const { ws, stdout, stdin, onDetach, handle } = await startAttach();
|
||||
ws.close();
|
||||
await handle.done;
|
||||
expect(onDetach).toHaveBeenCalledTimes(1);
|
||||
expect(onDetach.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||
expect(stdout.all()).toContain(ALT_SCREEN_LEAVE);
|
||||
expect(stdin.rawCalls.at(-1)).toBe(false);
|
||||
});
|
||||
|
||||
it("WS error → onDetach(error) and clean restore", async () => {
|
||||
const { ws, stdout, onDetach, handle } = await startAttach();
|
||||
ws.emit("error", new Error("boom"));
|
||||
await handle.done;
|
||||
expect(onDetach).toHaveBeenCalledTimes(1);
|
||||
expect((onDetach.mock.calls[0][0] as Error).message).toBe("boom");
|
||||
expect(stdout.all()).toContain(ALT_SCREEN_LEAVE);
|
||||
});
|
||||
|
||||
it("server `exit` frame ends the attach cleanly (no error)", async () => {
|
||||
const { ws, onDetach, handle } = await startAttach();
|
||||
ws.deliver({ type: "exit" });
|
||||
await handle.done;
|
||||
expect(onDetach).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("failed ticket mint surfaces the error without opening the WS", async () => {
|
||||
const onDetach = vi.fn();
|
||||
const wsFactory = vi.fn();
|
||||
const handle = attachTerminalSession({
|
||||
baseUrl: "http://127.0.0.1:9999",
|
||||
sessionId: "s",
|
||||
stdin: new FakeStdin(),
|
||||
stdout: new FakeStdout(),
|
||||
onDetach,
|
||||
fetchImpl: vi.fn(async () => new Response("x", { status: 500, statusText: "Err" })) as unknown as typeof fetch,
|
||||
wsFactory: wsFactory as never,
|
||||
});
|
||||
await handle.done;
|
||||
expect(wsFactory).not.toHaveBeenCalled();
|
||||
expect(onDetach).toHaveBeenCalledTimes(1);
|
||||
expect(onDetach.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Output neutralization (full U10 parity set) ─────────────────────────────────
|
||||
|
||||
describe("output neutralization before stdout", () => {
|
||||
it("strips OSC 52 clipboard-write", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
const hostile = `before\x1b]52;c;${Buffer.from("stolen").toString("base64")}\x07after`;
|
||||
ws.deliver({ type: "data", data: b64(hostile) });
|
||||
const out = stdout.all();
|
||||
expect(out).toContain("before");
|
||||
expect(out).toContain("after");
|
||||
expect(out).not.toContain("52;c;");
|
||||
});
|
||||
|
||||
it("strips a non-http(s) (javascript:) OSC 8 link URI but keeps the text", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
const hostile = `\x1b]8;;javascript:alert(1)\x07click me\x1b]8;;\x07`;
|
||||
ws.deliver({ type: "data", data: b64(hostile) });
|
||||
const out = stdout.all();
|
||||
expect(out).toContain("click me");
|
||||
expect(out).not.toContain("javascript:alert(1)");
|
||||
});
|
||||
|
||||
it("passes an http(s) OSC 8 link through", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
const safe = `\x1b]8;;https://example.com\x07link\x1b]8;;\x07`;
|
||||
ws.deliver({ type: "data", data: b64(safe) });
|
||||
expect(stdout.all()).toContain("https://example.com");
|
||||
});
|
||||
|
||||
it("strips a DSR device-status query (would forge input)", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
const hostile = "x\x1b[6ny"; // DSR cursor-position report
|
||||
ws.deliver({ type: "data", data: b64(hostile) });
|
||||
const out = stdout.all();
|
||||
expect(out).toContain("x");
|
||||
expect(out).toContain("y");
|
||||
expect(out).not.toContain("\x1b[6n");
|
||||
});
|
||||
|
||||
it("neutralizes a sequence split across two frames", async () => {
|
||||
const { stdout, ws } = await startAttach();
|
||||
// Split an OSC 52 across two data frames.
|
||||
const part1 = `safe\x1b]52;c;${Buffer.from("secret").toString("base64")}`;
|
||||
const part2 = `\x07tail`;
|
||||
ws.deliver({ type: "data", data: b64(part1) });
|
||||
ws.deliver({ type: "data", data: b64(part2) });
|
||||
const out = stdout.all();
|
||||
expect(out).toContain("safe");
|
||||
expect(out).toContain("tail");
|
||||
expect(out).not.toContain("52;c;");
|
||||
});
|
||||
});
|
||||
|
||||
// ── ACK flow control ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("ACK flow control", () => {
|
||||
it("emits an ACK after the threshold bytes are written", async () => {
|
||||
const { stdout, ws } = await startAttach({ ackThresholdBytes: 64 });
|
||||
void stdout;
|
||||
// 100 bytes of benign output → crosses the 64-byte threshold once.
|
||||
ws.deliver({ type: "data", data: b64("a".repeat(100)) });
|
||||
const acks = ws.parsedSent().filter((f) => f.type === "ack");
|
||||
expect(acks).toHaveLength(1);
|
||||
expect(acks[0].bytes).toBeGreaterThanOrEqual(64);
|
||||
});
|
||||
|
||||
it("does not ACK below the threshold", async () => {
|
||||
const { ws } = await startAttach({ ackThresholdBytes: 1024 });
|
||||
ws.deliver({ type: "data", data: b64("short") });
|
||||
expect(ws.parsedSent().filter((f) => f.type === "ack")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -134,6 +134,14 @@ export class DashboardTUI {
|
||||
waitUntilExit: () => Promise<unknown>;
|
||||
clear?: () => void;
|
||||
} & Record<string, unknown> | null = null;
|
||||
// Captured at start() so a full-screen terminal attach (U14) can unmount Ink,
|
||||
// hand the TTY to the passthrough loop, then remount the same app on detach.
|
||||
private renderApp: (() => unknown) | null = null;
|
||||
private inkRender: ((node: unknown) => typeof this.inkInstance) | null = null;
|
||||
// True while a full-screen terminal attach owns the TTY; suppresses Ink
|
||||
// re-render/resize work that would corrupt the passthrough surface.
|
||||
private terminalAttachActive = false;
|
||||
|
||||
// Resize listener attached at start(), detached at stop().
|
||||
private resizeListener: (() => void) | null = null;
|
||||
// Debounce timer for resize handling — coalesces tmux/ssh resize bursts.
|
||||
@@ -705,6 +713,12 @@ export class DashboardTUI {
|
||||
process.stdout.write("\x1b[?1049h\x1b[H");
|
||||
}
|
||||
|
||||
// Capture the app element factory + render fn so openTerminalAttach() can
|
||||
// remount the identical tree after a full-screen passthrough detaches.
|
||||
this.renderApp = () =>
|
||||
createElement(I18nextProvider, { i18n }, createElement(DashboardApp, { controller: this }));
|
||||
this.inkRender = (node: unknown) => render(node as Parameters<typeof render>[0]);
|
||||
|
||||
this.inkInstance = render(
|
||||
createElement(I18nextProvider, { i18n }, createElement(DashboardApp, { controller: this })),
|
||||
);
|
||||
@@ -899,6 +913,82 @@ export class DashboardTUI {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a CLI-agent session as a full-screen passthrough (U14). Suspend-and-
|
||||
* handoff: unmount Ink (releasing its raw-mode / stdin grip), let
|
||||
* `attachTerminalSession` own the alt-screen + raw mode for the passthrough
|
||||
* loop, then remount the same Ink app once the user detaches (Ctrl-]) or the
|
||||
* session ends. Resolves after the TUI has been remounted.
|
||||
*
|
||||
* No-op (resolves immediately) when there's no session info / not running.
|
||||
*/
|
||||
async openTerminalAttach(sessionId: string, projectId?: string): Promise<void> {
|
||||
if (!this.isRunning || this.terminalAttachActive) return;
|
||||
if (!this.renderApp || !this.inkRender) return;
|
||||
const baseUrl = this.systemInfo?.baseUrl;
|
||||
if (!baseUrl) return;
|
||||
const token = this.systemInfo?.authToken;
|
||||
|
||||
const { attachTerminalSession } = await import("./terminal-attach.js");
|
||||
|
||||
this.terminalAttachActive = true;
|
||||
|
||||
// Unmount Ink so it relinquishes raw mode + the stdin 'data' grip; the
|
||||
// passthrough loop installs its own listeners on the bare stdin/stdout.
|
||||
// Also drop our mouse listener so wheel reports don't leak into the PTY.
|
||||
this.uninstallMouseListener();
|
||||
if (this.inkInstance) {
|
||||
try {
|
||||
this.inkInstance.unmount();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.inkInstance = null;
|
||||
}
|
||||
// Leave Ink's alt-screen; the passthrough enters its own.
|
||||
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
|
||||
try {
|
||||
process.stdout.write("\x1b[?1049l");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const handle = attachTerminalSession({
|
||||
baseUrl,
|
||||
token,
|
||||
sessionId,
|
||||
projectId,
|
||||
stdin: process.stdin as unknown as import("./terminal-attach.js").AttachStdin,
|
||||
stdout: process.stdout as unknown as import("./terminal-attach.js").AttachStdout,
|
||||
onDetach: (error) => {
|
||||
if (error) {
|
||||
this.error(`Terminal session detached: ${error.message}`, "cli-agent");
|
||||
}
|
||||
},
|
||||
});
|
||||
void handle.done.finally(() => resolve());
|
||||
});
|
||||
|
||||
// Remount Ink on a clean alt-screen.
|
||||
this.terminalAttachActive = false;
|
||||
if (!this.isRunning) return; // stopped while attached — leave the terminal as-is
|
||||
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
|
||||
try {
|
||||
process.stdout.write("\x1b[?1049h\x1b[H");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.inkInstance = this.inkRender(this.renderApp());
|
||||
} catch {
|
||||
/* ignore — remount best-effort */
|
||||
}
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────────
|
||||
|
||||
// Attach a parallel `data` listener that decodes xterm SGR mouse
|
||||
|
||||
497
packages/cli/src/commands/dashboard-tui/terminal-attach.ts
Normal file
497
packages/cli/src/commands/dashboard-tui/terminal-attach.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* terminal-attach — full-screen passthrough attach to a CLI agent session
|
||||
* from the Ink TUI (CLI Agent Executor, U14).
|
||||
*
|
||||
* Model: SUSPEND-AND-HANDOFF, not embedding. The caller pauses Ink rendering,
|
||||
* then `attachTerminalSession` takes over the real TTY:
|
||||
* - enter the alternate screen (`\x1b[?1049h`) and put stdin in raw mode,
|
||||
* - WS `scrollback`/`data` frames → neutralize (U10 filter) → write to stdout,
|
||||
* - stdin bytes → WS `input` frames (base64),
|
||||
* - SIGWINCH / stdout resize → WS `resize` frames,
|
||||
* - ACK `bytes` consumed after every ~32KB written, for flow control,
|
||||
* - detach chord Ctrl-] (0x1d) → leave alt-screen, restore raw mode, close WS,
|
||||
* - WS close/error mid-attach → restore the terminal cleanly + surface via
|
||||
* `onDetach(error)`.
|
||||
*
|
||||
* SECURITY (the riskiest leg): the byte stream is UNTRUSTED. The host terminal
|
||||
* honors more escape sequences than xterm.js, so every byte written to the host
|
||||
* TTY is passed through `neutralizeTerminalOutput` FIRST — the identical filter
|
||||
* the dashboard WS bridge uses (re-exported from `@fusion/dashboard`). OSC 52
|
||||
* clipboard writes, OSC 8 non-http(s) links, and device-status queries (whose
|
||||
* auto-responses forge input) are stripped before they ever reach the terminal.
|
||||
*
|
||||
* CJK / double-width: bytes pass through verbatim — no width math is needed in a
|
||||
* passthrough (the host terminal does the width handling).
|
||||
*
|
||||
* The WS transport is injectable (`wsFactory`) so tests drive the loop with an
|
||||
* in-memory WS-like object and NEVER open a real socket (and never touch port
|
||||
* 4040). The default factory uses the `ws` Node client.
|
||||
*/
|
||||
|
||||
import { WebSocket } from "ws";
|
||||
import { neutralizeTerminalOutput, flushTerminalOutput } from "@fusion/dashboard";
|
||||
|
||||
/** Detach chord: Ctrl-] (GS, 0x1d). Documented + shown in the status hint. */
|
||||
export const DETACH_CHORD_BYTE = 0x1d;
|
||||
/** Human-readable label for the detach chord (status hint). */
|
||||
export const DETACH_CHORD_LABEL = "Ctrl-]";
|
||||
|
||||
/** Enter / leave the alternate screen buffer. */
|
||||
export const ALT_SCREEN_ENTER = "\x1b[?1049h";
|
||||
export const ALT_SCREEN_LEAVE = "\x1b[?1049l";
|
||||
|
||||
/** Emit an ACK after roughly this many bytes are written to stdout. */
|
||||
export const DEFAULT_ACK_THRESHOLD_BYTES = 32 * 1024;
|
||||
|
||||
// ── Frame shapes (mirror packages/dashboard/src/cli-session-ws.ts) ──────────
|
||||
|
||||
/** Server → client frames. */
|
||||
type ServerFrame =
|
||||
| { type: "scrollback"; data?: string }
|
||||
| { type: "data"; data?: string }
|
||||
| { type: "state"; [k: string]: unknown }
|
||||
| { type: "error"; message?: string; code?: string }
|
||||
| { type: "exit" };
|
||||
|
||||
/** Client → server frames. */
|
||||
type ClientFrame =
|
||||
| { type: "input"; data: string }
|
||||
| { type: "resize"; cols: number; rows: number }
|
||||
| { type: "ack"; bytes: number };
|
||||
|
||||
/**
|
||||
* The minimal WebSocket surface the passthrough loop uses. The real `ws` client
|
||||
* satisfies this; tests provide an in-memory implementation.
|
||||
*/
|
||||
export interface TerminalWebSocket {
|
||||
/** Register an event handler. */
|
||||
on(event: "open", listener: () => void): void;
|
||||
on(event: "message", listener: (data: unknown) => void): void;
|
||||
on(event: "close", listener: (code?: number, reason?: unknown) => void): void;
|
||||
on(event: "error", listener: (err: Error) => void): void;
|
||||
/** Send a (string) frame. */
|
||||
send(data: string): void;
|
||||
/** Close the socket. */
|
||||
close(code?: number, reason?: string): void;
|
||||
/** Current ready state; OPEN === 1 (matches the ws/WHATWG constant). */
|
||||
readyState: number;
|
||||
}
|
||||
|
||||
/** Ready-state constant matching the `ws` client / WHATWG WebSocket. */
|
||||
export const WS_OPEN = 1;
|
||||
|
||||
/** Factory that opens a WS connection to `url` with the given headers. */
|
||||
export type TerminalWebSocketFactory = (
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
) => TerminalWebSocket;
|
||||
|
||||
/** Minimal readable stdin surface (a TTY ReadStream satisfies this). */
|
||||
export interface AttachStdin {
|
||||
on(event: "data", listener: (chunk: Buffer | string) => void): void;
|
||||
off(event: "data", listener: (chunk: Buffer | string) => void): void;
|
||||
setRawMode?: (mode: boolean) => void;
|
||||
isRaw?: boolean;
|
||||
isTTY?: boolean;
|
||||
resume?: () => void;
|
||||
pause?: () => void;
|
||||
}
|
||||
|
||||
/** Minimal writable stdout surface (a TTY WriteStream satisfies this). */
|
||||
export interface AttachStdout {
|
||||
write(chunk: string): void;
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
on(event: "resize", listener: () => void): void;
|
||||
off(event: "resize", listener: () => void): void;
|
||||
}
|
||||
|
||||
export interface AttachTerminalSessionOptions {
|
||||
/** Dashboard base URL, e.g. `http://127.0.0.1:4040`. */
|
||||
baseUrl: string;
|
||||
/** Daemon token (Authorization: Bearer …). Optional when auth is disabled. */
|
||||
token?: string;
|
||||
/** Session id to attach to. */
|
||||
sessionId: string;
|
||||
/** Project id (scopes the attach-ticket mint), if known. */
|
||||
projectId?: string;
|
||||
stdin: AttachStdin;
|
||||
stdout: AttachStdout;
|
||||
/**
|
||||
* Called exactly once when the attach ends — cleanly (no arg) or with an error
|
||||
* (WS drop / failed ticket). The caller resumes Ink rendering here.
|
||||
*/
|
||||
onDetach: (error?: Error) => void;
|
||||
/** Injectable WS factory (default: the `ws` Node client). */
|
||||
wsFactory?: TerminalWebSocketFactory;
|
||||
/** Injectable fetch (default: global fetch) for the attach-ticket POST. */
|
||||
fetchImpl?: typeof fetch;
|
||||
/** ACK threshold override (bytes). */
|
||||
ackThresholdBytes?: number;
|
||||
/** Print a one-line detach hint before entering the alt-screen. */
|
||||
printHint?: boolean;
|
||||
}
|
||||
|
||||
/** Handle returned by `attachTerminalSession`; lets the caller force-detach. */
|
||||
export interface AttachHandle {
|
||||
/** Resolves when the attach fully ends (after terminal restore + onDetach). */
|
||||
done: Promise<void>;
|
||||
/** Force a clean detach (e.g. the TUI is quitting). Idempotent. */
|
||||
detach(): void;
|
||||
}
|
||||
|
||||
interface AttachTicketResponse {
|
||||
ticket: string;
|
||||
expiresAt?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single-use attach ticket for the session. Throws on non-2xx so the
|
||||
* caller surfaces a clean error and never opens the WS.
|
||||
*/
|
||||
export async function fetchAttachTicket(opts: {
|
||||
baseUrl: string;
|
||||
token?: string;
|
||||
sessionId: string;
|
||||
projectId?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<AttachTicketResponse> {
|
||||
const fetchFn = opts.fetchImpl ?? fetch;
|
||||
const url = `${opts.baseUrl.replace(/\/$/, "")}/api/cli-sessions/${encodeURIComponent(
|
||||
opts.sessionId,
|
||||
)}/attach-ticket`;
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (opts.token) headers.authorization = `Bearer ${opts.token}`;
|
||||
const res = await fetchFn(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(opts.projectId ? { projectId: opts.projectId } : {}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to mint attach ticket (HTTP ${res.status} ${res.statusText})`,
|
||||
);
|
||||
}
|
||||
const body = (await res.json()) as AttachTicketResponse;
|
||||
if (!body || typeof body.ticket !== "string" || body.ticket.length === 0) {
|
||||
throw new Error("Attach-ticket response missing `ticket`");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Build the cli-session WS URL with sessionId + ticket query params. */
|
||||
export function buildWsUrl(opts: {
|
||||
baseUrl: string;
|
||||
sessionId: string;
|
||||
ticket: string;
|
||||
}): string {
|
||||
const u = new URL(`${opts.baseUrl.replace(/\/$/, "")}/api/cli-sessions/ws`);
|
||||
// ws(s):// scheme — derive from http(s).
|
||||
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
||||
u.searchParams.set("sessionId", opts.sessionId);
|
||||
u.searchParams.set("ticket", opts.ticket);
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
function defaultWsFactory(): TerminalWebSocketFactory {
|
||||
return (url, headers) => {
|
||||
const ws = new WebSocket(url, { headers });
|
||||
return ws as unknown as TerminalWebSocket;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a server `data`/`scrollback` frame's base64 payload to a UTF-8 string.
|
||||
*/
|
||||
function decodeFrameData(data: string | undefined): string {
|
||||
if (typeof data !== "string" || data.length === 0) return "";
|
||||
return Buffer.from(data, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full-screen passthrough attach. Returns once the attach has fully
|
||||
* ended and the terminal has been restored (the same point `onDetach` fires).
|
||||
*
|
||||
* Lifecycle is single-shot: every termination path (detach chord, WS close, WS
|
||||
* error, ticket failure, force `detach()`) funnels through one idempotent
|
||||
* teardown that restores raw mode, leaves the alt-screen, closes the WS, and
|
||||
* fires `onDetach` exactly once.
|
||||
*/
|
||||
export function attachTerminalSession(
|
||||
opts: AttachTerminalSessionOptions,
|
||||
): AttachHandle {
|
||||
const {
|
||||
stdin,
|
||||
stdout,
|
||||
onDetach,
|
||||
ackThresholdBytes = DEFAULT_ACK_THRESHOLD_BYTES,
|
||||
} = opts;
|
||||
const wsFactory = opts.wsFactory ?? defaultWsFactory();
|
||||
|
||||
let settled = false;
|
||||
let resolveDone: () => void;
|
||||
const done = new Promise<void>((resolve) => {
|
||||
resolveDone = resolve;
|
||||
});
|
||||
|
||||
// Terminal state we must restore on teardown.
|
||||
const priorRaw = stdin.isRaw ?? false;
|
||||
let enteredAltScreen = false;
|
||||
let rawModeSet = false;
|
||||
|
||||
// Live wiring (set once the WS opens).
|
||||
let ws: TerminalWebSocket | null = null;
|
||||
let onStdinData: ((chunk: Buffer | string) => void) | null = null;
|
||||
let onResize: (() => void) | null = null;
|
||||
|
||||
// Outbound neutralization carry (threaded across data frames so a sequence
|
||||
// split across two frames is still caught).
|
||||
let carry = "";
|
||||
// Flow control: bytes written since the last ACK.
|
||||
let bytesSinceAck = 0;
|
||||
|
||||
const sendFrame = (frame: ClientFrame): void => {
|
||||
if (!ws || ws.readyState !== WS_OPEN) return;
|
||||
try {
|
||||
ws.send(JSON.stringify(frame));
|
||||
} catch {
|
||||
/* socket closing */
|
||||
}
|
||||
};
|
||||
|
||||
const ackConsumed = (n: number): void => {
|
||||
bytesSinceAck += n;
|
||||
if (bytesSinceAck >= ackThresholdBytes) {
|
||||
sendFrame({ type: "ack", bytes: bytesSinceAck });
|
||||
bytesSinceAck = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Write a (possibly partial) untrusted chunk to the host TTY through the U10
|
||||
// neutralizer. `isSnapshot` flushes the carry (scrollback is a complete unit).
|
||||
const writeNeutralized = (text: string, isSnapshot: boolean): void => {
|
||||
const result = neutralizeTerminalOutput(text, carry);
|
||||
let out = result.output;
|
||||
if (isSnapshot) {
|
||||
out += flushTerminalOutput(result.carry);
|
||||
carry = "";
|
||||
} else {
|
||||
carry = result.carry;
|
||||
}
|
||||
if (out.length === 0) return;
|
||||
try {
|
||||
stdout.write(out);
|
||||
} catch {
|
||||
/* stdout closing */
|
||||
}
|
||||
ackConsumed(Buffer.byteLength(out, "utf8"));
|
||||
};
|
||||
|
||||
const teardown = (error?: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
||||
// Detach stdin/resize listeners first so no late bytes race the restore.
|
||||
if (onStdinData) {
|
||||
try {
|
||||
stdin.off("data", onStdinData);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
onStdinData = null;
|
||||
}
|
||||
if (onResize) {
|
||||
try {
|
||||
stdout.off("resize", onResize);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
onResize = null;
|
||||
}
|
||||
|
||||
// Restore raw mode to its prior state (only if we changed it).
|
||||
if (rawModeSet && stdin.setRawMode) {
|
||||
try {
|
||||
stdin.setRawMode(priorRaw);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// Leave the alt-screen so the caller's shell / Ink scrollback is restored.
|
||||
if (enteredAltScreen) {
|
||||
try {
|
||||
stdout.write(ALT_SCREEN_LEAVE);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// Close the WS (never throws upward).
|
||||
if (ws) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
onDetach(error);
|
||||
} finally {
|
||||
resolveDone();
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerFrame = (frame: ServerFrame): void => {
|
||||
switch (frame.type) {
|
||||
case "scrollback":
|
||||
writeNeutralized(decodeFrameData(frame.data), true);
|
||||
break;
|
||||
case "data":
|
||||
writeNeutralized(decodeFrameData(frame.data), false);
|
||||
break;
|
||||
case "exit":
|
||||
teardown();
|
||||
break;
|
||||
case "error":
|
||||
// A server error frame (e.g. read-only) is informational; surface it on
|
||||
// stdout but don't tear down — the stream may continue.
|
||||
if (frame.message) {
|
||||
try {
|
||||
stdout.write(`\r\n[session] ${frame.message}\r\n`);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "state":
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Enter the alt-screen + raw mode, then wire the loop. We do this BEFORE the
|
||||
// WS opens so the first scrollback frame lands on a clean alt-screen.
|
||||
const enterPassthrough = (): void => {
|
||||
if (opts.printHint !== false) {
|
||||
try {
|
||||
stdout.write(
|
||||
`Attached to session ${opts.sessionId}. Press ${DETACH_CHORD_LABEL} to detach.\r\n`,
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
stdout.write(ALT_SCREEN_ENTER);
|
||||
enteredAltScreen = true;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (stdin.setRawMode) {
|
||||
try {
|
||||
stdin.setRawMode(true);
|
||||
rawModeSet = true;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
stdin.resume?.();
|
||||
|
||||
// stdin → input frames; detach chord intercepted.
|
||||
onStdinData = (chunk: Buffer | string): void => {
|
||||
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
||||
// Detach chord: if Ctrl-] appears, send any bytes before it, then detach.
|
||||
const idx = buf.indexOf(DETACH_CHORD_BYTE);
|
||||
if (idx !== -1) {
|
||||
if (idx > 0) {
|
||||
sendFrame({ type: "input", data: buf.subarray(0, idx).toString("base64") });
|
||||
}
|
||||
teardown();
|
||||
return;
|
||||
}
|
||||
sendFrame({ type: "input", data: buf.toString("base64") });
|
||||
};
|
||||
stdin.on("data", onStdinData);
|
||||
|
||||
// stdout resize → resize frames.
|
||||
onResize = (): void => {
|
||||
const cols = stdout.columns;
|
||||
const rows = stdout.rows;
|
||||
if (typeof cols === "number" && typeof rows === "number") {
|
||||
sendFrame({ type: "resize", cols, rows });
|
||||
}
|
||||
};
|
||||
stdout.on("resize", onResize);
|
||||
|
||||
// Send the initial size so the PTY matches the host TTY immediately.
|
||||
onResize();
|
||||
};
|
||||
|
||||
// ── Kick off: mint ticket, open WS, run the loop ──
|
||||
(async () => {
|
||||
let ticket: AttachTicketResponse;
|
||||
try {
|
||||
ticket = await fetchAttachTicket({
|
||||
baseUrl: opts.baseUrl,
|
||||
token: opts.token,
|
||||
sessionId: opts.sessionId,
|
||||
projectId: opts.projectId,
|
||||
fetchImpl: opts.fetchImpl,
|
||||
});
|
||||
} catch (err) {
|
||||
teardown(err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = buildWsUrl({
|
||||
baseUrl: opts.baseUrl,
|
||||
sessionId: opts.sessionId,
|
||||
ticket: ticket.ticket,
|
||||
});
|
||||
const headers: Record<string, string> = {};
|
||||
if (opts.token) headers.authorization = `Bearer ${opts.token}`;
|
||||
|
||||
try {
|
||||
ws = wsFactory(url, headers);
|
||||
} catch (err) {
|
||||
teardown(err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.on("open", () => {
|
||||
enterPassthrough();
|
||||
});
|
||||
ws.on("message", (data: unknown) => {
|
||||
let text: string;
|
||||
if (typeof data === "string") text = data;
|
||||
else if (Buffer.isBuffer(data)) text = data.toString("utf8");
|
||||
else if (data instanceof Uint8Array) text = Buffer.from(data).toString("utf8");
|
||||
else text = String(data);
|
||||
let frame: ServerFrame;
|
||||
try {
|
||||
frame = JSON.parse(text) as ServerFrame;
|
||||
} catch {
|
||||
return; // ignore malformed
|
||||
}
|
||||
handleServerFrame(frame);
|
||||
});
|
||||
ws.on("close", () => {
|
||||
// A close before any deliberate detach is treated as a clean end if the
|
||||
// server sent `exit` (already torn down), otherwise as a mid-attach drop.
|
||||
if (!settled) {
|
||||
teardown(new Error("Connection closed"));
|
||||
}
|
||||
});
|
||||
ws.on("error", (err: Error) => {
|
||||
teardown(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
})();
|
||||
|
||||
return {
|
||||
done,
|
||||
detach: () => teardown(),
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
createServer,
|
||||
AttachTicketStore,
|
||||
CliInputAttributionLog,
|
||||
CliConfirmAdvanceRegistry,
|
||||
GitHubClient,
|
||||
createSkillsAdapter,
|
||||
getCliPackageVersion,
|
||||
@@ -1741,9 +1744,33 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// to createServer — routes derived from getPluginRoutes() rely on it.
|
||||
await phaseTime("pluginLoadingPromise (await)", () => pluginLoadingPromise);
|
||||
|
||||
// ── CLI Agent Executor: hub resolver + session transport ─────────────
|
||||
//
|
||||
// The hook route validates a per-session token against the project's live
|
||||
// TelemetryHub; resolve it from that project's engine. The cli-sessions
|
||||
// transport (REST + WS attach) is supplied from the cwd project's runtime
|
||||
// (the canonical single-project surface) when the experimental flag is on.
|
||||
//
|
||||
const cliAgentHubResolver = (projectId: string | undefined, _sessionId: string) => {
|
||||
const engine = projectId ? engineManager.getEngine(projectId) : cwdEngine;
|
||||
return engine?.getCliAgentRuntime()?.bundle.hub;
|
||||
};
|
||||
const cwdCliAgentRuntime = cwdEngine?.getCliAgentRuntime();
|
||||
const cliSessionTransport = cwdCliAgentRuntime
|
||||
? {
|
||||
manager: cwdCliAgentRuntime.bundle.manager,
|
||||
store: cwdCliAgentRuntime.bundle.store,
|
||||
ticketStore: new AttachTicketStore(),
|
||||
attributionLog: new CliInputAttributionLog(),
|
||||
confirmAdvance: new CliConfirmAdvanceRegistry(),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
app = createServer(store, {
|
||||
engine: cwdEngine,
|
||||
engineManager,
|
||||
cliAgentHubResolver,
|
||||
cliSessionTransport,
|
||||
hybridExecutor,
|
||||
centralCore: centralCoreForEngine,
|
||||
authStorage: dashboardAuthStorage,
|
||||
|
||||
211
packages/core/src/__tests__/cli-session-store.test.ts
Normal file
211
packages/core/src/__tests__/cli-session-store.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
|
||||
import { CliSessionStore } from "../cli-session-store.js";
|
||||
import { Database } from "../db.js";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { rm } from "node:fs/promises";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-cli-session-store-test-"));
|
||||
}
|
||||
|
||||
describe("CliSessionStore", () => {
|
||||
let tmpDir: string;
|
||||
let fusionDir: string;
|
||||
let db: Database;
|
||||
let store: CliSessionStore;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = makeTmpDir();
|
||||
fusionDir = join(tmpDir, ".fusion");
|
||||
db = new Database(fusionDir, { inMemory: true });
|
||||
db.init();
|
||||
store = new CliSessionStore(fusionDir, db);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.exec("DELETE FROM cli_sessions");
|
||||
store.removeAllListeners();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
db.close();
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates and reads a session record", () => {
|
||||
const created = store.createSession({
|
||||
taskId: "FN-100",
|
||||
purpose: "execute",
|
||||
projectId: "proj-1",
|
||||
adapterId: "claude-local",
|
||||
worktreePath: "/tmp/wt/FN-100",
|
||||
autonomyPosture: { autoApprove: true, maxResumeAttempts: 3 },
|
||||
});
|
||||
|
||||
expect(created.id).toMatch(/^cli-/);
|
||||
expect(created.agentState).toBe("starting");
|
||||
expect(created.terminationReason).toBeNull();
|
||||
expect(created.resumeAttempts).toBe(0);
|
||||
expect(created.chatSessionId).toBeNull();
|
||||
expect(created.autonomyPosture).toEqual({ autoApprove: true, maxResumeAttempts: 3 });
|
||||
|
||||
const fetched = store.getSession(created.id);
|
||||
expect(fetched).toEqual(created);
|
||||
});
|
||||
|
||||
it("persists state transitions", () => {
|
||||
const s = store.createSession({
|
||||
taskId: "FN-101",
|
||||
purpose: "planning",
|
||||
projectId: "proj-1",
|
||||
adapterId: "codex-local",
|
||||
});
|
||||
|
||||
const states = ["ready", "busy", "waitingOnInput", "busy", "done"] as const;
|
||||
for (const state of states) {
|
||||
const updated = store.updateSession(s.id, { agentState: state });
|
||||
expect(updated?.agentState).toBe(state);
|
||||
// Persisted, not just returned.
|
||||
expect(store.getSession(s.id)?.agentState).toBe(state);
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips the native session id", () => {
|
||||
const s = store.createSession({
|
||||
taskId: "FN-102",
|
||||
purpose: "execute",
|
||||
projectId: "proj-1",
|
||||
adapterId: "claude-local",
|
||||
});
|
||||
expect(s.nativeSessionId).toBeNull();
|
||||
|
||||
store.updateSession(s.id, { nativeSessionId: "native-abc-123" });
|
||||
expect(store.getSession(s.id)?.nativeSessionId).toBe("native-abc-123");
|
||||
|
||||
// Reopen via a fresh store instance on the same DB to prove durability.
|
||||
const reopened = new CliSessionStore(fusionDir, db);
|
||||
expect(reopened.getSession(s.id)?.nativeSessionId).toBe("native-abc-123");
|
||||
});
|
||||
|
||||
it("updates terminationReason and resumeAttempts atomically with state", () => {
|
||||
const s = store.createSession({
|
||||
taskId: "FN-103",
|
||||
purpose: "validator",
|
||||
projectId: "proj-1",
|
||||
adapterId: "claude-local",
|
||||
});
|
||||
|
||||
const updated = store.updateSession(s.id, {
|
||||
agentState: "dead",
|
||||
terminationReason: "crashed",
|
||||
resumeAttempts: 2,
|
||||
});
|
||||
|
||||
expect(updated?.agentState).toBe("dead");
|
||||
expect(updated?.terminationReason).toBe("crashed");
|
||||
expect(updated?.resumeAttempts).toBe(2);
|
||||
|
||||
const persisted = store.getSession(s.id)!;
|
||||
expect(persisted.agentState).toBe("dead");
|
||||
expect(persisted.terminationReason).toBe("crashed");
|
||||
expect(persisted.resumeAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it("clears terminationReason when set back to null", () => {
|
||||
const s = store.createSession({
|
||||
taskId: "FN-104",
|
||||
purpose: "execute",
|
||||
projectId: "proj-1",
|
||||
adapterId: "claude-local",
|
||||
agentState: "dead",
|
||||
terminationReason: "killed",
|
||||
});
|
||||
expect(s.terminationReason).toBe("killed");
|
||||
|
||||
store.updateSession(s.id, { agentState: "starting", terminationReason: null });
|
||||
const persisted = store.getSession(s.id)!;
|
||||
expect(persisted.terminationReason).toBeNull();
|
||||
expect(persisted.agentState).toBe("starting");
|
||||
});
|
||||
|
||||
it("queries sessions by task and by chat entity", () => {
|
||||
store.createSession({ taskId: "FN-200", purpose: "execute", projectId: "p", adapterId: "a" });
|
||||
store.createSession({ taskId: "FN-200", purpose: "validator", projectId: "p", adapterId: "a" });
|
||||
store.createSession({ taskId: "FN-201", purpose: "execute", projectId: "p", adapterId: "a" });
|
||||
store.createSession({ chatSessionId: "chat-xyz", purpose: "chat", projectId: "p", adapterId: "a" });
|
||||
|
||||
expect(store.listByTask("FN-200")).toHaveLength(2);
|
||||
expect(store.listByTask("FN-201")).toHaveLength(1);
|
||||
expect(store.listByTask("FN-999")).toHaveLength(0);
|
||||
|
||||
const chatSessions = store.listByChatSession("chat-xyz");
|
||||
expect(chatSessions).toHaveLength(1);
|
||||
expect(chatSessions[0].purpose).toBe("chat");
|
||||
});
|
||||
|
||||
it("filters by projectId and agentState", () => {
|
||||
store.createSession({ taskId: "FN-300", purpose: "execute", projectId: "pA", adapterId: "a", agentState: "busy" });
|
||||
store.createSession({ taskId: "FN-301", purpose: "execute", projectId: "pA", adapterId: "a", agentState: "done" });
|
||||
store.createSession({ taskId: "FN-302", purpose: "execute", projectId: "pB", adapterId: "a", agentState: "busy" });
|
||||
|
||||
expect(store.listSessions({ projectId: "pA" })).toHaveLength(2);
|
||||
expect(store.listSessions({ projectId: "pA", agentState: "busy" })).toHaveLength(1);
|
||||
expect(store.listSessions({ agentState: "busy" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects an invalid agent state at the store boundary", () => {
|
||||
const s = store.createSession({
|
||||
taskId: "FN-400",
|
||||
purpose: "execute",
|
||||
projectId: "p",
|
||||
adapterId: "a",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
// @ts-expect-error invalid state value rejected at runtime
|
||||
store.updateSession(s.id, { agentState: "bogus" }),
|
||||
).toThrow(/Invalid CLI agent state/);
|
||||
|
||||
expect(() =>
|
||||
// @ts-expect-error invalid state value rejected at runtime
|
||||
store.createSession({ purpose: "execute", projectId: "p", adapterId: "a", agentState: "nope" }),
|
||||
).toThrow(/Invalid CLI agent state/);
|
||||
|
||||
// The original record was untouched by the failed update.
|
||||
expect(store.getSession(s.id)?.agentState).toBe("starting");
|
||||
});
|
||||
|
||||
it("rejects an invalid purpose and termination reason at the store boundary", () => {
|
||||
expect(() =>
|
||||
// @ts-expect-error invalid purpose rejected at runtime
|
||||
store.createSession({ purpose: "wat", projectId: "p", adapterId: "a" }),
|
||||
).toThrow(/Invalid CLI session purpose/);
|
||||
|
||||
const s = store.createSession({ taskId: "FN-401", purpose: "execute", projectId: "p", adapterId: "a" });
|
||||
expect(() =>
|
||||
// @ts-expect-error invalid termination reason rejected at runtime
|
||||
store.updateSession(s.id, { terminationReason: "exploded" }),
|
||||
).toThrow(/Invalid CLI termination reason/);
|
||||
});
|
||||
|
||||
it("emits create/update/delete events", () => {
|
||||
const events: string[] = [];
|
||||
store.on("cli-session:created", () => events.push("created"));
|
||||
store.on("cli-session:updated", () => events.push("updated"));
|
||||
store.on("cli-session:deleted", () => events.push("deleted"));
|
||||
|
||||
const s = store.createSession({ taskId: "FN-500", purpose: "ce", projectId: "p", adapterId: "a" });
|
||||
store.updateSession(s.id, { agentState: "ready" });
|
||||
expect(store.deleteSession(s.id)).toBe(true);
|
||||
expect(store.getSession(s.id)).toBeUndefined();
|
||||
|
||||
expect(events).toEqual(["created", "updated", "deleted"]);
|
||||
});
|
||||
|
||||
it("returns undefined when updating a missing session and false when deleting one", () => {
|
||||
expect(store.updateSession("cli-missing", { agentState: "ready" })).toBeUndefined();
|
||||
expect(store.deleteSession("cli-missing")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -715,7 +715,8 @@ describe("schema migration", () => {
|
||||
|
||||
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
|
||||
expect(row.deletedAt).toBeNull();
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -748,7 +749,8 @@ describe("schema migration", () => {
|
||||
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
|
||||
{ id: "WS-002", mode: "script", gateMode: "advisory" },
|
||||
]);
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -798,7 +800,8 @@ describe("schema migration", () => {
|
||||
reviewerContextRetryCount: 0,
|
||||
reviewerFallbackRetryCount: 0,
|
||||
});
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -827,7 +830,8 @@ describe("schema migration", () => {
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -868,7 +872,8 @@ describe("schema migration", () => {
|
||||
const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>;
|
||||
expect(missionColumns.map((column) => column.name)).toContain("autoMerge");
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -902,7 +907,8 @@ describe("schema migration", () => {
|
||||
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
|
||||
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
|
||||
]);
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -939,7 +945,8 @@ describe("schema migration", () => {
|
||||
|
||||
const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>;
|
||||
expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1000,7 +1007,7 @@ describe("schema migration", () => {
|
||||
expect(customFieldsColumn).toBeDefined();
|
||||
expect(customFieldsColumn?.dflt_value).toBe("'{}'");
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -1025,6 +1032,44 @@ describe("schema migration", () => {
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
|
||||
expect(tables.map((row) => row.name)).toContain("workflow_settings");
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(workflow_settings)").all() as Array<{
|
||||
name: string;
|
||||
pk: number;
|
||||
dflt_value: string | null;
|
||||
}>;
|
||||
expect(columns.map((column) => column.name)).toEqual(["workflowId", "projectId", "values", "updatedAt"]);
|
||||
expect(columns.filter((column) => column.pk > 0).map((column) => column.name).sort()).toEqual(["projectId", "workflowId"]);
|
||||
const valuesColumn = columns.find((column) => column.name === "values");
|
||||
expect(valuesColumn?.dflt_value).toBe("'{}'");
|
||||
|
||||
const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>;
|
||||
expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true);
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("adds cli_sessions table + indexes when migrating from schema version 108", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '108')");
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
"column" TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
db.init();
|
||||
|
||||
// The new per-(workflowId, projectId) setting-value table exists.
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
|
||||
expect(tables.map((row) => row.name)).toContain("workflow_settings");
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(workflow_settings)").all() as Array<{
|
||||
name: string;
|
||||
pk: number;
|
||||
@@ -1050,7 +1095,81 @@ describe("schema migration", () => {
|
||||
const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>;
|
||||
expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true);
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
// The durable CLI-session record table exists.
|
||||
const cliTables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
|
||||
expect(cliTables.map((row) => row.name)).toContain("cli_sessions");
|
||||
|
||||
const cliSessionColumns = db
|
||||
.prepare("PRAGMA table_info(cli_sessions)")
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(cliSessionColumns.map((column) => column.name)).toEqual([
|
||||
"id",
|
||||
"taskId",
|
||||
"chatSessionId",
|
||||
"purpose",
|
||||
"projectId",
|
||||
"adapterId",
|
||||
"agentState",
|
||||
"terminationReason",
|
||||
"nativeSessionId",
|
||||
"resumeAttempts",
|
||||
"autonomyPosture",
|
||||
"worktreePath",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
]);
|
||||
|
||||
const cliSessionIndexes = db
|
||||
.prepare("PRAGMA index_list(cli_sessions)")
|
||||
.all() as Array<{ name: string }>;
|
||||
const indexNames = cliSessionIndexes.map((index) => index.name);
|
||||
expect(indexNames).toContain("idx_cli_sessions_taskId");
|
||||
expect(indexNames).toContain("idx_cli_sessions_chatSessionId");
|
||||
expect(indexNames).toContain("idx_cli_sessions_project_state");
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("adds cliExecutorAdapterId to chat_sessions when migrating from schema version 109", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '109')");
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
agentId TEXT NOT NULL,
|
||||
title TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
projectId TEXT,
|
||||
modelProvider TEXT,
|
||||
modelId TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
cliSessionFile TEXT,
|
||||
inFlightGeneration TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
db.init();
|
||||
|
||||
const columns = db
|
||||
.prepare("PRAGMA table_info(chat_sessions)")
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId");
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("creates cli_sessions on a fresh database (fresh-create path)", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
|
||||
expect(tables.map((row) => row.name)).toContain("cli_sessions");
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -1107,20 +1226,23 @@ describe("schema migration", () => {
|
||||
.get() as { migrated_fragment_id: string | null };
|
||||
expect(stepRow.migrated_fragment_id).toBeNull();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("migration 109 is idempotent on re-init", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
db.close();
|
||||
|
||||
// Re-open the same on-disk DB: already at 109, the 109 block must be a no-op.
|
||||
const reopened = new Database(fusionDir);
|
||||
reopened.init();
|
||||
expect(reopened.getSchemaVersion()).toBe(110);
|
||||
expect(reopened.getSchemaVersion()).toBe(112);
|
||||
expect(reopened.getSchemaVersion()).toBe(112);
|
||||
const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>;
|
||||
expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1);
|
||||
const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>;
|
||||
|
||||
@@ -334,7 +334,8 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
});
|
||||
|
||||
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
|
||||
@@ -393,7 +394,8 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
});
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
// Update the config
|
||||
@@ -1463,7 +1465,8 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1488,11 +1491,16 @@ describe("schema migrations", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1527,7 +1535,8 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("priority");
|
||||
@@ -1568,7 +1577,8 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1640,7 +1650,8 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1880,7 +1891,8 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||
@@ -1954,7 +1966,8 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -1978,7 +1991,8 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -2082,7 +2096,8 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -2301,7 +2316,8 @@ describe("schema migrations", () => {
|
||||
|
||||
localDb.init();
|
||||
|
||||
expect(localDb.getSchemaVersion()).toBe(110);
|
||||
expect(localDb.getSchemaVersion()).toBe(112);
|
||||
expect(localDb.getSchemaVersion()).toBe(112);
|
||||
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
|
||||
|
||||
@@ -2612,7 +2628,8 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
@@ -2766,7 +2783,8 @@ describe("migration v77 task token budget columns", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(110);
|
||||
expect(migrated.getSchemaVersion()).toBe(112);
|
||||
expect(migrated.getSchemaVersion()).toBe(112);
|
||||
const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const names = new Set(rows.map((row) => row.name));
|
||||
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
|
||||
@@ -2797,7 +2815,8 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
|
||||
const fresh = new Database(fusion);
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(110);
|
||||
expect(fresh.getSchemaVersion()).toBe(112);
|
||||
expect(fresh.getSchemaVersion()).toBe(112);
|
||||
const names = new Set(
|
||||
(fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
|
||||
);
|
||||
@@ -2825,7 +2844,8 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(110);
|
||||
expect(migrated.getSchemaVersion()).toBe(112);
|
||||
expect(migrated.getSchemaVersion()).toBe(112);
|
||||
const names = new Set(
|
||||
(migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
|
||||
);
|
||||
@@ -2851,7 +2871,8 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
|
||||
const fresh = new Database(fusion);
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(110);
|
||||
expect(fresh.getSchemaVersion()).toBe(112);
|
||||
expect(fresh.getSchemaVersion()).toBe(112);
|
||||
const table = fresh
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
|
||||
.get() as { name: string } | undefined;
|
||||
@@ -2885,7 +2906,8 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(110);
|
||||
expect(migrated.getSchemaVersion()).toBe(112);
|
||||
expect(migrated.getSchemaVersion()).toBe(112);
|
||||
const table = migrated
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
|
||||
.get() as { name: string } | undefined;
|
||||
@@ -2926,7 +2948,8 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(110);
|
||||
expect(migrated.getSchemaVersion()).toBe(112);
|
||||
expect(migrated.getSchemaVersion()).toBe(112);
|
||||
const tables = migrated
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
@@ -2953,7 +2976,8 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(110);
|
||||
expect(fresh.getSchemaVersion()).toBe(112);
|
||||
expect(fresh.getSchemaVersion()).toBe(112);
|
||||
const tables = fresh
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
134
packages/core/src/__tests__/global-settings-cli-agents.test.ts
Normal file
134
packages/core/src/__tests__/global-settings-cli-agents.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* cliAgents global-settings slice (U15): round-trip with defaults merge +
|
||||
* invalid-dropped-at-the-write-boundary behavior.
|
||||
*/
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { GlobalSettingsStore } from "../global-settings.js";
|
||||
import { sanitizeCliAgentsSettings, sanitizeCliAgentSettings } from "../settings-schema.js";
|
||||
|
||||
describe("sanitizeCliAgentSettings (write-boundary validation)", () => {
|
||||
it("keeps valid fields and trims strings", () => {
|
||||
expect(
|
||||
sanitizeCliAgentSettings({
|
||||
commandOverride: " /opt/claude ",
|
||||
extraArgs: [" --foo ", "", "bar"],
|
||||
envAdditions: ["MY_VAR", " ", "OTHER"],
|
||||
autonomyMode: "elevated",
|
||||
}),
|
||||
).toEqual({
|
||||
commandOverride: "/opt/claude",
|
||||
extraArgs: ["--foo", "bar"],
|
||||
envAdditions: ["MY_VAR", "OTHER"],
|
||||
autonomyMode: "elevated",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops unknown fields and invalid values", () => {
|
||||
expect(
|
||||
sanitizeCliAgentSettings({
|
||||
commandOverride: 42,
|
||||
extraArgs: "not-an-array",
|
||||
envAdditions: [1, 2, 3],
|
||||
autonomyMode: "godmode",
|
||||
bogus: "x",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops empty-after-trim command override", () => {
|
||||
expect(sanitizeCliAgentSettings({ commandOverride: " " })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeCliAgentsSettings", () => {
|
||||
it("drops unknown adapter ids", () => {
|
||||
const out = sanitizeCliAgentsSettings({
|
||||
"claude-code": { autonomyMode: "elevated" },
|
||||
"totally-made-up": { autonomyMode: "elevated" },
|
||||
});
|
||||
expect(Object.keys(out)).toEqual(["claude-code"]);
|
||||
});
|
||||
|
||||
it("returns empty object for non-objects", () => {
|
||||
expect(sanitizeCliAgentsSettings(null)).toEqual({});
|
||||
expect(sanitizeCliAgentsSettings([1, 2])).toEqual({});
|
||||
expect(sanitizeCliAgentsSettings("x")).toEqual({});
|
||||
});
|
||||
|
||||
it("omits adapter entries that sanitize to nothing", () => {
|
||||
const out = sanitizeCliAgentsSettings({
|
||||
codex: { autonomyMode: "garbage" },
|
||||
pi: { extraArgs: ["--ok"] },
|
||||
});
|
||||
expect(out).toEqual({ pi: { extraArgs: ["--ok"] } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GlobalSettingsStore cliAgents round-trip", () => {
|
||||
let dir: string;
|
||||
let store: GlobalSettingsStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "fusion-cli-agents-"));
|
||||
store = new GlobalSettingsStore(dir);
|
||||
await store.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("defaults cliAgents to an empty object", async () => {
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.cliAgents).toEqual({});
|
||||
});
|
||||
|
||||
it("persists a valid adapter config across a fresh read", async () => {
|
||||
await store.updateSettings({
|
||||
cliAgents: {
|
||||
"claude-code": {
|
||||
commandOverride: "/usr/local/bin/claude",
|
||||
extraArgs: ["--verbose"],
|
||||
autonomyMode: "elevated",
|
||||
envAdditions: ["HTTP_PROXY"],
|
||||
},
|
||||
},
|
||||
});
|
||||
store.invalidateCache();
|
||||
const reread = await store.getSettings();
|
||||
expect(reread.cliAgents).toEqual({
|
||||
"claude-code": {
|
||||
commandOverride: "/usr/local/bin/claude",
|
||||
extraArgs: ["--verbose"],
|
||||
autonomyMode: "elevated",
|
||||
envAdditions: ["HTTP_PROXY"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("drops invalid adapter ids and fields at the write boundary", async () => {
|
||||
await store.updateSettings({
|
||||
cliAgents: {
|
||||
// unknown adapter id → dropped
|
||||
"evil-adapter": { autonomyMode: "elevated" },
|
||||
// valid adapter, junk autonomyMode dropped, valid extraArgs kept
|
||||
codex: { autonomyMode: "yolo", extraArgs: ["--model=gpt"] },
|
||||
} as never,
|
||||
});
|
||||
store.invalidateCache();
|
||||
const reread = await store.getSettings();
|
||||
expect(reread.cliAgents).toEqual({ codex: { extraArgs: ["--model=gpt"] } });
|
||||
});
|
||||
|
||||
it("merges per-adapter without dropping unrelated global keys", async () => {
|
||||
await store.updateSettings({ themeMode: "light" });
|
||||
await store.updateSettings({ cliAgents: { pi: { extraArgs: ["--tools=read"] } } });
|
||||
store.invalidateCache();
|
||||
const reread = await store.getSettings();
|
||||
expect(reread.themeMode).toBe("light");
|
||||
expect(reread.cliAgents).toEqual({ pi: { extraArgs: ["--tools=read"] } });
|
||||
});
|
||||
});
|
||||
@@ -90,7 +90,7 @@ describe("goals schema", () => {
|
||||
expect(table?.name).toBe("goals");
|
||||
});
|
||||
|
||||
it("reports schema version 110", () => {
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
it("reports schema version 101", () => {
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
||||
const db1 = createDatabase(legacyDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(110);
|
||||
expect(db1.getSchemaVersion()).toBe(112);
|
||||
db1.close();
|
||||
|
||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||
@@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
||||
// Now run init — this triggers the v32→v33 migration
|
||||
db3.init();
|
||||
expect(db3.getSchemaVersion()).toBe(110);
|
||||
expect(db3.getSchemaVersion()).toBe(112);
|
||||
|
||||
// Step 4: Verify insight tables exist after migration
|
||||
const tablesAfter = db3.prepare(
|
||||
@@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
try {
|
||||
const db1 = createDatabase(testDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(110);
|
||||
expect(db1.getSchemaVersion()).toBe(112);
|
||||
db1.close();
|
||||
|
||||
const db2 = createDatabase(testDir);
|
||||
expect(() => db2.init()).not.toThrow();
|
||||
expect(db2.getSchemaVersion()).toBe(110);
|
||||
expect(db2.getSchemaVersion()).toBe(112);
|
||||
db2.close();
|
||||
} finally {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
@@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh DB and run migrations
|
||||
const db1 = createDatabase(compatDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(110);
|
||||
expect(db1.getSchemaVersion()).toBe(112);
|
||||
|
||||
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
|
||||
// table without them. This simulates a DB that was created before the
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => {
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]);
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
});
|
||||
|
||||
it("upserts merge request records", async () => {
|
||||
|
||||
@@ -3745,8 +3745,8 @@ describe("MissionStore", () => {
|
||||
// ── Loop State & Validator Run Schema Tests ───────────────────────────
|
||||
|
||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||
it("schema version is 110 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
it("schema version is 101 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
});
|
||||
|
||||
it("mission_features table has loop state columns", () => {
|
||||
|
||||
38
packages/core/src/__tests__/redact-secrets.test.ts
Normal file
38
packages/core/src/__tests__/redact-secrets.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { redactSecrets } from "../redact-secrets.js";
|
||||
|
||||
// Parity fixtures mirror the original ACP plugin's process-manager tests so the
|
||||
// shared implementation produces identical behavior (Risk S8).
|
||||
describe("redactSecrets (shared @fusion/core)", () => {
|
||||
it("redacts bearer tokens", () => {
|
||||
const out = redactSecrets("Authorization: Bearer sk-live-ABCDEFG1234567890abcdef");
|
||||
expect(out).not.toContain("sk-live-ABCDEFG1234567890abcdef");
|
||||
expect(out).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts key=/token= assignments", () => {
|
||||
const out = redactSecrets("api_key=abcdef0123456789 token=ZZZ987654321");
|
||||
expect(out).not.toContain("abcdef0123456789");
|
||||
expect(out).not.toContain("ZZZ987654321");
|
||||
});
|
||||
|
||||
it("redacts long opaque hex/base64 secrets", () => {
|
||||
const out = redactSecrets("value 0123456789abcdef0123456789abcdef done");
|
||||
expect(out).not.toContain("0123456789abcdef0123456789abcdef");
|
||||
});
|
||||
|
||||
it("leaves benign text intact", () => {
|
||||
expect(redactSecrets("hello world")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("redacts standalone sk-/ghp_/AKIA opaque tokens", () => {
|
||||
const out = redactSecrets("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789");
|
||||
expect(out).toBe("[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts quoted secret assignments", () => {
|
||||
const out = redactSecrets('client_secret="topsecretvalue123"');
|
||||
expect(out).not.toContain("topsecretvalue123");
|
||||
expect(out).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
@@ -583,8 +583,8 @@ describe("Run Audit", () => {
|
||||
expect(indexNames).toContain("idxRunAuditEventsTimestamp");
|
||||
});
|
||||
|
||||
it("schema version is bumped to 109", () => {
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => {
|
||||
expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]),
|
||||
);
|
||||
|
||||
expect(store.getDatabase().getSchemaVersion()).toBe(110);
|
||||
expect(store.getDatabase().getSchemaVersion()).toBe(112);
|
||||
});
|
||||
|
||||
it("migrates a legacy v88 database and preserves task rows", async () => {
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(110);
|
||||
expect(db.getSchemaVersion()).toBe(112);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
@@ -86,6 +86,7 @@ interface ChatSessionRow {
|
||||
updatedAt: string;
|
||||
cliSessionFile: string | null;
|
||||
inFlightGeneration: string | null;
|
||||
cliExecutorAdapterId: string | null;
|
||||
}
|
||||
|
||||
/** Database row shape for chat_messages. */
|
||||
@@ -161,6 +162,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
updatedAt: row.updatedAt,
|
||||
cliSessionFile: row.cliSessionFile ?? null,
|
||||
inFlightGeneration: fromJson<ChatInFlightGenerationState>(row.inFlightGeneration) ?? null,
|
||||
cliExecutorAdapterId: row.cliExecutorAdapterId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,11 +256,12 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
updatedAt: now,
|
||||
cliSessionFile: null,
|
||||
inFlightGeneration: null,
|
||||
cliExecutorAdapterId: input.cliExecutorAdapterId ?? null,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration, cliExecutorAdapterId)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
session.id,
|
||||
session.agentId,
|
||||
@@ -270,6 +273,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
session.createdAt,
|
||||
session.updatedAt,
|
||||
null,
|
||||
session.cliExecutorAdapterId,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
@@ -466,6 +470,27 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
this.db.bumpLastModified();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or clear) the cli-agent adapter that backs this chat session (U12).
|
||||
* When set, the chat is CLI-backed: composer sends route through the inject
|
||||
* path and adapter transcript events map to chat_messages rows. Emits a
|
||||
* session update so the client can switch to the CLI-backed rendering path.
|
||||
*
|
||||
* @param id - Session ID
|
||||
* @param adapterId - cli-agent adapter id, or null to revert to the provider path
|
||||
*/
|
||||
setCliExecutorAdapterId(id: string, adapterId: string | null): ChatSession | undefined {
|
||||
const existing = this.getSession(id);
|
||||
if (!existing) return undefined;
|
||||
this.db
|
||||
.prepare("UPDATE chat_sessions SET cliExecutorAdapterId = ?, updatedAt = ? WHERE id = ?")
|
||||
.run(adapterId, new Date().toISOString(), id);
|
||||
this.db.bumpLastModified();
|
||||
const updated = this.getSession(id)!;
|
||||
this.emit("chat:session:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
setInFlightGeneration(id: string, inFlightGeneration: ChatInFlightGenerationState | null): ChatSession | undefined {
|
||||
const existing = this.getSession(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
@@ -68,6 +68,13 @@ export interface ChatSession {
|
||||
* for sessions that have never produced an assistant reply.
|
||||
*/
|
||||
cliSessionFile: string | null;
|
||||
/**
|
||||
* cli-agent adapter id backing this chat session (CLI Agent Executor, U12).
|
||||
* When non-null the chat is CLI-backed: composer sends inject into a live
|
||||
* CLI session and adapter transcript events map to chat_messages rows. Null
|
||||
* means the chat uses the standard model-provider path.
|
||||
*/
|
||||
cliExecutorAdapterId: string | null;
|
||||
/** Durable in-flight assistant snapshot used to recover streaming UI after refresh. */
|
||||
inFlightGeneration: ChatInFlightGenerationState | null;
|
||||
}
|
||||
@@ -160,6 +167,8 @@ export interface ChatSessionCreateInput {
|
||||
modelProvider?: string | null;
|
||||
/** Optional model ID override */
|
||||
modelId?: string | null;
|
||||
/** Optional cli-agent adapter id; when set the chat is CLI-backed (U12) */
|
||||
cliExecutorAdapterId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
336
packages/core/src/cli-session-store.ts
Normal file
336
packages/core/src/cli-session-store.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* CliSessionStore - Data layer for durable CLI agent session records
|
||||
* (CLI Agent Executor, U1).
|
||||
*
|
||||
* Manages CRUD for the `cli_sessions` table: the long-lived record that
|
||||
* survives executor restarts so a session can be reasoned about, resumed,
|
||||
* or reaped from its persisted state.
|
||||
*
|
||||
* Follows the same patterns as ChatStore:
|
||||
* - EventEmitter for change notifications.
|
||||
* - SQLite for structured data storage.
|
||||
* - JSON columns for nested data (autonomyPosture).
|
||||
* - Validation at the store boundary: invalid enum values are rejected.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Database } from "./db.js";
|
||||
import { fromJson, toJsonNullable } from "./db.js";
|
||||
import {
|
||||
isCliAgentState,
|
||||
isCliSessionPurpose,
|
||||
isCliTerminationReason,
|
||||
type CliAgentState,
|
||||
type CliAutonomyPosture,
|
||||
type CliSession,
|
||||
type CliSessionCreateInput,
|
||||
type CliSessionPurpose,
|
||||
type CliSessionUpdateInput,
|
||||
type CliTerminationReason,
|
||||
} from "./cli-session-types.js";
|
||||
|
||||
// ── Event Types ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface CliSessionStoreEvents {
|
||||
/** Emitted when a CLI session record is created. */
|
||||
"cli-session:created": [session: CliSession];
|
||||
/** Emitted when a CLI session record is updated. */
|
||||
"cli-session:updated": [session: CliSession];
|
||||
/** Emitted when a CLI session record is deleted. */
|
||||
"cli-session:deleted": [sessionId: string];
|
||||
}
|
||||
|
||||
// ── Row Interface ────────────────────────────────────────────────────────
|
||||
|
||||
/** Database row shape for cli_sessions. */
|
||||
interface CliSessionRow {
|
||||
id: string;
|
||||
taskId: string | null;
|
||||
chatSessionId: string | null;
|
||||
purpose: string;
|
||||
projectId: string;
|
||||
adapterId: string;
|
||||
agentState: string;
|
||||
terminationReason: string | null;
|
||||
nativeSessionId: string | null;
|
||||
resumeAttempts: number;
|
||||
autonomyPosture: string | null;
|
||||
worktreePath: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── CliSessionStore Class ────────────────────────────────────────────────
|
||||
|
||||
export class CliSessionStore extends EventEmitter<CliSessionStoreEvents> {
|
||||
constructor(
|
||||
private fusionDir: string,
|
||||
private db: Database,
|
||||
) {
|
||||
super();
|
||||
this.setMaxListeners(100);
|
||||
}
|
||||
|
||||
// ── Row-to-Object Converter ──────────────────────────────────────────
|
||||
|
||||
private rowToSession(row: CliSessionRow): CliSession {
|
||||
return {
|
||||
id: row.id,
|
||||
taskId: row.taskId ?? null,
|
||||
chatSessionId: row.chatSessionId ?? null,
|
||||
purpose: row.purpose as CliSessionPurpose,
|
||||
projectId: row.projectId,
|
||||
adapterId: row.adapterId,
|
||||
agentState: row.agentState as CliAgentState,
|
||||
terminationReason: (row.terminationReason as CliTerminationReason | null) ?? null,
|
||||
nativeSessionId: row.nativeSessionId ?? null,
|
||||
resumeAttempts: row.resumeAttempts ?? 0,
|
||||
autonomyPosture: fromJson<CliAutonomyPosture>(row.autonomyPosture) ?? null,
|
||||
worktreePath: row.worktreePath ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Boundary validation ──────────────────────────────────────────────
|
||||
|
||||
private assertAgentState(value: unknown): asserts value is CliAgentState {
|
||||
if (!isCliAgentState(value)) {
|
||||
throw new Error(`Invalid CLI agent state: ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private assertPurpose(value: unknown): asserts value is CliSessionPurpose {
|
||||
if (!isCliSessionPurpose(value)) {
|
||||
throw new Error(`Invalid CLI session purpose: ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private assertTerminationReason(
|
||||
value: unknown,
|
||||
): asserts value is CliTerminationReason | null {
|
||||
if (value === null || value === undefined) return;
|
||||
if (!isCliTerminationReason(value)) {
|
||||
throw new Error(`Invalid CLI termination reason: ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CRUD Operations ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new CLI session record.
|
||||
*
|
||||
* @throws Error if any enum value (purpose / agentState / terminationReason)
|
||||
* is invalid, or required fields are missing.
|
||||
*/
|
||||
createSession(input: CliSessionCreateInput): CliSession {
|
||||
this.assertPurpose(input.purpose);
|
||||
const agentState: CliAgentState = input.agentState ?? "starting";
|
||||
this.assertAgentState(agentState);
|
||||
this.assertTerminationReason(input.terminationReason ?? null);
|
||||
|
||||
if (!input.projectId) {
|
||||
throw new Error("CLI session requires a projectId");
|
||||
}
|
||||
if (!input.adapterId) {
|
||||
throw new Error("CLI session requires an adapterId");
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const id = input.id ?? `cli-${randomUUID().slice(0, 8)}`;
|
||||
const resumeAttempts = input.resumeAttempts ?? 0;
|
||||
|
||||
const session: CliSession = {
|
||||
id,
|
||||
taskId: input.taskId ?? null,
|
||||
chatSessionId: input.chatSessionId ?? null,
|
||||
purpose: input.purpose,
|
||||
projectId: input.projectId,
|
||||
adapterId: input.adapterId,
|
||||
agentState,
|
||||
terminationReason: input.terminationReason ?? null,
|
||||
nativeSessionId: input.nativeSessionId ?? null,
|
||||
resumeAttempts,
|
||||
autonomyPosture: input.autonomyPosture ?? null,
|
||||
worktreePath: input.worktreePath ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO cli_sessions (
|
||||
id, taskId, chatSessionId, purpose, projectId, adapterId,
|
||||
agentState, terminationReason, nativeSessionId, resumeAttempts,
|
||||
autonomyPosture, worktreePath, createdAt, updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
session.id,
|
||||
session.taskId,
|
||||
session.chatSessionId,
|
||||
session.purpose,
|
||||
session.projectId,
|
||||
session.adapterId,
|
||||
session.agentState,
|
||||
session.terminationReason,
|
||||
session.nativeSessionId,
|
||||
session.resumeAttempts,
|
||||
toJsonNullable(session.autonomyPosture),
|
||||
session.worktreePath,
|
||||
session.createdAt,
|
||||
session.updatedAt,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("cli-session:created", session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Get a CLI session record by ID. */
|
||||
getSession(id: string): CliSession | undefined {
|
||||
const row = this.db
|
||||
.prepare("SELECT * FROM cli_sessions WHERE id = ?")
|
||||
.get(id) as unknown as CliSessionRow | undefined;
|
||||
if (!row) return undefined;
|
||||
return this.rowToSession(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* List CLI session records with optional filtering.
|
||||
*
|
||||
* @returns Array of sessions ordered by updatedAt DESC.
|
||||
*/
|
||||
listSessions(options?: {
|
||||
taskId?: string;
|
||||
chatSessionId?: string;
|
||||
projectId?: string;
|
||||
agentState?: CliAgentState;
|
||||
purpose?: CliSessionPurpose;
|
||||
}): CliSession[] {
|
||||
const whereClauses: string[] = [];
|
||||
const params: string[] = [];
|
||||
|
||||
if (options?.taskId !== undefined) {
|
||||
whereClauses.push("taskId = ?");
|
||||
params.push(options.taskId);
|
||||
}
|
||||
if (options?.chatSessionId !== undefined) {
|
||||
whereClauses.push("chatSessionId = ?");
|
||||
params.push(options.chatSessionId);
|
||||
}
|
||||
if (options?.projectId !== undefined) {
|
||||
whereClauses.push("projectId = ?");
|
||||
params.push(options.projectId);
|
||||
}
|
||||
if (options?.agentState !== undefined) {
|
||||
this.assertAgentState(options.agentState);
|
||||
whereClauses.push("agentState = ?");
|
||||
params.push(options.agentState);
|
||||
}
|
||||
if (options?.purpose !== undefined) {
|
||||
this.assertPurpose(options.purpose);
|
||||
whereClauses.push("purpose = ?");
|
||||
params.push(options.purpose);
|
||||
}
|
||||
|
||||
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM cli_sessions ${whereSql} ORDER BY updatedAt DESC`)
|
||||
.all(...params);
|
||||
|
||||
return (rows as unknown as CliSessionRow[]).map((row) => this.rowToSession(row));
|
||||
}
|
||||
|
||||
/** List CLI session records owned by a task. */
|
||||
listByTask(taskId: string): CliSession[] {
|
||||
return this.listSessions({ taskId });
|
||||
}
|
||||
|
||||
/** List CLI session records owned by a chat session. */
|
||||
listByChatSession(chatSessionId: string): CliSession[] {
|
||||
return this.listSessions({ chatSessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a CLI session record.
|
||||
*
|
||||
* State, terminationReason, and resumeAttempts are written atomically in a
|
||||
* single UPDATE statement, so a state transition that also records why the
|
||||
* session ended and how many resumes were attempted cannot tear.
|
||||
*
|
||||
* @throws Error if any provided enum value is invalid.
|
||||
* @returns The updated session, or undefined if not found.
|
||||
*/
|
||||
updateSession(id: string, input: CliSessionUpdateInput): CliSession | undefined {
|
||||
const existing = this.getSession(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
if (input.agentState !== undefined) {
|
||||
this.assertAgentState(input.agentState);
|
||||
}
|
||||
if (input.terminationReason !== undefined) {
|
||||
this.assertTerminationReason(input.terminationReason);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const setClauses: string[] = ["updatedAt = ?"];
|
||||
const params: (string | number | null)[] = [now];
|
||||
|
||||
if (input.taskId !== undefined) {
|
||||
setClauses.push("taskId = ?");
|
||||
params.push(input.taskId);
|
||||
}
|
||||
if (input.chatSessionId !== undefined) {
|
||||
setClauses.push("chatSessionId = ?");
|
||||
params.push(input.chatSessionId);
|
||||
}
|
||||
if (input.agentState !== undefined) {
|
||||
setClauses.push("agentState = ?");
|
||||
params.push(input.agentState);
|
||||
}
|
||||
if (input.terminationReason !== undefined) {
|
||||
setClauses.push("terminationReason = ?");
|
||||
params.push(input.terminationReason);
|
||||
}
|
||||
if (input.nativeSessionId !== undefined) {
|
||||
setClauses.push("nativeSessionId = ?");
|
||||
params.push(input.nativeSessionId);
|
||||
}
|
||||
if (input.resumeAttempts !== undefined) {
|
||||
setClauses.push("resumeAttempts = ?");
|
||||
params.push(input.resumeAttempts);
|
||||
}
|
||||
if (input.autonomyPosture !== undefined) {
|
||||
setClauses.push("autonomyPosture = ?");
|
||||
params.push(toJsonNullable(input.autonomyPosture));
|
||||
}
|
||||
if (input.worktreePath !== undefined) {
|
||||
setClauses.push("worktreePath = ?");
|
||||
params.push(input.worktreePath);
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
|
||||
this.db
|
||||
.prepare(`UPDATE cli_sessions SET ${setClauses.join(", ")} WHERE id = ?`)
|
||||
.run(...params);
|
||||
|
||||
const updated = this.getSession(id)!;
|
||||
this.db.bumpLastModified();
|
||||
this.emit("cli-session:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Delete a CLI session record. */
|
||||
deleteSession(id: string): boolean {
|
||||
const existing = this.getSession(id);
|
||||
if (!existing) return false;
|
||||
|
||||
this.db.prepare("DELETE FROM cli_sessions WHERE id = ?").run(id);
|
||||
this.db.bumpLastModified();
|
||||
this.emit("cli-session:deleted", id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
196
packages/core/src/cli-session-types.ts
Normal file
196
packages/core/src/cli-session-types.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* CLI agent session type definitions (CLI Agent Executor, U1).
|
||||
*
|
||||
* Defines the durable record shape for a CLI agent session — the long-lived
|
||||
* process that drives a single autonomy unit (a task execution, a planning
|
||||
* pass, a validator run, a CE run, or an interactive chat). These records
|
||||
* outlive the in-memory executor so a crashed/restarted Fusion instance can
|
||||
* reason about, resume, or reap sessions from their persisted state.
|
||||
*
|
||||
* Follows the same conventions as chat-types.ts:
|
||||
* - String-literal unions for enums.
|
||||
* - Nullable owning-entity references (taskId / chatSessionId).
|
||||
* - JSON-serialized structured columns (autonomyPosture).
|
||||
*/
|
||||
|
||||
// ── Enums / String Literals ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lifecycle state of a CLI agent session.
|
||||
*
|
||||
* Transitions (typical): starting → ready → busy ↔ waitingOnInput → done,
|
||||
* with dead / needsAttention reachable from any active state on failure or
|
||||
* a condition requiring operator intervention.
|
||||
*/
|
||||
export type CliAgentState =
|
||||
| "starting"
|
||||
| "ready"
|
||||
| "busy"
|
||||
| "waitingOnInput"
|
||||
| "done"
|
||||
| "dead"
|
||||
| "needsAttention";
|
||||
|
||||
/** All valid agent states, for runtime validation at the store boundary. */
|
||||
export const CLI_AGENT_STATES: readonly CliAgentState[] = [
|
||||
"starting",
|
||||
"ready",
|
||||
"busy",
|
||||
"waitingOnInput",
|
||||
"done",
|
||||
"dead",
|
||||
"needsAttention",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Why a CLI agent session terminated. Null while the session is still live.
|
||||
*
|
||||
* Termination taxonomy (KTD):
|
||||
* - completed — the agent finished its unit of work successfully.
|
||||
* - userExited — the user/operator deliberately stopped the session.
|
||||
* - killed — the session was force-terminated (e.g. supervisor reap).
|
||||
* - crashed — the underlying process exited abnormally / unexpectedly.
|
||||
* - authFailed — the session ended because credentials/auth were rejected.
|
||||
* - engineDeath — the owning Fusion engine/process died, orphaning the session.
|
||||
*/
|
||||
export type CliTerminationReason =
|
||||
| "completed"
|
||||
| "userExited"
|
||||
| "killed"
|
||||
| "crashed"
|
||||
| "authFailed"
|
||||
| "engineDeath";
|
||||
|
||||
/** All valid termination reasons, for runtime validation at the store boundary. */
|
||||
export const CLI_TERMINATION_REASONS: readonly CliTerminationReason[] = [
|
||||
"completed",
|
||||
"userExited",
|
||||
"killed",
|
||||
"crashed",
|
||||
"authFailed",
|
||||
"engineDeath",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The purpose a CLI agent session serves — which autonomy unit it drives.
|
||||
*
|
||||
* - execute — a task execution run.
|
||||
* - planning — a planning / triage pass.
|
||||
* - validator — a validator / acceptance run.
|
||||
* - ce — a compound-engineering run.
|
||||
* - chat — an interactive chat session.
|
||||
*/
|
||||
export type CliSessionPurpose = "execute" | "planning" | "validator" | "ce" | "chat";
|
||||
|
||||
/** All valid session purposes, for runtime validation at the store boundary. */
|
||||
export const CLI_SESSION_PURPOSES: readonly CliSessionPurpose[] = [
|
||||
"execute",
|
||||
"planning",
|
||||
"validator",
|
||||
"ce",
|
||||
"chat",
|
||||
] as const;
|
||||
|
||||
// ── Core Types ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Operator-configured autonomy posture for a session. Stored as JSON.
|
||||
*
|
||||
* Kept intentionally open-ended (structured but extensible) so posture
|
||||
* controls can evolve without a schema migration. Persisted verbatim.
|
||||
*/
|
||||
export interface CliAutonomyPosture {
|
||||
/** Whether the session may proceed without per-step approval. */
|
||||
autoApprove?: boolean;
|
||||
/** Maximum number of resume attempts permitted before giving up. */
|
||||
maxResumeAttempts?: number;
|
||||
/** Free-form, forward-compatible posture fields. */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A durable CLI agent session record.
|
||||
*
|
||||
* Exactly one of `taskId` / `chatSessionId` is typically set, matching the
|
||||
* owning entity for the session's `purpose` (chat → chatSessionId; the rest →
|
||||
* taskId). Both may be null for sessions not yet attached to an entity.
|
||||
*/
|
||||
export interface CliSession {
|
||||
/** Stable primary key. */
|
||||
id: string;
|
||||
/** Owning task ID, when this session drives task work. Null otherwise. */
|
||||
taskId: string | null;
|
||||
/** Owning chat session ID, when purpose is "chat". Null otherwise. */
|
||||
chatSessionId: string | null;
|
||||
/** What autonomy unit this session drives. */
|
||||
purpose: CliSessionPurpose;
|
||||
/** Project this session belongs to. */
|
||||
projectId: string;
|
||||
/** Adapter (CLI agent integration) backing the session. */
|
||||
adapterId: string;
|
||||
/** Current lifecycle state. */
|
||||
agentState: CliAgentState;
|
||||
/** Why the session terminated, or null while live. */
|
||||
terminationReason: CliTerminationReason | null;
|
||||
/** Native (adapter/process) session identifier, for resume. Null until known. */
|
||||
nativeSessionId: string | null;
|
||||
/** Number of resume attempts made so far. */
|
||||
resumeAttempts: number;
|
||||
/** Operator-configured autonomy posture. */
|
||||
autonomyPosture: CliAutonomyPosture | null;
|
||||
/** Worktree path the session operates in. */
|
||||
worktreePath: string | null;
|
||||
/** When the record was created (ISO 8601). */
|
||||
createdAt: string;
|
||||
/** When the record was last updated (ISO 8601). */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Input for creating a CLI session record. */
|
||||
export interface CliSessionCreateInput {
|
||||
/** Optional explicit ID; generated when omitted. */
|
||||
id?: string;
|
||||
taskId?: string | null;
|
||||
chatSessionId?: string | null;
|
||||
purpose: CliSessionPurpose;
|
||||
projectId: string;
|
||||
adapterId: string;
|
||||
/** Initial state; defaults to "starting" when omitted. */
|
||||
agentState?: CliAgentState;
|
||||
terminationReason?: CliTerminationReason | null;
|
||||
nativeSessionId?: string | null;
|
||||
resumeAttempts?: number;
|
||||
autonomyPosture?: CliAutonomyPosture | null;
|
||||
worktreePath?: string | null;
|
||||
}
|
||||
|
||||
/** Partial updates to a CLI session record. */
|
||||
export interface CliSessionUpdateInput {
|
||||
taskId?: string | null;
|
||||
chatSessionId?: string | null;
|
||||
agentState?: CliAgentState;
|
||||
terminationReason?: CliTerminationReason | null;
|
||||
nativeSessionId?: string | null;
|
||||
resumeAttempts?: number;
|
||||
autonomyPosture?: CliAutonomyPosture | null;
|
||||
worktreePath?: string | null;
|
||||
}
|
||||
|
||||
// ── Validation helpers ───────────────────────────────────────────────────
|
||||
|
||||
/** Narrow an unknown value to a valid CliAgentState. */
|
||||
export function isCliAgentState(value: unknown): value is CliAgentState {
|
||||
return typeof value === "string" && (CLI_AGENT_STATES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Narrow an unknown value to a valid CliTerminationReason. */
|
||||
export function isCliTerminationReason(value: unknown): value is CliTerminationReason {
|
||||
return (
|
||||
typeof value === "string" && (CLI_TERMINATION_REASONS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** Narrow an unknown value to a valid CliSessionPurpose. */
|
||||
export function isCliSessionPurpose(value: unknown): value is CliSessionPurpose {
|
||||
return typeof value === "string" && (CLI_SESSION_PURPOSES as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 110;
|
||||
const SCHEMA_VERSION = 112;
|
||||
|
||||
export { SCHEMA_VERSION };
|
||||
|
||||
@@ -1252,6 +1252,23 @@ export const MIGRATION_ONLY_TABLE_SCHEMAS: Record<string, Record<string, string>
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
cliSessionFile: "TEXT",
|
||||
inFlightGeneration: "TEXT",
|
||||
cliExecutorAdapterId: "TEXT",
|
||||
},
|
||||
cli_sessions: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
taskId: "TEXT",
|
||||
chatSessionId: "TEXT",
|
||||
purpose: "TEXT NOT NULL",
|
||||
projectId: "TEXT NOT NULL",
|
||||
adapterId: "TEXT NOT NULL",
|
||||
agentState: "TEXT NOT NULL DEFAULT 'starting'",
|
||||
terminationReason: "TEXT",
|
||||
nativeSessionId: "TEXT",
|
||||
resumeAttempts: "INTEGER NOT NULL DEFAULT 0",
|
||||
autonomyPosture: "TEXT",
|
||||
worktreePath: "TEXT",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
chat_messages: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
@@ -4311,12 +4328,10 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
// Migration 109: Workflow editor consolidation (workflow-editor-consolidation
|
||||
// U1, KTD-1). Adds workflows.kind (fragment vs workflow discriminator;
|
||||
// existing rows default to 'workflow') and workflow_steps.migrated_fragment_id
|
||||
// (nullable marker stamping a step that has been migrated into a fragment, so
|
||||
// the lazy step migration is idempotent). Additive-only, idempotent
|
||||
// (addColumnIfMissing guards); no backfill.
|
||||
// Migration 109: Workflow editor consolidation. Adds workflows.kind
|
||||
// (fragment vs workflow discriminator; existing rows default 'workflow')
|
||||
// and workflow_steps.migrated_fragment_id (idempotent lazy step migration).
|
||||
// Additive-only, idempotent (addColumnIfMissing guards); no backfill.
|
||||
if (version < 109) {
|
||||
this.applyMigration(109, () => {
|
||||
this.addColumnIfMissing("workflows", "kind", "TEXT NOT NULL DEFAULT 'workflow'");
|
||||
@@ -4324,16 +4339,56 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
// Migration 110: Workflow setting values (workflow-settings U2, KTD-2).
|
||||
// Migration 110: Durable CLI agent session records (CLI Agent Executor U1).
|
||||
// cli_sessions — one row per long-lived CLI agent session. agentState ∈
|
||||
// starting|ready|busy|waitingOnInput|done|dead|needsAttention; terminationReason
|
||||
// ∈ completed|userExited|killed|crashed|authFailed|engineDeath; purpose ∈
|
||||
// execute|planning|validator|ce|chat. Additive-only, idempotent.
|
||||
if (version < 110) {
|
||||
this.applyMigration(110, () => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cli_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
taskId TEXT,
|
||||
chatSessionId TEXT,
|
||||
purpose TEXT NOT NULL,
|
||||
projectId TEXT NOT NULL,
|
||||
adapterId TEXT NOT NULL,
|
||||
agentState TEXT NOT NULL DEFAULT 'starting',
|
||||
terminationReason TEXT,
|
||||
nativeSessionId TEXT,
|
||||
resumeAttempts INTEGER NOT NULL DEFAULT 0,
|
||||
autonomyPosture TEXT,
|
||||
worktreePath TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cli_sessions_taskId ON cli_sessions(taskId);
|
||||
CREATE INDEX IF NOT EXISTS idx_cli_sessions_chatSessionId ON cli_sessions(chatSessionId);
|
||||
CREATE INDEX IF NOT EXISTS idx_cli_sessions_project_state ON cli_sessions(projectId, agentState);
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
// Migration 111: per-chat-session cli-agent adapter selection (U12).
|
||||
if (version < 111) {
|
||||
this.applyMigration(111, () => {
|
||||
if (this.hasTable("chat_sessions")) {
|
||||
this.addColumnIfMissing("chat_sessions", "cliExecutorAdapterId", "TEXT");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Migration 112: Workflow setting values (workflow-settings U2, KTD-2).
|
||||
// Adds workflow_settings — one row per (workflowId, projectId) carrying a JSON
|
||||
// map of setting values declared by the workflow's IR. Values are validated by
|
||||
// the store write authority against the named workflow's declarations; built-in
|
||||
// workflow ids are accepted for value writes even though their declarations are
|
||||
// non-editable. Additive-only, idempotent (table-exists guard); no backfill.
|
||||
// (Authored as 109 on the feature branch; renumbered to 110 when main's
|
||||
// editor-consolidation migration landed first with the same number.)
|
||||
if (version < 110) {
|
||||
this.applyMigration(110, () => {
|
||||
// (Authored as 109 on the feature branch; renumbered as mainline migrations
|
||||
// land first — currently 112.)
|
||||
if (version < 112) {
|
||||
this.applyMigration(112, () => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS workflow_settings (
|
||||
workflowId TEXT NOT NULL,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { mkdir, readFile, writeFile, rename, chmod } from "node:fs/promises";
|
||||
import { existsSync, mkdirSync, renameSync } from "node:fs";
|
||||
import type { GlobalSettings } from "./types.js";
|
||||
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
|
||||
import { sanitizeCliAgentsSettings } from "./settings-schema.js";
|
||||
|
||||
function getHomeDir(): string {
|
||||
return process.env.HOME || process.env.USERPROFILE || homedir();
|
||||
@@ -193,6 +194,11 @@ export class GlobalSettingsStore {
|
||||
// null → delete this key from the merged object
|
||||
// This effectively makes it fall through to the default
|
||||
delete merged[key];
|
||||
} else if (key === "cliAgents") {
|
||||
// Validation at the write boundary (U15, Global Settings convention):
|
||||
// unknown adapter ids and invalid fields are dropped before persist so
|
||||
// a malformed `cliAgents` payload can never reach launch resolution.
|
||||
merged[key] = sanitizeCliAgentsSettings(value);
|
||||
} else {
|
||||
// normal value → set it
|
||||
merged[key] = value;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
|
||||
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js";
|
||||
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js";
|
||||
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
|
||||
export {
|
||||
resolveEntryPointBranchAssignment,
|
||||
@@ -16,6 +16,7 @@ export type {
|
||||
EntryPointBranchAssignment,
|
||||
} from "./branch-assignment.js";
|
||||
export { customProviderRegistryKey } from "./custom-provider-key.js";
|
||||
export { redactSecrets } from "./redact-secrets.js";
|
||||
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
|
||||
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";
|
||||
export {
|
||||
@@ -79,6 +80,9 @@ export type {
|
||||
WorkflowSettingType,
|
||||
WorkflowSettingOption,
|
||||
WorkflowSettingRender,
|
||||
// CLI Agent Executor (U7): node-config executor typing.
|
||||
WorkflowNodeExecutorKind,
|
||||
WorkflowNodeExecutorConfig,
|
||||
} from "./workflow-ir-types.js";
|
||||
export {
|
||||
instanceNodeId,
|
||||
@@ -1592,6 +1596,25 @@ export type {
|
||||
} from "./chat-types.js";
|
||||
export { ChatStore } from "./chat-store.js";
|
||||
export type { ChatStoreEvents } from "./chat-store.js";
|
||||
export {
|
||||
CLI_AGENT_STATES,
|
||||
CLI_TERMINATION_REASONS,
|
||||
CLI_SESSION_PURPOSES,
|
||||
isCliAgentState,
|
||||
isCliTerminationReason,
|
||||
isCliSessionPurpose,
|
||||
} from "./cli-session-types.js";
|
||||
export type {
|
||||
CliAgentState,
|
||||
CliTerminationReason,
|
||||
CliSessionPurpose,
|
||||
CliAutonomyPosture,
|
||||
CliSession,
|
||||
CliSessionCreateInput,
|
||||
CliSessionUpdateInput,
|
||||
} from "./cli-session-types.js";
|
||||
export { CliSessionStore } from "./cli-session-store.js";
|
||||
export type { CliSessionStoreEvents } from "./cli-session-store.js";
|
||||
export {
|
||||
choosePreferredStoredCredential,
|
||||
extractClaudeCliStoredCredential,
|
||||
|
||||
31
packages/core/src/redact-secrets.ts
Normal file
31
packages/core/src/redact-secrets.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Shared secret-redaction helper.
|
||||
*
|
||||
* Pure string logic that strips token-like / auth patterns from text so auth
|
||||
* errors and process output don't leak verbatim into logs or buffers. Best
|
||||
* effort: covers bearer tokens, `Authorization:` header values,
|
||||
* `key=`/`token=`/`secret=` assignments, and long base64/hex secrets.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Redact token-like / auth patterns from `text`.
|
||||
*/
|
||||
export function redactSecrets(text: string): string {
|
||||
return (
|
||||
text
|
||||
// Authorization: Bearer <token> / Authorization: <token>
|
||||
.replace(/(authorization\s*[:=]\s*)(bearer\s+)?[^\s,;"']+/gi, "$1$2[REDACTED]")
|
||||
// Bearer <token>
|
||||
.replace(/\b(bearer)\s+[A-Za-z0-9._\-+/=]+/gi, "$1 [REDACTED]")
|
||||
// key=... token=... secret=... password=... apikey=... (quoted or bare)
|
||||
.replace(
|
||||
/\b((?:api[_-]?key|key|token|secret|password|passwd|pwd|access[_-]?token|refresh[_-]?token|client[_-]?secret)\s*[:=]\s*)("?)[^\s,;"']+\2/gi,
|
||||
"$1$2[REDACTED]$2",
|
||||
)
|
||||
// sk-/ghp_/github_pat_/xoxb-/AKIA-style long opaque tokens
|
||||
.replace(/\b(sk-|ghp_|gho_|github_pat_|xox[abpr]-|AKIA)[A-Za-z0-9_-]{8,}/g, "[REDACTED]")
|
||||
// standalone long base64/hex secrets (>=32 chars)
|
||||
.replace(/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]")
|
||||
.replace(/\b[0-9a-fA-F]{32,}\b/g, "[REDACTED]")
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GlobalSettings, ProjectSettings, Settings } from "./types.js";
|
||||
import type { CliAgentSettings, GlobalSettings, ProjectSettings, Settings } from "./types.js";
|
||||
|
||||
export interface MergeRequestContractShadowSettingsSource {
|
||||
mergeRequestContractShadowEnabled?: boolean;
|
||||
@@ -230,6 +230,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
},
|
||||
owningNodeHandoffPolicy: "reassign-to-local",
|
||||
experimentalFeatures: {},
|
||||
cliAgents: {},
|
||||
} satisfies CompleteSettings<GlobalSettings>;
|
||||
|
||||
/** Default values for project-level settings. */
|
||||
@@ -238,6 +239,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
globalPauseReason: undefined,
|
||||
defaultWorkflowId: undefined,
|
||||
approvedWorkflowCliCommands: undefined,
|
||||
approvedCliAutonomyAdapters: undefined,
|
||||
enginePaused: false,
|
||||
maxConcurrent: 2,
|
||||
maxTriageConcurrent: 2,
|
||||
@@ -562,3 +564,81 @@ export function resolvePersistAgentThinkingLog(
|
||||
if (typeof settings?.persistAgentThinkingLog === "boolean") return settings.persistAgentThinkingLog;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── CLI-agent settings sanitization (U15) ───────────────────────────────────
|
||||
|
||||
/** Adapter ids accepted in `cliAgents`. Unknown ids are dropped at the write
|
||||
* boundary so a settings file cannot carry config for non-existent adapters. */
|
||||
export const CLI_AGENT_ADAPTER_IDS = Object.freeze([
|
||||
"claude-code",
|
||||
"codex",
|
||||
"droid",
|
||||
"pi",
|
||||
"generic",
|
||||
] as const);
|
||||
|
||||
/** Autonomy modes accepted in a `CliAgentSettings` entry. */
|
||||
export const CLI_AGENT_AUTONOMY_MODES = Object.freeze(["default", "elevated"] as const);
|
||||
|
||||
function sanitizeStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const cleaned = value
|
||||
.filter((v): v is string => typeof v === "string")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
return cleaned.length > 0 ? cleaned : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a single adapter's launch settings (U15). Drops unknown fields and
|
||||
* invalid values; returns `undefined` when nothing survives (so the caller can
|
||||
* omit an empty entry). Pure — no I/O.
|
||||
*
|
||||
* Validation rules:
|
||||
* - `commandOverride`: non-empty trimmed string, else dropped.
|
||||
* - `extraArgs` / `envAdditions`: arrays of non-empty trimmed strings, else dropped.
|
||||
* - `autonomyMode`: one of CLI_AGENT_AUTONOMY_MODES, else dropped (falls back to
|
||||
* the adapter baseline at resolution time).
|
||||
*/
|
||||
export function sanitizeCliAgentSettings(value: unknown): CliAgentSettings | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const input = value as Record<string, unknown>;
|
||||
const out: CliAgentSettings = {};
|
||||
|
||||
if (typeof input.commandOverride === "string") {
|
||||
const trimmed = input.commandOverride.trim();
|
||||
if (trimmed.length > 0) out.commandOverride = trimmed;
|
||||
}
|
||||
|
||||
const extraArgs = sanitizeStringArray(input.extraArgs);
|
||||
if (extraArgs) out.extraArgs = extraArgs;
|
||||
|
||||
const envAdditions = sanitizeStringArray(input.envAdditions);
|
||||
if (envAdditions) out.envAdditions = envAdditions;
|
||||
|
||||
if (
|
||||
typeof input.autonomyMode === "string" &&
|
||||
(CLI_AGENT_AUTONOMY_MODES as readonly string[]).includes(input.autonomyMode)
|
||||
) {
|
||||
out.autonomyMode = input.autonomyMode as CliAgentSettings["autonomyMode"];
|
||||
}
|
||||
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the whole `cliAgents` map at the write boundary (U15). Drops unknown
|
||||
* adapter ids and any entry that sanitizes to nothing. Returns a fresh object;
|
||||
* always returns an object (possibly empty) so the field round-trips cleanly.
|
||||
*/
|
||||
export function sanitizeCliAgentsSettings(value: unknown): Record<string, CliAgentSettings> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
const input = value as Record<string, unknown>;
|
||||
const out: Record<string, CliAgentSettings> = {};
|
||||
for (const adapterId of CLI_AGENT_ADAPTER_IDS) {
|
||||
if (!(adapterId in input)) continue;
|
||||
const entry = sanitizeCliAgentSettings(input[adapterId]);
|
||||
if (entry) out[adapterId] = entry;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -13664,6 +13664,50 @@ ${stepsSection}`;
|
||||
} as unknown as Partial<Settings>);
|
||||
}
|
||||
|
||||
/** Whether a CLI-agent adapter has been approved for ELEVATED autonomy in this
|
||||
* project (CLI Agent Executor, U15). Mirrors the raw-command approval
|
||||
* precedent; approval is per-project + per-adapter and stored in project
|
||||
* settings (`approvedCliAutonomyAdapters`). */
|
||||
async isCliAutonomyApproved(adapterId: string): Promise<boolean> {
|
||||
const trimmed = adapterId.trim();
|
||||
if (!trimmed) return false;
|
||||
const settings = await this.getSettings();
|
||||
const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters;
|
||||
return Array.isArray(approved) && approved.includes(trimmed);
|
||||
}
|
||||
|
||||
/** Record approval for elevated CLI-agent autonomy for an adapter. Idempotent.
|
||||
* The approving principal in v1 is the daemon-token holder (route-level). */
|
||||
async approveCliAutonomy(adapterId: string): Promise<void> {
|
||||
const trimmed = adapterId.trim();
|
||||
if (!trimmed) throw new Error("Adapter id is required");
|
||||
const settings = await this.getSettings();
|
||||
const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters ?? [];
|
||||
if (approved.includes(trimmed)) return;
|
||||
await this.updateSettings({
|
||||
approvedCliAutonomyAdapters: [...approved, trimmed],
|
||||
} as unknown as Partial<Settings>);
|
||||
}
|
||||
|
||||
/** Revoke a previously-granted elevated-autonomy approval. Idempotent. */
|
||||
async revokeCliAutonomy(adapterId: string): Promise<void> {
|
||||
const trimmed = adapterId.trim();
|
||||
if (!trimmed) return;
|
||||
const settings = await this.getSettings();
|
||||
const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters ?? [];
|
||||
if (!approved.includes(trimmed)) return;
|
||||
await this.updateSettings({
|
||||
approvedCliAutonomyAdapters: approved.filter((a) => a !== trimmed),
|
||||
} as unknown as Partial<Settings>);
|
||||
}
|
||||
|
||||
/** List adapters approved for elevated autonomy in this project. */
|
||||
async listApprovedCliAutonomyAdapters(): Promise<string[]> {
|
||||
const settings = await this.getSettings();
|
||||
const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters;
|
||||
return Array.isArray(approved) ? [...approved] : [];
|
||||
}
|
||||
|
||||
/** Read the workflow currently selected for a task, if any. */
|
||||
/**
|
||||
* Synchronously resolve the parsed WorkflowIr that governs a task's columns
|
||||
|
||||
@@ -2967,6 +2967,39 @@ export interface GlobalSettings {
|
||||
*
|
||||
* Default: {} (empty object — no experimental features enabled). */
|
||||
experimentalFeatures?: Record<string, boolean>;
|
||||
/** Per-adapter CLI-agent launch configuration (CLI Agent Executor, U15).
|
||||
* Keyed by adapter id (e.g. `"claude-code"`, `"codex"`, `"generic"`). Each
|
||||
* entry carries operator overrides layered over the adapter's shipped
|
||||
* defaults: a command override, extra args, an autonomy mode, and env
|
||||
* allowlist additions. Validated + sanitized at the write boundary
|
||||
* (`sanitizeCliAgentsSettings`); invalid entries/fields are dropped.
|
||||
*
|
||||
* Note: elevation expressed through ANY of these channels (autonomy mode,
|
||||
* extra args, env additions, a non-default command override) is gated by a
|
||||
* stored per-project approval at launch — see `@fusion/engine`'s
|
||||
* `resolveEffectivePosture`. These settings only describe *intent*; the
|
||||
* engine resolves and enforces posture. Default: {} (no overrides). */
|
||||
cliAgents?: Record<string, CliAgentSettings>;
|
||||
}
|
||||
|
||||
/** Operator launch config for one CLI-agent adapter (U15). Values are layered
|
||||
* over the adapter's shipped defaults at launch. All fields optional; an empty
|
||||
* object means "use shipped defaults". */
|
||||
export interface CliAgentSettings {
|
||||
/** Override for the binary path/name to invoke. A non-default value is treated
|
||||
* as privileged (routes through the autonomy approval gate). */
|
||||
commandOverride?: string;
|
||||
/** Extra args appended after the adapter's computed base args. Free-form; the
|
||||
* engine's elevation detector scans these for bypass markers. */
|
||||
extraArgs?: string[];
|
||||
/** Autonomy mode above the adapter baseline. `"default"` is the baseline (no
|
||||
* elevation); `"elevated"` requests bypass-permissions-style autonomy and is
|
||||
* gated. Kept as a string enum so adapters can map it to their own flags. */
|
||||
autonomyMode?: "default" | "elevated";
|
||||
/** Additional env var KEYS to forward from the parent process to the child.
|
||||
* Names only (never values); the engine copies these from `process.env`.
|
||||
* Service credentials (`FUSION_*`) are always excluded regardless. */
|
||||
envAdditions?: string[];
|
||||
}
|
||||
|
||||
export type RemoteAccessProvider = "tailscale" | "cloudflare";
|
||||
@@ -3056,6 +3089,12 @@ export interface ProjectSettings {
|
||||
* (trust-on-first-use). A node's command must appear here before it runs;
|
||||
* named scripts (settings.scripts) never require approval. */
|
||||
approvedWorkflowCliCommands?: string[];
|
||||
/** CLI-agent adapter ids the project owner has approved for ELEVATED autonomy
|
||||
* (CLI Agent Executor, U15). An adapter must appear here before a launch whose
|
||||
* resolved posture is elevated (bypass-permissions-style) is permitted; an
|
||||
* unapproved elevation fails the launch with a typed error. Approving
|
||||
* principal in v1: the daemon-token holder (the single workspace owner). */
|
||||
approvedCliAutonomyAdapters?: string[];
|
||||
/** Engine pause (soft pause): when true, the scheduler and triage
|
||||
* processor stop dispatching **new** work (scheduling, triage
|
||||
* specification, and auto-merge), but currently running agent sessions
|
||||
@@ -3916,6 +3955,10 @@ export {
|
||||
isProjectSettingsKey,
|
||||
isMergeRequestContractShadowEnabled,
|
||||
resolvePersistAgentThinkingLog,
|
||||
sanitizeCliAgentSettings,
|
||||
sanitizeCliAgentsSettings,
|
||||
CLI_AGENT_ADAPTER_IDS,
|
||||
CLI_AGENT_AUTONOMY_MODES,
|
||||
} from "./settings-schema.js";
|
||||
|
||||
export interface BoardConfig {
|
||||
|
||||
@@ -26,6 +26,50 @@ export interface WorkflowIrNode {
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executor kinds selectable on a prompt/execute node's `config.executor` (CLI
|
||||
* Agent Executor, U7). The engine reads `config.executor` as an open string; this
|
||||
* union documents the recognized values and `WorkflowNodeExecutorConfig` the
|
||||
* fields each one consumes. `config` itself stays an open `Record` so unknown
|
||||
* keys remain forward-compatible.
|
||||
*
|
||||
* - `model` (default): run the prompt on the configured/override model.
|
||||
* - `agent` : run as a named agent (adopt its model + persona).
|
||||
* - `skill` : invoke a named skill with the prompt as input.
|
||||
* - `cli` : run a named project script with the prompt via env.
|
||||
* - `cli-agent` : drive a CLI coding agent (Claude Code / Codex / Droid / Pi /
|
||||
* generic) in an engine-owned PTY for the execute step. Honors
|
||||
* cancel/abort/re-entry semantics and positive-completion gating.
|
||||
*/
|
||||
export type WorkflowNodeExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
|
||||
|
||||
/**
|
||||
* The cli-agent slice of a workflow node's `config`. These ride on the open
|
||||
* `WorkflowIrNode.config` record (read at U7's executor seam); they are NOT a
|
||||
* separate column. The resolved values are SNAPSHOTTED at session launch — a
|
||||
* mid-run edit to the node config applies to the next run only.
|
||||
*/
|
||||
export interface WorkflowNodeExecutorConfig {
|
||||
/** Selected executor kind for this node. */
|
||||
executor?: WorkflowNodeExecutorKind;
|
||||
/** cli-agent: adapter id to drive the session (resolved against the registry). */
|
||||
cliAdapterId?: string;
|
||||
/**
|
||||
* cli-agent: autonomy posture (drives privileged flags + resume caps). Stored
|
||||
* verbatim; structured but extensible (mirrors `CliAutonomyPosture`).
|
||||
*/
|
||||
cliAutonomy?: {
|
||||
autoApprove?: boolean;
|
||||
maxResumeAttempts?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* cli-agent: notification settings for waiting-on-input events on this node
|
||||
* (origin R2/R11). Opaque to the engine seam; forwarded to the dispatch.
|
||||
*/
|
||||
cliNotify?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WorkflowIrEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
|
||||
@@ -8546,10 +8546,36 @@ export function reorderTodoItems(listId: string, itemIds: string[], projectId?:
|
||||
|
||||
// ── AI Sessions (Background Tasks) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Needs-attention variants for a CLI agent session (CLI Agent Executor, U11).
|
||||
* Each carries pinned banner copy + action verbs:
|
||||
* - userExited → Advance / Retry / Cancel task
|
||||
* - authFailed → Re-authenticate / Retry
|
||||
* - resume-exhausted → Relaunch fresh / Cancel task
|
||||
*/
|
||||
export type CliNeedsAttentionVariant = "userExited" | "authFailed" | "resume-exhausted";
|
||||
|
||||
export interface AiSessionSummary {
|
||||
id: string;
|
||||
type: "planning" | "subtask" | "mission_interview" | "milestone_interview" | "slice_interview";
|
||||
status: "draft" | "generating" | "awaiting_input" | "complete" | "error";
|
||||
type:
|
||||
| "planning"
|
||||
| "subtask"
|
||||
| "mission_interview"
|
||||
| "milestone_interview"
|
||||
| "slice_interview"
|
||||
| "cli-agent";
|
||||
status:
|
||||
| "draft"
|
||||
| "generating"
|
||||
| "awaiting_input"
|
||||
| "complete"
|
||||
| "error"
|
||||
| "waiting_on_input"
|
||||
| "needs_attention";
|
||||
/** For cli-agent sessions: which needs-attention variant (drives pinned copy/actions). */
|
||||
cliVariant?: CliNeedsAttentionVariant;
|
||||
/** Underlying CLI session id, for action wiring (confirm-advance / re-auth / etc.). */
|
||||
cliSessionId?: string;
|
||||
title: string;
|
||||
/** Server-derived preview of the in-progress initialPlan; only set for draft planning sessions. */
|
||||
preview?: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./BackgroundTasksIndicator.css";
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { Lightbulb, Layers, Target, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react";
|
||||
import { Lightbulb, Layers, Target, Terminal, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AiSessionSummary } from "../api";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
@@ -21,6 +21,7 @@ const TYPE_ICONS = {
|
||||
mission_interview: Target,
|
||||
milestone_interview: Target,
|
||||
slice_interview: Target,
|
||||
"cli-agent": Terminal,
|
||||
} as const;
|
||||
|
||||
export function BackgroundTasksIndicator({
|
||||
@@ -49,6 +50,7 @@ export function BackgroundTasksIndicator({
|
||||
mission_interview: t("backgroundTasks.typeLabel.missionInterview", "Mission Interview"),
|
||||
milestone_interview: t("backgroundTasks.typeLabel.milestoneInterview", "Milestone Interview"),
|
||||
slice_interview: t("backgroundTasks.typeLabel.sliceInterview", "Slice Interview"),
|
||||
"cli-agent": t("backgroundTasks.typeLabel.cliAgent", "CLI Agent"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ import { AgentMentionPopup } from "./AgentMentionPopup";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { FileMentionPopup } from "./FileMentionPopup";
|
||||
import { CreateRoomModal } from "./CreateRoomModal";
|
||||
import { CliChatSurface, type CliChatTier } from "./CliChatSurface";
|
||||
import { useFileMention } from "../hooks/useFileMention";
|
||||
import { useModelsCache } from "../hooks/useModelsCache";
|
||||
import { useDiscoveredSkillsCache } from "../hooks/useDiscoveredSkillsCache";
|
||||
@@ -2623,6 +2624,300 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
containerEl.scrollTo({ top, behavior: prefersReducedMotion ? "auto" : "smooth" });
|
||||
}, []);
|
||||
|
||||
// ── CLI-backed chat mount (U12) ──────────────────────────────────────────
|
||||
// When the active chat session selects a cli-agent executor, the message-pane
|
||||
// + composer region is delegated to <CliChatSurface> (transcript + raw-terminal
|
||||
// toggle for hybrid/native adapters, terminal-only for the generic adapter).
|
||||
// The transcript renderer and composer renderer are the EXISTING ChatView JSX
|
||||
// passed through as thunks so there is no parallel message/composer UI.
|
||||
const cliAdapterId = activeSession?.cliExecutorAdapterId ?? null;
|
||||
const cliChatActive = Boolean(cliAdapterId);
|
||||
// Generic adapter has no structured transcript → terminal-only; every other
|
||||
// bundled adapter exposes a transcript and gets the toggle (the authoritative
|
||||
// tier is resolved server-side; this only needs the generic vs. non-generic
|
||||
// split that drives the toggle's presence).
|
||||
const cliChatTier: CliChatTier = cliAdapterId === "generic" ? "generic" : "hybrid";
|
||||
// Terminal attach id: the native session linkage when known, else the chat id.
|
||||
const cliTerminalSessionId = activeSession?.cliSessionFile || activeSession?.id || "";
|
||||
|
||||
// The session message pane and composer, captured once so both the normal
|
||||
// provider path and the CLI-backed path (CliChatSurface thunks) render the
|
||||
// exact same JSX — no parallel message/composer UI.
|
||||
const renderSessionMessagesPane = () => (
|
||||
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
|
||||
<div ref={loadMoreSentinelRef} className="chat-load-more-sentinel">
|
||||
{hasMoreMessages && messagesLoading && (
|
||||
<div className="chat-loading-older">{t("chat.loadingOlderMessages", "Loading older messages…")}</div>
|
||||
)}
|
||||
</div>
|
||||
{isStreaming ? (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<ChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
forcePlain={showAllAsPlain}
|
||||
agentName={agentName}
|
||||
hideAssistantIdentity={hideAssistantIdentity}
|
||||
showAssistantModelTag={showAssistantModelTag}
|
||||
activeModelTag={activeModelTag}
|
||||
activeModelProvider={activeModelProvider}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={null}
|
||||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||||
onScrollToTop={handleScrollMessageToTop}
|
||||
/>
|
||||
))}
|
||||
<div className="chat-message chat-message--assistant chat-message--streaming">
|
||||
{!hideAssistantIdentity && (
|
||||
<div className="chat-message-avatar">
|
||||
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}
|
||||
<span>{agentName}</span>
|
||||
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
</div>
|
||||
)}
|
||||
{streamingText ? (
|
||||
renderAssistantContent(streamingText, showAllAsPlain)
|
||||
) : (
|
||||
<div className="chat-message-content chat-message-content--waiting">
|
||||
{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.connectingStatus", "Connecting…")}
|
||||
</div>
|
||||
)}
|
||||
{showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")}
|
||||
{renderToolCalls(streamingToolCalls, t)}
|
||||
{streamingThinking && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>{t("chat.thinking", "Thinking")}</summary>
|
||||
<pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="chat-typing-indicator">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : messagesLoading ? (
|
||||
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
|
||||
) : messages.length === 0 && !activeSession ? (
|
||||
renderEmptyState()
|
||||
) : messages.length === 0 && activeSession ? (
|
||||
<div className="chat-empty-state">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<ChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
forcePlain={showAllAsPlain}
|
||||
agentName={agentName}
|
||||
hideAssistantIdentity={hideAssistantIdentity}
|
||||
showAssistantModelTag={showAssistantModelTag}
|
||||
activeModelTag={activeModelTag}
|
||||
activeModelProvider={activeModelProvider}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={null}
|
||||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||||
onScrollToTop={handleScrollMessageToTop}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSessionComposerPane = () => (
|
||||
<div className="chat-input-area">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,.txt,.json,.yaml,.yml,.log,.csv,.xml,.md"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={(event) => {
|
||||
handleAttachmentFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{showSkillMenu && (
|
||||
<div className="chat-skill-menu" data-testid="chat-skill-menu" role="listbox" aria-label={t("chat.skillSuggestions", "Skill suggestions")}>
|
||||
{skillsLoading ? (
|
||||
<div className="chat-skill-menu-empty">{t("chat.loadingSkills", "Loading skills…")}</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className="chat-skill-menu-empty">
|
||||
{skillFilter ? t("chat.noSkillsFound", "No skills found") : t("chat.noSkillsAvailable", "No skills available")}
|
||||
</div>
|
||||
) : (
|
||||
filteredSkills.map((skill, index) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === highlightedSkillIndex}
|
||||
className={`chat-skill-menu-item${index === highlightedSkillIndex ? " chat-skill-menu-item--highlighted" : ""}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHighlightedSkillIndex(index)}
|
||||
onClick={() => handleSkillSelect(skill)}
|
||||
>
|
||||
<span className="chat-skill-menu-item-name">{skill.name}</span>
|
||||
<span className="chat-skill-menu-item-description" title={skill.relativePath}>
|
||||
{skill.relativePath}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{pendingAttachments.length > 0 && (
|
||||
<div className="chat-attachment-previews" data-testid="chat-attachment-previews">
|
||||
{pendingAttachments.map((attachment, index) => (
|
||||
<div
|
||||
key={attachment.previewUrl || `${attachment.file.name}-${index}`}
|
||||
className="chat-attachment-preview"
|
||||
data-testid={`chat-attachment-preview-${index}`}
|
||||
>
|
||||
{attachment.previewUrl ? (
|
||||
<img src={attachment.previewUrl} alt={attachment.file.name} />
|
||||
) : (
|
||||
<span className="chat-attachment-preview-name">{attachment.file.name}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="chat-attachment-remove"
|
||||
onClick={() => removeAttachment(index)}
|
||||
data-testid={`chat-attachment-remove-${index}`}
|
||||
aria-label={`Remove ${attachment.file.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-input-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon chat-attach-btn"
|
||||
data-testid="chat-attach-btn"
|
||||
aria-label={t("chat.attachFiles", "Attach files")}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
<div
|
||||
className={`chat-input-wrapper${isDragOver ? " chat-input-wrapper--dragover" : ""}`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(false);
|
||||
handleAttachmentFiles(event.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={handleComposerRef}
|
||||
className="chat-input-textarea"
|
||||
placeholder={t("chat.typeMessage", "Type a message...")}
|
||||
value={messageInput}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onKeyUp={handleInputKeyUp}
|
||||
onClick={handleInputSelectionChange}
|
||||
onBlur={handleInputBlur}
|
||||
onFocus={handleInputFocus}
|
||||
onPaste={handlePaste}
|
||||
onTouchStart={(event) => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.innerWidth > 768) return;
|
||||
if (!isIOS()) return;
|
||||
if (document.activeElement === event.currentTarget) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus({ preventScroll: true });
|
||||
}}
|
||||
rows={1}
|
||||
data-testid="chat-input"
|
||||
/>
|
||||
<AgentMentionPopup
|
||||
agents={mentionAgents}
|
||||
filter={mentionFilter}
|
||||
highlightedIndex={mentionHighlightIndex}
|
||||
visible={mentionPopupVisible}
|
||||
onSelect={handleMentionSelect}
|
||||
position="below"
|
||||
roomMemberIds={roomContext?.memberIds}
|
||||
roomName={roomContext?.roomName}
|
||||
/>
|
||||
<FileMentionPopup
|
||||
visible={fileMention.mentionActive && !mentionPopupVisible}
|
||||
position={fileMentionPosition}
|
||||
tasks={fileMention.tasks}
|
||||
files={fileMention.files}
|
||||
selectedIndex={fileMention.selectedIndex}
|
||||
onSelectTask={(task) => {
|
||||
insertHashMention(fileMention.selectTask(task, messageInput), `#${task.id}`);
|
||||
}}
|
||||
onSelectFile={(file) => {
|
||||
insertHashMention(fileMention.selectFile(file, messageInput), `#${file.path}`);
|
||||
}}
|
||||
loading={fileMention.loading}
|
||||
/>
|
||||
{pendingMessage && (
|
||||
<div className="chat-pending-message" data-testid="chat-pending-indicator">
|
||||
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: pendingPreview })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-pending-message-dismiss"
|
||||
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
|
||||
data-testid="chat-pending-dismiss"
|
||||
onClick={clearPendingMessage}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isStreaming ? (
|
||||
<button
|
||||
className="chat-input-stop"
|
||||
onClick={stopStreaming}
|
||||
aria-label={t("chat.stopGeneration", "Stop generation")}
|
||||
data-testid="chat-stop-btn"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-input-send"
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType && event.pointerType !== "mouse") {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onClick={() => {
|
||||
void handleSend();
|
||||
}}
|
||||
disabled={!messageInput.trim() && pendingAttachments.length === 0}
|
||||
data-testid="chat-send-btn"
|
||||
style={{ touchAction: "manipulation" }}
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="chat-view">
|
||||
{/* Sidebar */}
|
||||
@@ -3216,301 +3511,33 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
|
||||
<div ref={loadMoreSentinelRef} className="chat-load-more-sentinel">
|
||||
{hasMoreMessages && messagesLoading && (
|
||||
<div className="chat-loading-older">{t("chat.loadingOlderMessages", "Loading older messages…")}</div>
|
||||
)}
|
||||
</div>
|
||||
{isStreaming ? (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<ChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
forcePlain={showAllAsPlain}
|
||||
agentName={agentName}
|
||||
hideAssistantIdentity={hideAssistantIdentity}
|
||||
showAssistantModelTag={showAssistantModelTag}
|
||||
activeModelTag={activeModelTag}
|
||||
activeModelProvider={activeModelProvider}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={null}
|
||||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||||
onScrollToTop={handleScrollMessageToTop}
|
||||
/>
|
||||
))}
|
||||
<div className="chat-message chat-message--assistant chat-message--streaming">
|
||||
{!hideAssistantIdentity && (
|
||||
<div className="chat-message-avatar">
|
||||
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}
|
||||
<span>{agentName}</span>
|
||||
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
</div>
|
||||
)}
|
||||
{streamingText ? (
|
||||
renderAssistantContent(streamingText, showAllAsPlain)
|
||||
) : (
|
||||
<div className="chat-message-content chat-message-content--waiting">
|
||||
{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.connectingStatus", "Connecting…")}
|
||||
</div>
|
||||
)}
|
||||
{showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")}
|
||||
{renderToolCalls(streamingToolCalls, t)}
|
||||
{streamingThinking && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>{t("chat.thinking", "Thinking")}</summary>
|
||||
<pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="chat-typing-indicator">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : messagesLoading ? (
|
||||
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
|
||||
) : messages.length === 0 && !activeSession ? (
|
||||
renderEmptyState()
|
||||
) : messages.length === 0 && activeSession ? (
|
||||
<div className="chat-empty-state">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<ChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
forcePlain={showAllAsPlain}
|
||||
agentName={agentName}
|
||||
hideAssistantIdentity={hideAssistantIdentity}
|
||||
showAssistantModelTag={showAssistantModelTag}
|
||||
activeModelTag={activeModelTag}
|
||||
activeModelProvider={activeModelProvider}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={null}
|
||||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||||
onScrollToTop={handleScrollMessageToTop}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
{isUserScrolling && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm chat-jump-to-latest"
|
||||
data-testid="chat-jump-to-latest"
|
||||
onClick={() => scrollToBottom("fab-click")}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
{t("chat.latest", "Latest")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
{activeSession && (
|
||||
<div className="chat-input-area">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,.txt,.json,.yaml,.yml,.log,.csv,.xml,.md"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={(event) => {
|
||||
handleAttachmentFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{showSkillMenu && (
|
||||
<div className="chat-skill-menu" data-testid="chat-skill-menu" role="listbox" aria-label={t("chat.skillSuggestions", "Skill suggestions")}>
|
||||
{skillsLoading ? (
|
||||
<div className="chat-skill-menu-empty">{t("chat.loadingSkills", "Loading skills…")}</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className="chat-skill-menu-empty">
|
||||
{skillFilter ? t("chat.noSkillsFound", "No skills found") : t("chat.noSkillsAvailable", "No skills available")}
|
||||
</div>
|
||||
) : (
|
||||
filteredSkills.map((skill, index) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === highlightedSkillIndex}
|
||||
className={`chat-skill-menu-item${index === highlightedSkillIndex ? " chat-skill-menu-item--highlighted" : ""}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHighlightedSkillIndex(index)}
|
||||
onClick={() => handleSkillSelect(skill)}
|
||||
>
|
||||
<span className="chat-skill-menu-item-name">{skill.name}</span>
|
||||
<span className="chat-skill-menu-item-description" title={skill.relativePath}>
|
||||
{skill.relativePath}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{pendingAttachments.length > 0 && (
|
||||
<div className="chat-attachment-previews" data-testid="chat-attachment-previews">
|
||||
{pendingAttachments.map((attachment, index) => (
|
||||
<div
|
||||
key={attachment.previewUrl || `${attachment.file.name}-${index}`}
|
||||
className="chat-attachment-preview"
|
||||
data-testid={`chat-attachment-preview-${index}`}
|
||||
>
|
||||
{attachment.previewUrl ? (
|
||||
<img src={attachment.previewUrl} alt={attachment.file.name} />
|
||||
) : (
|
||||
<span className="chat-attachment-preview-name">{attachment.file.name}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="chat-attachment-remove"
|
||||
onClick={() => removeAttachment(index)}
|
||||
data-testid={`chat-attachment-remove-${index}`}
|
||||
aria-label={`Remove ${attachment.file.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-input-row">
|
||||
{/* Messages + composer. CLI-backed chat sessions delegate this
|
||||
region to <CliChatSurface> (transcript/raw-terminal toggle +
|
||||
queued composer); generic-tier adapters render terminal-only. */}
|
||||
{cliChatActive ? (
|
||||
<CliChatSurface
|
||||
cliSessionId={cliTerminalSessionId}
|
||||
tier={cliChatTier}
|
||||
projectId={projectId}
|
||||
renderTranscript={renderSessionMessagesPane}
|
||||
renderComposer={() => (activeSession ? renderSessionComposerPane() : null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{renderSessionMessagesPane()}
|
||||
{isUserScrolling && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon chat-attach-btn"
|
||||
data-testid="chat-attach-btn"
|
||||
aria-label={t("chat.attachFiles", "Attach files")}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="btn btn-sm chat-jump-to-latest"
|
||||
data-testid="chat-jump-to-latest"
|
||||
onClick={() => scrollToBottom("fab-click")}
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
<ChevronDown size={14} />
|
||||
{t("chat.latest", "Latest")}
|
||||
</button>
|
||||
<div
|
||||
className={`chat-input-wrapper${isDragOver ? " chat-input-wrapper--dragover" : ""}`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(false);
|
||||
handleAttachmentFiles(event.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={handleComposerRef}
|
||||
className="chat-input-textarea"
|
||||
placeholder={t("chat.typeMessage", "Type a message...")}
|
||||
value={messageInput}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onKeyUp={handleInputKeyUp}
|
||||
onClick={handleInputSelectionChange}
|
||||
onBlur={handleInputBlur}
|
||||
onFocus={handleInputFocus}
|
||||
onPaste={handlePaste}
|
||||
onTouchStart={(event) => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.innerWidth > 768) return;
|
||||
// iOS-only: see comment on the other chat-input touchstart
|
||||
// handler above. On Android, preventDefault blocks the
|
||||
// soft keyboard from opening.
|
||||
if (!isIOS()) return;
|
||||
if (document.activeElement === event.currentTarget) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus({ preventScroll: true });
|
||||
}}
|
||||
rows={1}
|
||||
data-testid="chat-input"
|
||||
/>
|
||||
<AgentMentionPopup
|
||||
agents={mentionAgents}
|
||||
filter={mentionFilter}
|
||||
highlightedIndex={mentionHighlightIndex}
|
||||
visible={mentionPopupVisible}
|
||||
onSelect={handleMentionSelect}
|
||||
position="below"
|
||||
roomMemberIds={roomContext?.memberIds}
|
||||
roomName={roomContext?.roomName}
|
||||
/>
|
||||
<FileMentionPopup
|
||||
visible={fileMention.mentionActive && !mentionPopupVisible}
|
||||
position={fileMentionPosition}
|
||||
tasks={fileMention.tasks}
|
||||
files={fileMention.files}
|
||||
selectedIndex={fileMention.selectedIndex}
|
||||
onSelectTask={(task) => {
|
||||
insertHashMention(fileMention.selectTask(task, messageInput), `#${task.id}`);
|
||||
}}
|
||||
onSelectFile={(file) => {
|
||||
insertHashMention(fileMention.selectFile(file, messageInput), `#${file.path}`);
|
||||
}}
|
||||
loading={fileMention.loading}
|
||||
/>
|
||||
{pendingMessage && (
|
||||
<div className="chat-pending-message" data-testid="chat-pending-indicator">
|
||||
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: pendingPreview })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-pending-message-dismiss"
|
||||
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
|
||||
data-testid="chat-pending-dismiss"
|
||||
onClick={clearPendingMessage}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isStreaming ? (
|
||||
<button
|
||||
className="chat-input-stop"
|
||||
onClick={stopStreaming}
|
||||
aria-label={t("chat.stopGeneration", "Stop generation")}
|
||||
data-testid="chat-stop-btn"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-input-send"
|
||||
// Keep keyboard up when sending. preventDefault fires on
|
||||
// pointerdown for touch pointers (BEFORE iOS blurs the
|
||||
// textarea — the synthesized mousedown is too late on
|
||||
// iOS), and on mousedown for desktop. Crucially we do NOT
|
||||
// call preventDefault on touchstart and we do NOT run the
|
||||
// action here — both of those broke quick taps. Click
|
||||
// still fires from the iOS touch sequence and runs the
|
||||
// action reliably.
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType && event.pointerType !== "mouse") {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onClick={() => {
|
||||
void handleSend();
|
||||
}}
|
||||
disabled={!messageInput.trim() && pendingAttachments.length === 0}
|
||||
data-testid="chat-send-btn"
|
||||
style={{ touchAction: "manipulation" }}
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeSession && renderSessionComposerPane()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
125
packages/dashboard/app/components/CliChatSurface.tsx
Normal file
125
packages/dashboard/app/components/CliChatSurface.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
// CliChatSurface — the CLI-backed chat rendering surface (CLI Agent Executor, U12).
|
||||
//
|
||||
// ChatView delegates to this component when the active chat session selects a
|
||||
// cli-agent executor. It encapsulates the three KTD behaviors that distinguish
|
||||
// a CLI-backed chat from a provider chat:
|
||||
//
|
||||
// 1. Hybrid (native/hybrid tier): render the durable transcript as today PLUS a
|
||||
// transcript ↔ terminal toggle. Raw-terminal mode swaps the message list for
|
||||
// <SessionTerminal> and HIDES the composer (the terminal owns input);
|
||||
// toggling back restores the transcript and composer.
|
||||
// 2. Generic tier: terminal-ONLY. No toggle, no transcript pane — the affordance
|
||||
// is absent, not empty (screen-output parsing for a structured transcript is
|
||||
// out of scope for generic CLIs).
|
||||
// 3. Composer queued state: while the underlying CLI session is busy, sends are
|
||||
// queued with a visible indicator. The flush decision is owned server-side
|
||||
// (CliChatSessionRunner, which re-fetches authoritative state — the
|
||||
// stale-isGenerating learning); this component only surfaces the queued count.
|
||||
//
|
||||
// Rendering of the transcript message list itself stays with ChatView's existing
|
||||
// renderer (passed in as `renderTranscript`) so there is no parallel message UI.
|
||||
import React, { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Terminal as TerminalIcon, MessageSquare } from "lucide-react";
|
||||
import { SessionTerminal, type SessionTerminalProps } from "./SessionTerminal";
|
||||
|
||||
/** Adapter capability tier — drives whether a transcript view exists at all. */
|
||||
export type CliChatTier = "native" | "hybrid" | "generic";
|
||||
|
||||
export interface CliChatSurfaceProps {
|
||||
/** Live CLI session id to attach the terminal to. */
|
||||
cliSessionId: string;
|
||||
/** Adapter tier. Generic → terminal-only (no toggle, no transcript). */
|
||||
tier: CliChatTier;
|
||||
projectId?: string;
|
||||
/** Renders the existing ChatView transcript message list. */
|
||||
renderTranscript: () => ReactNode;
|
||||
/** Renders the existing ChatView composer (hidden in raw-terminal mode). */
|
||||
renderComposer: () => ReactNode;
|
||||
/** Number of composer messages queued behind a busy session (0 = none). */
|
||||
queuedCount?: number;
|
||||
/** Extra props forwarded to SessionTerminal (posture, settings link, etc.). */
|
||||
terminalProps?: Partial<Omit<SessionTerminalProps, "sessionId" | "projectId">>;
|
||||
}
|
||||
|
||||
type SurfaceView = "transcript" | "terminal";
|
||||
|
||||
export function CliChatSurface({
|
||||
cliSessionId,
|
||||
tier,
|
||||
projectId,
|
||||
renderTranscript,
|
||||
renderComposer,
|
||||
queuedCount = 0,
|
||||
terminalProps,
|
||||
}: CliChatSurfaceProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const isGeneric = tier === "generic";
|
||||
// Generic tier is terminal-only; hybrid/native default to the transcript view.
|
||||
const [view, setView] = useState<SurfaceView>(isGeneric ? "terminal" : "transcript");
|
||||
|
||||
// Generic tier: render the terminal directly, no toggle, no composer, no
|
||||
// transcript pane. The terminal owns all input.
|
||||
if (isGeneric) {
|
||||
return (
|
||||
<div className="cli-chat-surface cli-chat-surface--generic" data-tier="generic">
|
||||
<SessionTerminal sessionId={cliSessionId} projectId={projectId} {...terminalProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showTerminal = view === "terminal";
|
||||
|
||||
return (
|
||||
<div className="cli-chat-surface" data-tier={tier} data-view={view}>
|
||||
<div className="cli-chat-surface__toolbar" role="tablist" aria-label={t("cliChat.viewToggleLabel", "Chat view")}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={!showTerminal}
|
||||
className={`cli-chat-surface__tab${!showTerminal ? " is-active" : ""}`}
|
||||
onClick={() => setView("transcript")}
|
||||
>
|
||||
<MessageSquare size={14} aria-hidden="true" />
|
||||
<span>{t("cliChat.transcriptTab", "Transcript")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={showTerminal}
|
||||
className={`cli-chat-surface__tab${showTerminal ? " is-active" : ""}`}
|
||||
onClick={() => setView("terminal")}
|
||||
>
|
||||
<TerminalIcon size={14} aria-hidden="true" />
|
||||
<span>{t("cliChat.terminalTab", "Terminal")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="cli-chat-surface__body">
|
||||
{showTerminal ? (
|
||||
// Raw-terminal mode: the message list is swapped out and the terminal
|
||||
// owns input. Composer is hidden below.
|
||||
<SessionTerminal sessionId={cliSessionId} projectId={projectId} {...terminalProps} />
|
||||
) : (
|
||||
renderTranscript()
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Composer is hidden in raw-terminal mode — the terminal owns input. */}
|
||||
{!showTerminal && (
|
||||
<div className="cli-chat-surface__composer">
|
||||
{queuedCount > 0 && (
|
||||
<div className="cli-chat-surface__queued" role="status" aria-live="polite">
|
||||
{t("cliChat.queued", "{{count}} message queued — will send when the agent is ready", {
|
||||
count: queuedCount,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{renderComposer()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CliChatSurface;
|
||||
@@ -1,22 +1,35 @@
|
||||
import "./SessionNotificationBanner.css";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Lightbulb, Layers, Target, X } from "lucide-react";
|
||||
import type { AiSessionSummary } from "../api";
|
||||
import { AlertCircle, Lightbulb, Layers, Target, Terminal, X } from "lucide-react";
|
||||
import type { AiSessionSummary, CliNeedsAttentionVariant } from "../api";
|
||||
|
||||
type CliActionId = "advance" | "retry" | "cancel" | "reauthenticate" | "relaunch";
|
||||
|
||||
interface SessionNotificationBannerProps {
|
||||
sessions: AiSessionSummary[];
|
||||
onResumeSession: (session: AiSessionSummary) => void;
|
||||
onDismissSession: (id: string) => void;
|
||||
onDismissAll: () => void;
|
||||
/**
|
||||
* CLI agent needs-attention / confirm-advance actions (CLI Agent Executor,
|
||||
* U11). `advance` wires the userExited "Advance" verb + generic-tier
|
||||
* confirm-advance; the others map to existing endpoints where present, else
|
||||
* are no-op callbacks marked TODO-wire by the caller.
|
||||
*/
|
||||
onCliAction?: (session: AiSessionSummary, action: CliActionId) => void;
|
||||
}
|
||||
|
||||
// `cli-agent` extends the previously-closed union: a SINGLE Terminal icon for
|
||||
// all adapters (reusing the banner without this entry crashes on the unknown
|
||||
// type — the union-regression the U11 tests guard).
|
||||
const TYPE_ICONS = {
|
||||
planning: Lightbulb,
|
||||
subtask: Layers,
|
||||
mission_interview: Target,
|
||||
milestone_interview: Target,
|
||||
slice_interview: Target,
|
||||
"cli-agent": Terminal,
|
||||
} as const;
|
||||
|
||||
const TYPE_LABEL_KEYS: Record<keyof typeof TYPE_ICONS, { key: string; defaultVal: string }> = {
|
||||
@@ -25,6 +38,38 @@ const TYPE_LABEL_KEYS: Record<keyof typeof TYPE_ICONS, { key: string; defaultVal
|
||||
mission_interview: { key: "sessionBanner.typeLabel.missionInterview", defaultVal: "Mission Interview" },
|
||||
milestone_interview: { key: "sessionBanner.typeLabel.milestoneInterview", defaultVal: "Milestone Interview" },
|
||||
slice_interview: { key: "sessionBanner.typeLabel.sliceInterview", defaultVal: "Slice Interview" },
|
||||
"cli-agent": { key: "sessionBanner.typeLabel.cliAgent", defaultVal: "CLI Agent" },
|
||||
};
|
||||
|
||||
/** Action verb defaults (i18n) for each pinned needs-attention variant. */
|
||||
const CLI_ACTION_LABELS: Record<CliActionId, { key: string; defaultVal: string }> = {
|
||||
advance: { key: "sessionBanner.cli.advance", defaultVal: "Advance" },
|
||||
retry: { key: "sessionBanner.cli.retry", defaultVal: "Retry" },
|
||||
cancel: { key: "sessionBanner.cli.cancelTask", defaultVal: "Cancel task" },
|
||||
reauthenticate: { key: "sessionBanner.cli.reauthenticate", defaultVal: "Re-authenticate" },
|
||||
relaunch: { key: "sessionBanner.cli.relaunch", defaultVal: "Relaunch fresh" },
|
||||
};
|
||||
|
||||
/** Pinned copy + ordered actions per needs-attention variant (U11). */
|
||||
const CLI_VARIANT_SPEC: Record<
|
||||
CliNeedsAttentionVariant,
|
||||
{ messageKey: string; messageDefault: string; actions: CliActionId[] }
|
||||
> = {
|
||||
userExited: {
|
||||
messageKey: "sessionBanner.cli.userExited",
|
||||
messageDefault: "Agent exited before completing",
|
||||
actions: ["advance", "retry", "cancel"],
|
||||
},
|
||||
authFailed: {
|
||||
messageKey: "sessionBanner.cli.authFailed",
|
||||
messageDefault: "CLI authentication failed",
|
||||
actions: ["reauthenticate", "retry"],
|
||||
},
|
||||
"resume-exhausted": {
|
||||
messageKey: "sessionBanner.cli.resumeExhausted",
|
||||
messageDefault: "Couldn't resume the session",
|
||||
actions: ["relaunch", "cancel"],
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "fusion:session-banner-dismissed";
|
||||
@@ -66,6 +111,21 @@ function persistDismissed(map: Map<string, number>): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statuses that warrant a banner entry. Extended for CLI agent sessions:
|
||||
* `waiting_on_input` (F2) and `needs_attention` (pinned variants) join the
|
||||
* existing `awaiting_input` / `error`. A CLI session returning to `busy`
|
||||
* (no longer in this set) clears the banner entry — covering F2.
|
||||
*/
|
||||
function isNotifyingStatus(status: AiSessionSummary["status"]): boolean {
|
||||
return (
|
||||
status === "awaiting_input" ||
|
||||
status === "error" ||
|
||||
status === "waiting_on_input" ||
|
||||
status === "needs_attention"
|
||||
);
|
||||
}
|
||||
|
||||
// Map of sessionId → epoch-ms timestamp at which the user dismissed the
|
||||
// banner for that session. The banner re-shows the session only when the
|
||||
// session's `updatedAt` advances strictly past the recorded dismissal time
|
||||
@@ -78,6 +138,7 @@ export function SessionNotificationBanner({
|
||||
onResumeSession,
|
||||
onDismissSession,
|
||||
onDismissAll,
|
||||
onCliAction,
|
||||
}: SessionNotificationBannerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [dismissRevision, setDismissRevision] = useState(0);
|
||||
@@ -98,7 +159,7 @@ export function SessionNotificationBanner({
|
||||
for (const [id, dismissedAtMs] of dismissedIds) {
|
||||
const session = sessionById.get(id);
|
||||
if (!session) continue;
|
||||
const stillNotifying = session.status === "awaiting_input" || session.status === "error";
|
||||
const stillNotifying = isNotifyingStatus(session.status);
|
||||
if (!stillNotifying) {
|
||||
dismissedIds.delete(id);
|
||||
pruned = true;
|
||||
@@ -117,7 +178,7 @@ export function SessionNotificationBanner({
|
||||
const sessionsNeedingInput = useMemo(
|
||||
() =>
|
||||
sessions.filter((session) => {
|
||||
if (session.status !== "awaiting_input" && session.status !== "error") return false;
|
||||
if (!isNotifyingStatus(session.status)) return false;
|
||||
const dismissedAtMs = dismissedIds.get(session.id);
|
||||
if (dismissedAtMs === undefined) return true;
|
||||
return parseUpdatedAtMs(session.updatedAt) > dismissedAtMs;
|
||||
@@ -129,8 +190,14 @@ export function SessionNotificationBanner({
|
||||
return null;
|
||||
}
|
||||
|
||||
const awaitingInputCount = sessionsNeedingInput.filter((s) => s.status === "awaiting_input").length;
|
||||
const errorCount = sessionsNeedingInput.filter((s) => s.status === "error").length;
|
||||
// CLI `waiting_on_input` rolls into the "needs input" count; `needs_attention`
|
||||
// rolls into the "failed" count for the summary header.
|
||||
const awaitingInputCount = sessionsNeedingInput.filter(
|
||||
(s) => s.status === "awaiting_input" || s.status === "waiting_on_input",
|
||||
).length;
|
||||
const errorCount = sessionsNeedingInput.filter(
|
||||
(s) => s.status === "error" || s.status === "needs_attention",
|
||||
).length;
|
||||
|
||||
let headerText = "";
|
||||
if (awaitingInputCount > 0 && errorCount > 0) {
|
||||
@@ -207,6 +274,64 @@ export function SessionNotificationBanner({
|
||||
{sessionsNeedingInput.map((session) => {
|
||||
const Icon = TYPE_ICONS[session.type];
|
||||
const isError = session.status === "error";
|
||||
const variantSpec =
|
||||
session.type === "cli-agent" && session.cliVariant
|
||||
? CLI_VARIANT_SPEC[session.cliVariant]
|
||||
: null;
|
||||
|
||||
// Pinned needs-attention variant: per-variant copy + ordered actions.
|
||||
if (variantSpec) {
|
||||
return (
|
||||
<article
|
||||
className="session-notification-banner__item session-notification-banner__item--cli session-notification-banner__item--error"
|
||||
key={session.id}
|
||||
data-session-type={session.type}
|
||||
data-session-status={session.status}
|
||||
data-cli-variant={session.cliVariant}
|
||||
>
|
||||
<div className="session-notification-banner__item-main">
|
||||
<Icon size={16} className="session-notification-banner__type-icon" aria-hidden="true" />
|
||||
<div className="session-notification-banner__text">
|
||||
<p className="session-notification-banner__title" title={session.title}>{session.title}</p>
|
||||
<p className="session-notification-banner__meta">
|
||||
{t(variantSpec.messageKey, variantSpec.messageDefault)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="session-notification-banner__actions">
|
||||
{variantSpec.actions.map((action) => (
|
||||
<button
|
||||
key={action}
|
||||
className="session-notification-banner__resume"
|
||||
data-cli-action={action}
|
||||
onClick={() => {
|
||||
// "advance" wires confirm-advance; other verbs hit
|
||||
// existing endpoints or remain TODO-wire no-ops upstream.
|
||||
onCliAction?.(session, action);
|
||||
if (action === "cancel" || action === "advance") {
|
||||
dismissLocally(session);
|
||||
onDismissSession(session.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(CLI_ACTION_LABELS[action].key, CLI_ACTION_LABELS[action].defaultVal)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="session-notification-banner__dismiss"
|
||||
onClick={() => {
|
||||
dismissLocally(session);
|
||||
onDismissSession(session.id);
|
||||
}}
|
||||
aria-label={t("sessionBanner.dismissItem", "Dismiss {{title}}", { title: session.title })}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
|
||||
276
packages/dashboard/app/components/SessionTerminal.css
Normal file
276
packages/dashboard/app/components/SessionTerminal.css
Normal file
@@ -0,0 +1,276 @@
|
||||
/* SessionTerminal (CLI Agent Executor, U11) — canonical tokens only. */
|
||||
|
||||
.cli-session-terminal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
background: var(--terminal-bg, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cli-session-terminal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cli-session-terminal__posture-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cli-posture-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 2px var(--space-sm);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast, 0.12s) ease;
|
||||
}
|
||||
|
||||
.cli-posture-chip:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.cli-posture-chip--elevated {
|
||||
color: var(--warning, var(--color-warning));
|
||||
border-color: var(--warning, var(--color-warning));
|
||||
}
|
||||
|
||||
.cli-posture-chip__mode {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cli-posture-chip__flag {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cli-posture-tooltip {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-xs));
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
min-width: 220px;
|
||||
padding: var(--space-sm);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.cli-posture-tooltip__title {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cli-posture-tooltip__list {
|
||||
margin: 0;
|
||||
padding-left: var(--space-md);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cli-posture-tooltip__settings {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
padding: 2px var(--space-sm);
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent, var(--color-primary));
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cli-session-terminal__readonly-badge,
|
||||
.cli-session-terminal__replay-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 2px var(--space-sm);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.cli-session-terminal__replay-badge[data-replay-mode="ended"] {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cli-session-terminal__viewport {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: var(--space-xs);
|
||||
background: var(--terminal-bg, var(--bg));
|
||||
}
|
||||
|
||||
.cli-session-terminal__advance-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cli-session-terminal__advance-copy {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cli-session-terminal__advance-actions {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.cli-session-terminal__advance-btn {
|
||||
padding: 4px var(--space-md);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--button-primary-text, var(--accent-text));
|
||||
background: var(--button-primary-bg, var(--accent));
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cli-session-terminal__advance-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cli-session-terminal__advance-btn--secondary {
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* ── Mobile input model (U13) ──────────────────────────────────────────────
|
||||
* Visible input field + accessory key bar. xterm's hidden-textarea input is
|
||||
* unreliable on mobile (KTD), so the bar is the primary input surface. The
|
||||
* bar is a fixed footer that lifts above the virtual keyboard when it opens
|
||||
* (driven by useMobileKeyboard's keyboardOverlap, applied inline).
|
||||
*/
|
||||
.cli-session-terminal__mobile-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm)
|
||||
calc(var(--space-sm) + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cli-session-terminal__mobile-bar--keyboard-open {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
/* `bottom` is set inline to keyboardOverlap so the bar clears the keyboard. */
|
||||
padding-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.cli-session-terminal__key-row {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.cli-session-terminal__key-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cli-terminal-key {
|
||||
flex: 0 0 auto;
|
||||
min-width: 40px;
|
||||
min-height: 36px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.cli-terminal-key:active {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.cli-terminal-key--ctrl.cli-terminal-key--active {
|
||||
color: var(--accent-text, var(--button-primary-text));
|
||||
background: var(--accent, var(--color-primary));
|
||||
border-color: var(--accent, var(--color-primary));
|
||||
}
|
||||
|
||||
.cli-terminal-key--ctrlc {
|
||||
color: var(--warning, var(--color-warning));
|
||||
border-color: var(--warning, var(--color-warning));
|
||||
}
|
||||
|
||||
.cli-session-terminal__input-row {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.cli-session-terminal__mobile-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 38px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: 16px; /* >=16px avoids iOS focus zoom */
|
||||
color: var(--text);
|
||||
background: var(--input-bg, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.cli-session-terminal__mobile-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent, var(--color-primary));
|
||||
}
|
||||
|
||||
.cli-session-terminal__mobile-send {
|
||||
flex: 0 0 auto;
|
||||
min-height: 38px;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--button-primary-text, var(--accent-text));
|
||||
background: var(--button-primary-bg, var(--accent));
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* On mobile the terminal viewport is read-mostly; the bar drives input. */
|
||||
@media (max-width: 768px), (max-height: 480px) {
|
||||
.cli-session-terminal__viewport {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
730
packages/dashboard/app/components/SessionTerminal.tsx
Normal file
730
packages/dashboard/app/components/SessionTerminal.tsx
Normal file
@@ -0,0 +1,730 @@
|
||||
import "./SessionTerminal.css";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Terminal as TerminalIcon, ShieldAlert, Settings, Eye } from "lucide-react";
|
||||
import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm";
|
||||
import { appendTokenQuery } from "../auth";
|
||||
import { api } from "../api";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
|
||||
/**
|
||||
* SessionTerminal (CLI Agent Executor, U11) — shared xterm terminal for a CLI
|
||||
* agent session. Lazy-loads xterm + fit/webgl/unicode11 addons (kept out of the
|
||||
* main bundle), bridges to the U10 WebSocket attach channel with ACK flow
|
||||
* control, and renders the posture chip / read-only badge / confirm-advance
|
||||
* strip / replay states described in the U11 visibility matrix.
|
||||
*
|
||||
* The WS bridge:
|
||||
* 1. POST /api/cli-sessions/:id/attach-ticket → { ticket }
|
||||
* 2. open WS /api/cli-sessions/ws?sessionId=&ticket= (fn_token carried on URL)
|
||||
* 3. base64 scrollback/data → term.write; term.onData → input frames
|
||||
* 4. fit + debounced ResizeObserver → resize frames
|
||||
* 5. ACK {type:"ack",bytes} via term.write callbacks (~32KB cadence)
|
||||
*/
|
||||
|
||||
/** ACK cadence — ACK roughly every 32KB of consumed output. */
|
||||
const ACK_THRESHOLD_BYTES = 32 * 1024;
|
||||
const RESIZE_DEBOUNCE_MS = 100;
|
||||
|
||||
/**
|
||||
* Canonical mobile breakpoint (matches the repo CSS convention). Landscape
|
||||
* phones exceed 768px wide, so the height clause covers them too.
|
||||
*/
|
||||
const MOBILE_MEDIA_QUERY = "(max-width: 768px), (max-height: 480px)";
|
||||
|
||||
/**
|
||||
* Control sequences emitted by the accessory key bar (U13). These are
|
||||
* deliberate user keystrokes routed straight to the session input path —
|
||||
* exempt from U2's injected-text neutralization (which governs composed /
|
||||
* injected strings, not real keystrokes).
|
||||
*/
|
||||
const SEQ_ESC = "\x1b"; // 0x1B
|
||||
const SEQ_TAB = "\x09"; // 0x09
|
||||
const SEQ_CTRL_C = "\x03"; // 0x03
|
||||
const SEQ_ARROW_UP = "\x1b[A"; // CSI A
|
||||
const SEQ_ARROW_DOWN = "\x1b[B"; // CSI B
|
||||
const SEQ_ARROW_RIGHT = "\x1b[C"; // CSI C
|
||||
const SEQ_ARROW_LEFT = "\x1b[D"; // CSI D
|
||||
|
||||
/**
|
||||
* Resolve the control byte for a sticky-Ctrl + key combination. Ctrl maps a
|
||||
* letter to its control code (A→0x01 … Z→0x1A): code = (toUpper(ch) & 0x1f).
|
||||
* Returns null for keys that have no meaningful Ctrl combination.
|
||||
*/
|
||||
function ctrlCombo(key: string): string | null {
|
||||
if (key.length !== 1) return null;
|
||||
const upper = key.toUpperCase();
|
||||
const code = upper.charCodeAt(0);
|
||||
if (code >= 0x40 && code <= 0x5f) {
|
||||
// @ A-Z [ \ ] ^ _ → 0x00-0x1F
|
||||
return String.fromCharCode(code & 0x1f);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Reactive mobile-viewport detection via the repo breakpoint convention. */
|
||||
function useIsMobileViewport(): boolean {
|
||||
const [isMobile, setIsMobile] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return false;
|
||||
}
|
||||
return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return;
|
||||
}
|
||||
const mql = window.matchMedia(MOBILE_MEDIA_QUERY);
|
||||
const onChange = () => setIsMobile(mql.matches);
|
||||
onChange();
|
||||
// Safari < 14 only has addListener/removeListener.
|
||||
if (typeof mql.addEventListener === "function") {
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}
|
||||
mql.addListener(onChange);
|
||||
return () => mql.removeListener(onChange);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
/** The posture surfaced on the session record (denormalized at launch, U15). */
|
||||
export interface SessionTerminalPosture {
|
||||
/** Adapter display name (single Terminal icon for all adapters). */
|
||||
adapterName: string;
|
||||
/** Resolved autonomy mode label (e.g. "auto-approve", "default"). */
|
||||
mode?: string;
|
||||
/**
|
||||
* Whether the resolved argv+env elevates above the adapter baseline. When
|
||||
* true the chip renders in warning color with a shield naming the flag.
|
||||
*/
|
||||
elevated?: boolean;
|
||||
/** The elevated flag(s), named on the chip / tooltip when elevated. */
|
||||
elevatedFlags?: string[];
|
||||
/** Resolved posture lines shown in the click tooltip. */
|
||||
resolved?: string[];
|
||||
}
|
||||
|
||||
/** Replay/live mode for the terminal viewport. */
|
||||
export type SessionTerminalMode = "live" | "idle" | "ended";
|
||||
|
||||
export interface SessionTerminalProps {
|
||||
sessionId: string;
|
||||
/** When true, term.onData is dropped (one-shot / replay sessions). */
|
||||
readOnly?: boolean;
|
||||
posture?: SessionTerminalPosture;
|
||||
/** Drives the replay header: live | "session idle" | "session ended". */
|
||||
mode?: SessionTerminalMode;
|
||||
projectId?: string;
|
||||
/** Generic-tier idle confirm-advance strip — POST confirm-advance on Advance. */
|
||||
onConfirmAdvance?: (decision: "advance" | "not-yet") => void | Promise<void>;
|
||||
/** Whether the confirm-advance strip is offered (generic-tier idle). */
|
||||
showConfirmAdvance?: boolean;
|
||||
/** Settings deep link for the posture chip tooltip. */
|
||||
onOpenAdapterSettings?: () => void;
|
||||
}
|
||||
|
||||
interface AttachTicketResponse {
|
||||
ticket: string;
|
||||
expiresAt: string;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
/** Build the WS URL for the cli-sessions attach channel (mirrors useTerminal). */
|
||||
function buildCliWsUrl(sessionId: string, ticket: string): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const base =
|
||||
`${protocol}//${window.location.host}/api/cli-sessions/ws` +
|
||||
`?sessionId=${encodeURIComponent(sessionId)}&ticket=${encodeURIComponent(ticket)}`;
|
||||
return appendTokenQuery(base);
|
||||
}
|
||||
|
||||
function decodeBase64ToString(b64: string): string {
|
||||
if (typeof window !== "undefined" && typeof window.atob === "function") {
|
||||
// atob → binary string → UTF-8 decode.
|
||||
const binary = window.atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return new TextDecoder("utf-8").decode(bytes);
|
||||
}
|
||||
return Buffer.from(b64, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
export function SessionTerminal({
|
||||
sessionId,
|
||||
readOnly = false,
|
||||
posture,
|
||||
mode = "live",
|
||||
projectId,
|
||||
onConfirmAdvance,
|
||||
showConfirmAdvance = false,
|
||||
onOpenAdapterSettings,
|
||||
}: SessionTerminalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const xtermRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<ITerminalAddon | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const [postureTooltipOpen, setPostureTooltipOpen] = useState(false);
|
||||
const [advanceDismissed, setAdvanceDismissed] = useState(false);
|
||||
const [advancePending, setAdvancePending] = useState(false);
|
||||
|
||||
// ── Mobile input model (U13) ───────────────────────────────────────────────
|
||||
const isMobile = useIsMobileViewport();
|
||||
// Only arm keyboard tracking on mobile (the hook no-ops off-mobile anyway).
|
||||
const { keyboardOpen, keyboardOverlap } = useMobileKeyboard({ enabled: isMobile });
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [mobileInput, setMobileInput] = useState("");
|
||||
// Sticky Ctrl: tap Ctrl, then the next tapped key combines into a control
|
||||
// sequence (Ctrl-C → 0x03, Ctrl-D → 0x04, Ctrl-Z → 0x1A).
|
||||
const [ctrlSticky, setCtrlSticky] = useState(false);
|
||||
|
||||
/** Write raw bytes to the session input path (mobile bar + submit). */
|
||||
const sendInput = useCallback((data: string) => {
|
||||
if (!data) return;
|
||||
const ws = wsRef.current;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Emit one accessory-bar key. If sticky Ctrl is active and the key has a
|
||||
* Ctrl combination, send the combined control byte and clear the modifier;
|
||||
* otherwise send the literal sequence. Keeps the input focused (the caller's
|
||||
* pointerdown preventDefault stops the blur).
|
||||
*/
|
||||
const emitBarKey = useCallback(
|
||||
(seq: string) => {
|
||||
if (ctrlSticky) {
|
||||
const combined = ctrlCombo(seq);
|
||||
setCtrlSticky(false);
|
||||
if (combined) {
|
||||
sendInput(combined);
|
||||
return;
|
||||
}
|
||||
}
|
||||
sendInput(seq);
|
||||
},
|
||||
[ctrlSticky, sendInput],
|
||||
);
|
||||
|
||||
/** iOS composer pattern: keep focus on the visible input when tapping a key. */
|
||||
const keepFocus = useCallback((e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const handleMobileSubmit = useCallback(
|
||||
(e?: { preventDefault?: () => void }) => {
|
||||
e?.preventDefault?.();
|
||||
// User-typed text + Enter — deliberate input, no neutralization.
|
||||
if (mobileInput) sendInput(mobileInput);
|
||||
sendInput("\r");
|
||||
setMobileInput("");
|
||||
},
|
||||
[mobileInput, sendInput],
|
||||
);
|
||||
|
||||
/**
|
||||
* Input onChange. When sticky Ctrl is armed, the next typed character is
|
||||
* captured as a Ctrl combination (Ctrl-D `0x04`, Ctrl-Z `0x1A`, …) instead of
|
||||
* landing in the field — this is how Ctrl-letter chords beyond the bar's
|
||||
* dedicated Ctrl-C are reached on mobile. Otherwise the value updates
|
||||
* normally for free-text + Enter submit.
|
||||
*/
|
||||
const handleMobileInputChange = useCallback(
|
||||
(next: string) => {
|
||||
if (ctrlSticky && next.length > mobileInput.length) {
|
||||
// The newly-typed character is the last one appended.
|
||||
const ch = next.slice(mobileInput.length, mobileInput.length + 1);
|
||||
const combined = ctrlCombo(ch);
|
||||
setCtrlSticky(false);
|
||||
if (combined) {
|
||||
sendInput(combined);
|
||||
return; // swallow — do not echo the raw key into the field
|
||||
}
|
||||
}
|
||||
setMobileInput(next);
|
||||
},
|
||||
[ctrlSticky, mobileInput, sendInput],
|
||||
);
|
||||
|
||||
// Re-arm the strip whenever a fresh idle window is offered.
|
||||
useEffect(() => {
|
||||
if (showConfirmAdvance) setAdvanceDismissed(false);
|
||||
}, [showConfirmAdvance, sessionId]);
|
||||
|
||||
// ── xterm lifecycle + WS bridge ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!sessionId || typeof window === "undefined") return;
|
||||
let disposed = false;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let unackedBytes = 0;
|
||||
|
||||
const sendResize = (cols: number, rows: number) => {
|
||||
const ws = wsRef.current;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
};
|
||||
|
||||
const ackBytes = (n: number) => {
|
||||
unackedBytes += n;
|
||||
if (unackedBytes < ACK_THRESHOLD_BYTES) return;
|
||||
const ws = wsRef.current;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ack", bytes: unackedBytes }));
|
||||
}
|
||||
unackedBytes = 0;
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
// 1. Mint a single-use attach ticket via the app API helper.
|
||||
let ticketRes: AttachTicketResponse;
|
||||
try {
|
||||
ticketRes = await api<AttachTicketResponse>(
|
||||
`/cli-sessions/${encodeURIComponent(sessionId)}/attach-ticket`,
|
||||
{ method: "POST", body: JSON.stringify(projectId ? { projectId } : {}) },
|
||||
);
|
||||
} catch {
|
||||
return; // surfaced via the "disconnected" state header below
|
||||
}
|
||||
if (disposed) return;
|
||||
|
||||
// 2. Lazy-load xterm + addons (out of the main bundle).
|
||||
const [{ Terminal }, { FitAddon }, { Unicode11Addon }] = await Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
import("@xterm/addon-unicode11"),
|
||||
]);
|
||||
if (disposed || !containerRef.current) return;
|
||||
|
||||
const term = new Terminal({
|
||||
convertEol: false,
|
||||
cursorBlink: !readOnly && mode === "live",
|
||||
disableStdin: readOnly,
|
||||
scrollback: 10000,
|
||||
// Defensive: do NOT register an OSC 52 (clipboard-write) handler. The
|
||||
// server-side neutralizer (U10) strips it; we add no client handling.
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
fontSize: 13,
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
const unicode11 = new Unicode11Addon();
|
||||
term.loadAddon(unicode11);
|
||||
term.unicode.activeVersion = "11";
|
||||
|
||||
term.open(containerRef.current);
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon as unknown as ITerminalAddon;
|
||||
|
||||
// WebGL renderer with context-loss fallback to the DOM renderer.
|
||||
try {
|
||||
const { WebglAddon } = await import("@xterm/addon-webgl");
|
||||
if (!disposed) {
|
||||
const webgl = new WebglAddon();
|
||||
webgl.onContextLoss(() => {
|
||||
try {
|
||||
webgl.dispose();
|
||||
} catch {
|
||||
/* fall back to DOM renderer */
|
||||
}
|
||||
});
|
||||
term.loadAddon(webgl);
|
||||
}
|
||||
} catch {
|
||||
/* WebGL unavailable — DOM renderer is the default fallback */
|
||||
}
|
||||
|
||||
try {
|
||||
(fitAddon as unknown as { fit: () => void }).fit();
|
||||
} catch {
|
||||
/* container not measurable yet */
|
||||
}
|
||||
|
||||
// term.onData → input frames (skip entirely when read-only).
|
||||
if (!readOnly) {
|
||||
term.onData((data: string) => {
|
||||
const ws = wsRef.current;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Debounced ResizeObserver → resize frames.
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
try {
|
||||
(fitAddon as unknown as { fit: () => void }).fit();
|
||||
sendResize(term.cols, term.rows);
|
||||
} catch {
|
||||
/* ignore transient measure failures */
|
||||
}
|
||||
}, RESIZE_DEBOUNCE_MS);
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
// 3. Open the WS attach channel.
|
||||
const ws = new WebSocket(buildCliWsUrl(sessionId, ticketRes.ticket));
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
sendResize(term.cols, term.rows);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let msg: { type?: string; data?: string };
|
||||
try {
|
||||
msg = JSON.parse(typeof event.data === "string" ? event.data : "");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
switch (msg.type) {
|
||||
case "scrollback":
|
||||
case "data": {
|
||||
if (typeof msg.data !== "string") return;
|
||||
const text = decodeBase64ToString(msg.data);
|
||||
const byteLen = text.length;
|
||||
// ACK once xterm has flushed the chunk to the screen.
|
||||
term.write(text, () => ackBytes(byteLen));
|
||||
break;
|
||||
}
|
||||
// state / error / exit frames are advisory; the SSE channel and the
|
||||
// mode prop drive header copy. We intentionally do not mutate the
|
||||
// viewport on them.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
void init();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
if (resizeObserver) resizeObserver.disconnect();
|
||||
const ws = wsRef.current;
|
||||
if (ws) {
|
||||
ws.onopen = null;
|
||||
ws.onmessage = null;
|
||||
ws.onclose = null;
|
||||
ws.onerror = null;
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* already closing */
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
const term = xtermRef.current;
|
||||
if (term) {
|
||||
try {
|
||||
term.dispose();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
xtermRef.current = null;
|
||||
}
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, [sessionId, readOnly, mode, projectId]);
|
||||
|
||||
const replayLabel = useMemo(() => {
|
||||
if (mode === "idle") return t("cliTerminal.replayIdle", "Session idle");
|
||||
if (mode === "ended") return t("cliTerminal.replayEnded", "Session ended");
|
||||
return null;
|
||||
}, [mode, t]);
|
||||
|
||||
const handleAdvance = useCallback(async () => {
|
||||
if (!onConfirmAdvance) return;
|
||||
setAdvancePending(true);
|
||||
try {
|
||||
await onConfirmAdvance("advance");
|
||||
setAdvanceDismissed(true);
|
||||
} finally {
|
||||
setAdvancePending(false);
|
||||
}
|
||||
}, [onConfirmAdvance]);
|
||||
|
||||
const handleNotYet = useCallback(async () => {
|
||||
if (onConfirmAdvance) await onConfirmAdvance("not-yet");
|
||||
// "Not yet" stays in execute and re-arms the idle timer (server-side); the
|
||||
// strip hides until the next idle window re-offers it.
|
||||
setAdvanceDismissed(true);
|
||||
}, [onConfirmAdvance]);
|
||||
|
||||
const elevated = Boolean(posture?.elevated);
|
||||
const flagSummary = posture?.elevatedFlags?.join(", ");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`cli-session-terminal${isMobile ? " cli-session-terminal--mobile" : ""}${
|
||||
isMobile && keyboardOpen ? " cli-session-terminal--keyboard-open" : ""
|
||||
}`}
|
||||
data-mode={mode}
|
||||
data-read-only={readOnly}
|
||||
data-mobile={isMobile}
|
||||
data-keyboard-open={isMobile && keyboardOpen}
|
||||
>
|
||||
<header className="cli-session-terminal__header">
|
||||
{posture && (
|
||||
<div className="cli-session-terminal__posture-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className={`cli-posture-chip${elevated ? " cli-posture-chip--elevated" : ""}`}
|
||||
data-elevated={elevated}
|
||||
aria-expanded={postureTooltipOpen}
|
||||
onClick={() => setPostureTooltipOpen((v) => !v)}
|
||||
>
|
||||
{elevated ? (
|
||||
<ShieldAlert size={13} aria-hidden="true" />
|
||||
) : (
|
||||
<TerminalIcon size={13} aria-hidden="true" />
|
||||
)}
|
||||
<span className="cli-posture-chip__name">{posture.adapterName}</span>
|
||||
{posture.mode && (
|
||||
<span className="cli-posture-chip__mode">{posture.mode}</span>
|
||||
)}
|
||||
{elevated && flagSummary && (
|
||||
<span className="cli-posture-chip__flag">{flagSummary}</span>
|
||||
)}
|
||||
</button>
|
||||
{postureTooltipOpen && (
|
||||
<div className="cli-posture-tooltip" role="tooltip">
|
||||
<p className="cli-posture-tooltip__title">
|
||||
{t("cliTerminal.postureResolved", "Resolved posture")}
|
||||
</p>
|
||||
<ul className="cli-posture-tooltip__list">
|
||||
{(posture.resolved ?? []).map((line, i) => (
|
||||
<li key={i}>{line}</li>
|
||||
))}
|
||||
{(posture.resolved ?? []).length === 0 && (
|
||||
<li>{posture.mode ?? t("cliTerminal.postureBaseline", "Baseline")}</li>
|
||||
)}
|
||||
</ul>
|
||||
{onOpenAdapterSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className="cli-posture-tooltip__settings"
|
||||
onClick={() => {
|
||||
setPostureTooltipOpen(false);
|
||||
onOpenAdapterSettings();
|
||||
}}
|
||||
>
|
||||
<Settings size={12} aria-hidden="true" />
|
||||
{t("cliTerminal.adapterSettings", "Adapter settings")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{readOnly && (
|
||||
<span className="cli-session-terminal__readonly-badge">
|
||||
<Eye size={12} aria-hidden="true" />
|
||||
{t("cliTerminal.readOnly", "Read-only")}
|
||||
</span>
|
||||
)}
|
||||
{replayLabel && (
|
||||
<span className="cli-session-terminal__replay-badge" data-replay-mode={mode}>
|
||||
{replayLabel}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="cli-session-terminal__viewport"
|
||||
ref={containerRef}
|
||||
data-testid="cli-terminal-viewport"
|
||||
/>
|
||||
|
||||
{showConfirmAdvance && !advanceDismissed && (
|
||||
<div className="cli-session-terminal__advance-strip" role="region">
|
||||
<span className="cli-session-terminal__advance-copy">
|
||||
{t(
|
||||
"cliTerminal.advancePrompt",
|
||||
"This session looks idle — advance to review?",
|
||||
)}
|
||||
</span>
|
||||
<div className="cli-session-terminal__advance-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="cli-session-terminal__advance-btn"
|
||||
disabled={advancePending}
|
||||
onClick={handleAdvance}
|
||||
>
|
||||
{t("cliTerminal.advance", "Advance")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cli-session-terminal__advance-btn cli-session-terminal__advance-btn--secondary"
|
||||
disabled={advancePending}
|
||||
onClick={handleNotYet}
|
||||
>
|
||||
{t("cliTerminal.notYet", "Not yet")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMobile && !readOnly && (
|
||||
<div
|
||||
className={`cli-session-terminal__mobile-bar${
|
||||
keyboardOpen ? " cli-session-terminal__mobile-bar--keyboard-open" : ""
|
||||
}`}
|
||||
data-testid="cli-terminal-mobile-bar"
|
||||
style={
|
||||
// Lift the fixed footer above the virtual keyboard when it's open.
|
||||
keyboardOpen ? { bottom: `${keyboardOverlap}px` } : undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="cli-session-terminal__key-row"
|
||||
data-testid="cli-terminal-key-bar"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`cli-terminal-key cli-terminal-key--ctrl${
|
||||
ctrlSticky ? " cli-terminal-key--active" : ""
|
||||
}`}
|
||||
data-testid="cli-key-ctrl"
|
||||
aria-label={t("cliTerminal.mobileKeyCtrl", "Sticky Ctrl modifier")}
|
||||
aria-pressed={ctrlSticky}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => setCtrlSticky((v) => !v)}
|
||||
>
|
||||
Ctrl
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cli-terminal-key"
|
||||
data-testid="cli-key-esc"
|
||||
aria-label={t("cliTerminal.mobileKeyEsc", "Send Escape")}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => emitBarKey(SEQ_ESC)}
|
||||
>
|
||||
Esc
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cli-terminal-key"
|
||||
data-testid="cli-key-tab"
|
||||
aria-label={t("cliTerminal.mobileKeyTab", "Send Tab")}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => emitBarKey(SEQ_TAB)}
|
||||
>
|
||||
Tab
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cli-terminal-key cli-terminal-key--ctrlc"
|
||||
data-testid="cli-key-ctrl-c"
|
||||
aria-label={t("cliTerminal.mobileKeyCtrlC", "Send Ctrl-C")}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => {
|
||||
// Dedicated shortcut: always Ctrl-C, regardless of sticky state.
|
||||
setCtrlSticky(false);
|
||||
sendInput(SEQ_CTRL_C);
|
||||
}}
|
||||
>
|
||||
^C
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cli-terminal-key"
|
||||
data-testid="cli-key-arrow-up"
|
||||
aria-label={t("cliTerminal.mobileKeyArrowUp", "Cursor up")}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => emitBarKey(SEQ_ARROW_UP)}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cli-terminal-key"
|
||||
data-testid="cli-key-arrow-down"
|
||||
aria-label={t("cliTerminal.mobileKeyArrowDown", "Cursor down")}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => emitBarKey(SEQ_ARROW_DOWN)}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cli-terminal-key"
|
||||
data-testid="cli-key-arrow-left"
|
||||
aria-label={t("cliTerminal.mobileKeyArrowLeft", "Cursor left")}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => emitBarKey(SEQ_ARROW_LEFT)}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cli-terminal-key"
|
||||
data-testid="cli-key-arrow-right"
|
||||
aria-label={t("cliTerminal.mobileKeyArrowRight", "Cursor right")}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => emitBarKey(SEQ_ARROW_RIGHT)}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
className="cli-session-terminal__input-row"
|
||||
onSubmit={handleMobileSubmit}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="cli-session-terminal__mobile-input"
|
||||
data-testid="cli-terminal-mobile-input"
|
||||
value={mobileInput}
|
||||
placeholder={t(
|
||||
"cliTerminal.mobileInputPlaceholder",
|
||||
"Type to send to the session…",
|
||||
)}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(e) => handleMobileInputChange(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="cli-session-terminal__mobile-send"
|
||||
data-testid="cli-terminal-mobile-send"
|
||||
aria-label={t("cliTerminal.mobileSend", "Send")}
|
||||
// iOS pattern: act on click, preventDefault on pointer/mouse down
|
||||
// so the input doesn't blur (which dismisses the keyboard).
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => handleMobileSubmit()}
|
||||
>
|
||||
{t("cliTerminal.mobileSend", "Send")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -223,6 +223,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
{ id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global" },
|
||||
{ id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global" },
|
||||
{ id: "global-models", label: "Models", labelKey: "settings.nav.globalModels", scope: "global" },
|
||||
{ id: "cli-agents", label: "CLI Agents", labelKey: "settings.nav.cliAgents", scope: "global" },
|
||||
{ id: "research-global", label: "Research Defaults", labelKey: "settings.nav.researchGlobal", scope: "global" },
|
||||
{ id: "remote", label: "Remote Access & Node Sync", labelKey: "settings.nav.remote", scope: "global" },
|
||||
{ id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global" },
|
||||
@@ -353,6 +354,233 @@ interface SettingsModalProps {
|
||||
onOpenWorkflowSettings?: () => void;
|
||||
}
|
||||
|
||||
/** Adapter descriptor served by GET /api/cli-agents (U15). */
|
||||
interface CliAdapterDescriptorView {
|
||||
id: string;
|
||||
name: string;
|
||||
tier: "native" | "hybrid" | "generic";
|
||||
defaultCommand: string | null;
|
||||
}
|
||||
|
||||
interface CliAgentSettingsEntry {
|
||||
commandOverride?: string;
|
||||
extraArgs?: string[];
|
||||
autonomyMode?: "default" | "elevated";
|
||||
envAdditions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-adapter CLI-agent launch settings section (U15). Reads the adapter catalog
|
||||
* + persisted settings + per-project autonomy approval state, and lets the
|
||||
* operator edit command override / extra args / env additions / autonomy mode.
|
||||
* Switching an adapter to elevated autonomy goes through an explicit
|
||||
* confirmation flow before the per-project approval is granted.
|
||||
*/
|
||||
function CliAgentsSettingsSection({
|
||||
projectId: _projectId,
|
||||
addToast,
|
||||
}: {
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
const [adapters, setAdapters] = useState<CliAdapterDescriptorView[]>([]);
|
||||
const [settings, setSettings] = useState<Record<string, CliAgentSettingsEntry>>({});
|
||||
const [approved, setApproved] = useState<Record<string, boolean>>({});
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [catRes, setRes] = await Promise.all([
|
||||
fetch("/api/cli-agents"),
|
||||
fetch("/api/cli-agents/settings"),
|
||||
]);
|
||||
const cat = catRes.ok ? await catRes.json() : { adapters: [] };
|
||||
const set = setRes.ok ? await setRes.json() : { cliAgents: {} };
|
||||
if (cancelled) return;
|
||||
const list = (cat.adapters ?? []) as CliAdapterDescriptorView[];
|
||||
setAdapters(list);
|
||||
setSettings((set.cliAgents ?? {}) as Record<string, CliAgentSettingsEntry>);
|
||||
if (list.length > 0) setSelectedId((prev) => prev || list[0].id);
|
||||
// Approval state is per-adapter; fetch lazily per selection below.
|
||||
} catch {
|
||||
// Non-fatal: render the static fallback list.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
let cancelled = false;
|
||||
fetch(`/api/cli-agents/${selectedId}/autonomy`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
setApproved((prev) => ({ ...prev, [selectedId]: Boolean(data.approved) }));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedId]);
|
||||
|
||||
const current = settings[selectedId] ?? {};
|
||||
|
||||
const persist = useCallback(
|
||||
async (adapterId: string, config: CliAgentSettingsEntry) => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-agents/settings", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ adapterId, config }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setSettings((data.cliAgents ?? {}) as Record<string, CliAgentSettingsEntry>);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("settings.cliAgents.saveFailed"), "error");
|
||||
}
|
||||
},
|
||||
[addToast, t],
|
||||
);
|
||||
|
||||
const updateCurrent = useCallback(
|
||||
(patch: Partial<CliAgentSettingsEntry>) => {
|
||||
if (!selectedId) return;
|
||||
const next = { ...current, ...patch };
|
||||
setSettings((prev) => ({ ...prev, [selectedId]: next }));
|
||||
void persist(selectedId, next);
|
||||
},
|
||||
[selectedId, current, persist],
|
||||
);
|
||||
|
||||
const onAutonomyChange = useCallback(
|
||||
async (mode: "default" | "elevated") => {
|
||||
if (!selectedId) return;
|
||||
if (mode === "elevated") {
|
||||
const ok = await confirm({
|
||||
title: t("settings.cliAgents.elevatedConfirmTitle"),
|
||||
message: t("settings.cliAgents.elevatedConfirmBody"),
|
||||
confirmLabel: t("settings.cliAgents.elevatedConfirmAction"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
// Record the per-project approval first, then persist the mode.
|
||||
try {
|
||||
const res = await fetch(`/api/cli-agents/${selectedId}/approve-autonomy`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setApproved((prev) => ({ ...prev, [selectedId]: true }));
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("settings.cliAgents.approveFailed"), "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
updateCurrent({ autonomyMode: mode });
|
||||
},
|
||||
[selectedId, confirm, t, addToast, updateCurrent],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="cli-agents-settings">
|
||||
<h4 className="settings-section-heading">{t("settings.cliAgents.heading")}</h4>
|
||||
<p className="settings-section-description">{t("settings.cliAgents.description")}</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="cliAgentAdapter">{t("settings.cliAgents.adapterLabel")}</label>
|
||||
<select
|
||||
id="cliAgentAdapter"
|
||||
value={selectedId}
|
||||
onChange={(e) => setSelectedId(e.target.value)}
|
||||
>
|
||||
{adapters.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name} ({t(`settings.cliAgents.tier.${a.tier}`)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedId && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="cliAgentCommand">{t("settings.cliAgents.commandLabel")}</label>
|
||||
<input
|
||||
id="cliAgentCommand"
|
||||
type="text"
|
||||
placeholder={
|
||||
adapters.find((a) => a.id === selectedId)?.defaultCommand ?? ""
|
||||
}
|
||||
value={current.commandOverride ?? ""}
|
||||
onChange={(e) => updateCurrent({ commandOverride: e.target.value || undefined })}
|
||||
/>
|
||||
<p className="settings-field-help">{t("settings.cliAgents.commandHelp")}</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="cliAgentExtraArgs">{t("settings.cliAgents.extraArgsLabel")}</label>
|
||||
<input
|
||||
id="cliAgentExtraArgs"
|
||||
type="text"
|
||||
value={(current.extraArgs ?? []).join(" ")}
|
||||
onChange={(e) =>
|
||||
updateCurrent({
|
||||
extraArgs: e.target.value.split(/\s+/).filter((s) => s.length > 0),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="settings-field-help">{t("settings.cliAgents.extraArgsHelp")}</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="cliAgentEnv">{t("settings.cliAgents.envLabel")}</label>
|
||||
<input
|
||||
id="cliAgentEnv"
|
||||
type="text"
|
||||
value={(current.envAdditions ?? []).join(", ")}
|
||||
onChange={(e) =>
|
||||
updateCurrent({
|
||||
envAdditions: e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="settings-field-help">{t("settings.cliAgents.envHelp")}</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="cliAgentAutonomy">{t("settings.cliAgents.autonomyLabel")}</label>
|
||||
<select
|
||||
id="cliAgentAutonomy"
|
||||
value={current.autonomyMode ?? "default"}
|
||||
onChange={(e) => void onAutonomyChange(e.target.value as "default" | "elevated")}
|
||||
>
|
||||
<option value="default">{t("settings.cliAgents.autonomy.default")}</option>
|
||||
<option value="elevated">{t("settings.cliAgents.autonomy.elevated")}</option>
|
||||
</select>
|
||||
<p className="settings-field-help">
|
||||
{approved[selectedId]
|
||||
? t("settings.cliAgents.approvedNote")
|
||||
: t("settings.cliAgents.autonomyHelp")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
onClose,
|
||||
addToast,
|
||||
@@ -2195,6 +2423,13 @@ export function SettingsModal({
|
||||
|
||||
const renderSectionFields = () => {
|
||||
switch (activeSection) {
|
||||
case "cli-agents":
|
||||
return (
|
||||
<>
|
||||
{renderScopeBanner()}
|
||||
<CliAgentsSettingsSection projectId={projectId} addToast={addToast} />
|
||||
</>
|
||||
);
|
||||
case "general":
|
||||
return (
|
||||
<GeneralSection
|
||||
|
||||
@@ -407,6 +407,25 @@ interface TaskCardProps {
|
||||
/** Card-placed custom field definitions for this task's workflow (U13/KTD-14).
|
||||
* Empty/undefined → no field badges render (card byte-identical to today). */
|
||||
cardFieldDefs?: WorkflowFieldDefinition[];
|
||||
/**
|
||||
* CLI agent session state for this task's session (CLI Agent Executor, U11).
|
||||
* Drives the waiting-on-input / needs-attention card badges, which are
|
||||
* DISTINCT from staleness/stall badges (which U8 suppresses in these states).
|
||||
* Undefined when the task has no CLI session → no badge (card unchanged).
|
||||
*/
|
||||
cliSessionState?: CliCardState;
|
||||
}
|
||||
|
||||
/** Minimal CLI session shape the card needs for its badges (U11). */
|
||||
export interface CliCardState {
|
||||
agentState:
|
||||
| "starting"
|
||||
| "ready"
|
||||
| "busy"
|
||||
| "waitingOnInput"
|
||||
| "done"
|
||||
| "dead"
|
||||
| "needsAttention";
|
||||
}
|
||||
|
||||
function getTaskPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
|
||||
@@ -540,6 +559,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs &&
|
||||
previous.prAuthAvailable === next.prAuthAvailable &&
|
||||
previous.autoMergeEnabled === next.autoMergeEnabled &&
|
||||
previous.cliSessionState?.agentState === next.cliSessionState?.agentState &&
|
||||
previous.cardFieldDefs === next.cardFieldDefs &&
|
||||
(previous.cardFieldDefs == null && next.cardFieldDefs == null
|
||||
? true
|
||||
@@ -658,6 +678,7 @@ function TaskCardComponent({
|
||||
prAuthAvailable,
|
||||
autoMergeEnabled = false,
|
||||
cardFieldDefs,
|
||||
cliSessionState,
|
||||
}: TaskCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const columnLabel = useColumnLabel();
|
||||
@@ -924,6 +945,9 @@ function TaskCardComponent({
|
||||
const stalledReview = getStalledReviewSignal(task);
|
||||
const showStalledReview = Boolean(stalledReview && task.column === "in-review" && !isPaused);
|
||||
const hasInReviewStall = shouldShowInReviewStallBadge(task);
|
||||
// CLI agent session badges (U11) — distinct from staleness/stall badges.
|
||||
const cliWaitingOnInput = cliSessionState?.agentState === "waitingOnInput";
|
||||
const cliNeedsAttention = cliSessionState?.agentState === "needsAttention";
|
||||
const stallCopy = task.inReviewStall
|
||||
? getInReviewStallCopy(task.inReviewStall, {
|
||||
mergeRetries: task.mergeRetries,
|
||||
@@ -1811,6 +1835,24 @@ function TaskCardComponent({
|
||||
{stallCopy.badgeLabel}{stallCopy.counter ? ` ${stallCopy.counter}` : ""}
|
||||
</span>
|
||||
)}
|
||||
{cliWaitingOnInput && (
|
||||
<span
|
||||
className="card-status-badge card-status-badge--cli-waiting"
|
||||
data-cli-state="waitingOnInput"
|
||||
title={t("tasks.cliWaitingOnInputTitle", "The CLI agent is waiting for your input")}
|
||||
>
|
||||
{t("tasks.cliWaitingOnInput", "Waiting on input")}
|
||||
</span>
|
||||
)}
|
||||
{cliNeedsAttention && (
|
||||
<span
|
||||
className="card-status-badge card-status-badge--cli-attention failed"
|
||||
data-cli-state="needsAttention"
|
||||
title={t("tasks.cliNeedsAttentionTitle", "The CLI agent needs your attention")}
|
||||
>
|
||||
{t("tasks.cliNeedsAttention", "Needs attention")}
|
||||
</span>
|
||||
)}
|
||||
{hasStalePausedReview && stalePausedReviewCopy && (
|
||||
<span
|
||||
className={`card-status-badge card-status-badge--in-review stale-paused-review stale-paused-review--${stalePausedReviewCopy.code}`}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
resolveTaskPlanningModel,
|
||||
resolveTaskValidatorModel,
|
||||
} from "@fusion/core";
|
||||
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields } from "../api";
|
||||
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, api } from "../api";
|
||||
import type { RecoverBranchBindingOutcome, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
|
||||
import { ApiRequestError } from "../api";
|
||||
import { TaskFieldsSection } from "./TaskFieldsSection";
|
||||
@@ -46,6 +46,7 @@ import { BranchGroupCard } from "./BranchGroupCard";
|
||||
import { PluginSlot } from "./PluginSlot";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import type { SessionTerminalMode, SessionTerminalPosture } from "./SessionTerminal";
|
||||
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
||||
import { appendTokenQuery } from "../auth";
|
||||
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
|
||||
@@ -281,7 +282,69 @@ function formatDurationCompact(ageMs: number): string {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
type TabId = "definition" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | `plugin-${string}`;
|
||||
type TabId = "definition" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | `plugin-${string}`;
|
||||
|
||||
// Lazy-load the terminal so xterm + addons stay out of the main bundle (U11).
|
||||
const LazySessionTerminal = lazy(() =>
|
||||
import("./SessionTerminal").then((m) => ({ default: m.SessionTerminal })),
|
||||
);
|
||||
|
||||
/** CLI session record fields the terminal tab needs (mirrors @fusion/core CliSession). */
|
||||
export interface CliSessionSummaryRecord {
|
||||
id: string;
|
||||
taskId: string | null;
|
||||
projectId: string;
|
||||
adapterId: string;
|
||||
agentState:
|
||||
| "starting"
|
||||
| "ready"
|
||||
| "busy"
|
||||
| "waitingOnInput"
|
||||
| "done"
|
||||
| "dead"
|
||||
| "needsAttention";
|
||||
terminationReason: string | null;
|
||||
autonomyPosture?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
type CliTabVisibility =
|
||||
| { kind: "hidden" }
|
||||
| { kind: "live"; readOnly: boolean; mode: SessionTerminalMode; showConfirmAdvance: boolean }
|
||||
| { kind: "replay"; mode: SessionTerminalMode };
|
||||
|
||||
/**
|
||||
* Tab visibility matrix (U11):
|
||||
* - starting/ready/busy/waitingOnInput → live terminal
|
||||
* - one-shot (planning/validator) live → read-only live + badge
|
||||
* - done (resumable) → replay "session idle"
|
||||
* - dead/needsAttention (PTY reaped) → replay "session ended"
|
||||
* - no recorded session → hidden
|
||||
*/
|
||||
export function deriveCliTabVisibility(
|
||||
session: CliSessionSummaryRecord | null,
|
||||
opts: { oneShot?: boolean; genericIdle?: boolean } = {},
|
||||
): CliTabVisibility {
|
||||
if (!session) return { kind: "hidden" };
|
||||
const live =
|
||||
session.agentState === "starting" ||
|
||||
session.agentState === "ready" ||
|
||||
session.agentState === "busy" ||
|
||||
session.agentState === "waitingOnInput";
|
||||
if (live) {
|
||||
return {
|
||||
kind: "live",
|
||||
readOnly: Boolean(opts.oneShot),
|
||||
mode: "live",
|
||||
showConfirmAdvance: Boolean(opts.genericIdle),
|
||||
};
|
||||
}
|
||||
if (session.agentState === "done") {
|
||||
// execute-done but resumable → scrollback replay with a "session idle" header.
|
||||
return { kind: "replay", mode: "idle" };
|
||||
}
|
||||
// dead / needsAttention → PTY reaped → "session ended".
|
||||
return { kind: "replay", mode: "ended" };
|
||||
}
|
||||
|
||||
export interface TaskDetailModalProps {
|
||||
task: Task | TaskDetail;
|
||||
@@ -494,6 +557,9 @@ export function TaskDetailContent({
|
||||
const columnLabel = useColumnLabel();
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab === "retries" ? "definition" : initialTab);
|
||||
|
||||
// ── CLI agent session (U11) ────────────────────────────────────────────────
|
||||
const [cliSession, setCliSession] = useState<CliSessionSummaryRecord | null>(null);
|
||||
|
||||
// ── Async detail loading ──────────────────────────────────────────────────
|
||||
// When opened optimistically with a Task (no prompt), fetch the full
|
||||
// TaskDetail in the background. The modal renders immediately with the
|
||||
@@ -757,6 +823,56 @@ export function TaskDetailContent({
|
||||
? pluginTabs.find((tab) => tab.tabId === activeTab) ?? null
|
||||
: null;
|
||||
|
||||
// ── CLI terminal tab visibility + posture (U11) ────────────────────────────
|
||||
const cliOneShot =
|
||||
cliSession?.adapterId != null &&
|
||||
(cliSession?.autonomyPosture?.purpose === "planning" ||
|
||||
cliSession?.autonomyPosture?.purpose === "validator" ||
|
||||
cliSession?.autonomyPosture?.readOnly === true);
|
||||
const cliGenericIdle = cliSession?.autonomyPosture?.genericIdle === true;
|
||||
const cliTabVisibility = useMemo(
|
||||
() =>
|
||||
deriveCliTabVisibility(cliSession, {
|
||||
oneShot: cliOneShot,
|
||||
genericIdle: cliGenericIdle,
|
||||
}),
|
||||
[cliSession, cliOneShot, cliGenericIdle],
|
||||
);
|
||||
const showCliTab = cliTabVisibility.kind !== "hidden";
|
||||
const cliPosture: SessionTerminalPosture | undefined = useMemo(() => {
|
||||
if (!cliSession) return undefined;
|
||||
const p = cliSession.autonomyPosture ?? {};
|
||||
const flags = Array.isArray(p.elevatedFlags) ? (p.elevatedFlags as string[]) : undefined;
|
||||
return {
|
||||
adapterName: (p.adapterName as string) ?? cliSession.adapterId,
|
||||
mode: (p.mode as string) ?? (p.autoApprove ? "auto-approve" : undefined),
|
||||
elevated: p.elevated === true,
|
||||
elevatedFlags: flags,
|
||||
resolved: Array.isArray(p.resolved) ? (p.resolved as string[]) : undefined,
|
||||
};
|
||||
}, [cliSession]);
|
||||
|
||||
// Confirm-advance handler — POST /api/cli-sessions/:id/confirm-advance.
|
||||
const handleConfirmAdvance = useCallback(
|
||||
async (decision: "advance" | "not-yet") => {
|
||||
if (!cliSession) return;
|
||||
try {
|
||||
await api(`/cli-sessions/${encodeURIComponent(cliSession.id)}/confirm-advance`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ decision, ...(projectId ? { projectId } : {}) }),
|
||||
});
|
||||
} catch {
|
||||
/* surfaced via the strip's disabled state reset */
|
||||
}
|
||||
},
|
||||
[cliSession, projectId],
|
||||
);
|
||||
|
||||
// If the terminal tab is active but the session disappears, fall back.
|
||||
useEffect(() => {
|
||||
if (activeTab === "terminal" && !showCliTab) setActiveTab("definition");
|
||||
}, [activeTab, showCliTab]);
|
||||
|
||||
// Track mount state to avoid setting state on unmounted component
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@@ -886,6 +1002,76 @@ export function TaskDetailContent({
|
||||
});
|
||||
}, [activeTab, task.id, projectId]);
|
||||
|
||||
// Load the CLI agent session for this task (drives the terminal tab + matrix).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const search = new URLSearchParams({ taskId: task.id });
|
||||
if (projectId) search.set("projectId", projectId);
|
||||
void api<{ sessions: CliSessionSummaryRecord[] }>(`/cli-sessions?${search.toString()}`)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
// Most-recent session for the task (the list is store-ordered).
|
||||
const sessions = res.sessions ?? [];
|
||||
setCliSession(sessions.length > 0 ? sessions[sessions.length - 1] : null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCliSession(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [task.id, projectId]);
|
||||
|
||||
// Live CLI session state via SSE — MERGE payload fields onto the record
|
||||
// (never wholesale-replace: the list fetch carries enriched fields the SSE
|
||||
// payload omits, e.g. adapterId / autonomyPosture).
|
||||
useEffect(() => {
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const handleCliState = (e: MessageEvent) => {
|
||||
try {
|
||||
const payload = JSON.parse(e.data) as {
|
||||
sessionId: string;
|
||||
taskId: string | null;
|
||||
state: string;
|
||||
terminationReason?: string | null;
|
||||
};
|
||||
if (payload.taskId !== task.id) return;
|
||||
setCliSession((prev) => {
|
||||
if (!prev || prev.id !== payload.sessionId) {
|
||||
// Unknown/new session for this task — keep the enriched record from
|
||||
// the list fetch as the source of truth; ignore until it loads.
|
||||
if (!prev) return prev;
|
||||
}
|
||||
// The machine "idle"/"resuming" states map onto persisted enums; the
|
||||
// card/tab only need the persisted set, so coerce here.
|
||||
const next = { ...prev } as CliSessionSummaryRecord;
|
||||
if (
|
||||
payload.state === "starting" ||
|
||||
payload.state === "ready" ||
|
||||
payload.state === "busy" ||
|
||||
payload.state === "waitingOnInput" ||
|
||||
payload.state === "done" ||
|
||||
payload.state === "dead" ||
|
||||
payload.state === "needsAttention"
|
||||
) {
|
||||
next.agentState = payload.state;
|
||||
} else if (payload.state === "idle" || payload.state === "resuming") {
|
||||
next.agentState = "busy";
|
||||
}
|
||||
if (payload.terminationReason !== undefined) {
|
||||
next.terminationReason = payload.terminationReason ?? null;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
/* skip malformed events */
|
||||
}
|
||||
};
|
||||
return subscribeSse(`/api/events${query}`, {
|
||||
events: { "cli:session:state": handleCliState },
|
||||
});
|
||||
}, [task.id, projectId]);
|
||||
|
||||
// Reset dependency search when dropdown closes
|
||||
useEffect(() => {
|
||||
if (!showDepDropdown) {
|
||||
@@ -2825,6 +3011,14 @@ export function TaskDetailContent({
|
||||
>
|
||||
{t("taskDetail.tabs.routing", "Routing")}
|
||||
</button>
|
||||
{showCliTab && (
|
||||
<button
|
||||
className={`detail-tab${activeTab === "terminal" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("terminal")}
|
||||
>
|
||||
{t("taskDetail.tabs.terminal", "Terminal")}
|
||||
</button>
|
||||
)}
|
||||
{/* Plugin tabs */}
|
||||
{pluginTabs.map(({ entry, tabId }) => {
|
||||
return (
|
||||
@@ -3116,6 +3310,27 @@ export function TaskDetailContent({
|
||||
onTaskUpdated={onTaskUpdated}
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === "terminal" ? (
|
||||
<div className="detail-section detail-section--terminal">
|
||||
{cliSession && cliTabVisibility.kind !== "hidden" ? (
|
||||
<Suspense fallback={<div className="detail-loading">{t("taskDetail.terminal.loading", "Loading terminal…")}</div>}>
|
||||
<LazySessionTerminal
|
||||
sessionId={cliSession.id}
|
||||
projectId={projectId}
|
||||
posture={cliPosture}
|
||||
readOnly={
|
||||
cliTabVisibility.kind === "replay" ||
|
||||
(cliTabVisibility.kind === "live" && cliTabVisibility.readOnly)
|
||||
}
|
||||
mode={cliTabVisibility.mode}
|
||||
showConfirmAdvance={
|
||||
cliTabVisibility.kind === "live" && cliTabVisibility.showConfirmAdvance
|
||||
}
|
||||
onConfirmAdvance={handleConfirmAdvance}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary section - only for done tasks with summary */}
|
||||
|
||||
@@ -81,7 +81,23 @@ import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel";
|
||||
import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
|
||||
type ExecutorKind = "model" | "agent" | "skill" | "cli";
|
||||
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
|
||||
|
||||
/** Adapter descriptor served by GET /api/cli-agents (U15). */
|
||||
interface CliAdapterDescriptorView {
|
||||
id: string;
|
||||
name: string;
|
||||
tier: "native" | "hybrid" | "generic";
|
||||
}
|
||||
|
||||
/** Static fallback so the picker renders before/without the API fetch. */
|
||||
const CLI_AGENT_ADAPTER_FALLBACK: CliAdapterDescriptorView[] = [
|
||||
{ id: "claude-code", name: "Claude Code", tier: "native" },
|
||||
{ id: "codex", name: "Codex", tier: "hybrid" },
|
||||
{ id: "droid", name: "Droid", tier: "hybrid" },
|
||||
{ id: "pi", name: "Pi", tier: "hybrid" },
|
||||
{ id: "generic", name: "Generic CLI", tier: "generic" },
|
||||
];
|
||||
|
||||
// Mirror of @fusion/core's isBuiltinWorkflowId / BUILTIN_WORKFLOW_ID_PREFIX.
|
||||
// Inlined because the dashboard app build aliases "@fusion/core" to its
|
||||
@@ -1793,6 +1809,9 @@ function InnerEditor({
|
||||
setAgents([]);
|
||||
}, [projectId]);
|
||||
const [skills, setSkills] = useState<DiscoveredSkill[]>([]);
|
||||
// CLI-agent adapter catalog (U15). Falls back to the static list when the API
|
||||
// fetch fails so the picker is always usable.
|
||||
const [cliAdapters, setCliAdapters] = useState<CliAdapterDescriptorView[]>(CLI_AGENT_ADAPTER_FALLBACK);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -1859,6 +1878,23 @@ function InnerEditor({
|
||||
[overrideColumnBinding, agents],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentExecutor !== "cli-agent") return;
|
||||
let cancelled = false;
|
||||
fetch("/api/cli-agents")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (cancelled || !data?.adapters) return;
|
||||
setCliAdapters(data.adapters as CliAdapterDescriptorView[]);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the static fallback; the picker stays functional.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentExecutor]);
|
||||
|
||||
useEffect(() => {
|
||||
// step-review offers an optional review model picker (KTD-4).
|
||||
if (selectedNode?.data.kind === "step-review" && models.length === 0) {
|
||||
@@ -2654,6 +2690,7 @@ function InnerEditor({
|
||||
<option value="agent">Agent</option>
|
||||
<option value="skill">Skill</option>
|
||||
<option value="cli">CLI / script</option>
|
||||
<option value="cli-agent">{t("workflowEditor.cliAgent.executorOption")}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -2781,6 +2818,83 @@ function InnerEditor({
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentExecutor === "cli-agent" && (
|
||||
<div data-testid="cli-agent-config">
|
||||
<label className="wf-field">
|
||||
<span>{t("workflowEditor.cliAgent.adapterLabel")}</span>
|
||||
<select
|
||||
data-testid="cli-agent-adapter"
|
||||
value={String(selectedNode.data.config?.cliAdapterId ?? "")}
|
||||
onChange={(e) =>
|
||||
updateSelectedData({ config: { cliAdapterId: e.target.value || undefined } })
|
||||
}
|
||||
>
|
||||
<option value="">{t("workflowEditor.cliAgent.adapterPlaceholder")}</option>
|
||||
{cliAdapters.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name} ({t(`workflowEditor.cliAgent.tier.${a.tier}`)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="wf-inspector-note">
|
||||
{t("workflowEditor.cliAgent.adapterNote")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="wf-field wf-field--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="cli-agent-autonomy"
|
||||
checked={Boolean(
|
||||
(selectedNode.data.config?.cliAutonomy as { autoApprove?: boolean } | undefined)
|
||||
?.autoApprove,
|
||||
)}
|
||||
onChange={(e) =>
|
||||
updateSelectedData({
|
||||
config: {
|
||||
cliAutonomy: {
|
||||
...((selectedNode.data.config?.cliAutonomy as Record<string, unknown>) ?? {}),
|
||||
autoApprove: e.target.checked,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{t("workflowEditor.cliAgent.autonomyLabel")}</span>
|
||||
</label>
|
||||
{Boolean(
|
||||
(selectedNode.data.config?.cliAutonomy as { autoApprove?: boolean } | undefined)
|
||||
?.autoApprove,
|
||||
) && (
|
||||
<p className="wf-inspector-note wf-inspector-note--info">
|
||||
{t("workflowEditor.cliAgent.autonomyNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label className="wf-field">
|
||||
<span>{t("workflowEditor.cliAgent.notifyLabel")}</span>
|
||||
<select
|
||||
data-testid="cli-agent-notify"
|
||||
value={String(
|
||||
(selectedNode.data.config?.cliNotify as { mode?: string } | undefined)?.mode ??
|
||||
"banner",
|
||||
)}
|
||||
onChange={(e) =>
|
||||
updateSelectedData({ config: { cliNotify: { mode: e.target.value } } })
|
||||
}
|
||||
>
|
||||
<option value="banner">{t("workflowEditor.cliAgent.notify.banner")}</option>
|
||||
<option value="banner+notify">
|
||||
{t("workflowEditor.cliAgent.notify.bannerNotify")}
|
||||
</option>
|
||||
</select>
|
||||
<span className="wf-inspector-note">
|
||||
{t("workflowEditor.cliAgent.notifyNote")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="wf-field wf-field--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// ChatView CLI-backed mount test (CLI Agent Executor, U12 completion).
|
||||
//
|
||||
// Asserts ChatView delegates the message-pane + composer region to
|
||||
// <CliChatSurface> when the active chat session carries a `cliExecutorAdapterId`,
|
||||
// and falls back to the normal provider composer for a regular session.
|
||||
//
|
||||
// SessionTerminal is mocked (no xterm / no WS / no PTY / no port 4040) because
|
||||
// CliChatSurface renders it under the hood.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { ChatView } from "../ChatView";
|
||||
import * as useChatModule from "../../hooks/useChat";
|
||||
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
||||
import type { ChatSessionInfo, UseChatReturn } from "../../hooks/useChat";
|
||||
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
||||
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
||||
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
vi.mock("../SessionTerminal", () => ({
|
||||
SessionTerminal: ({ sessionId }: { sessionId: string }) => (
|
||||
<div data-testid="session-terminal" data-session-id={sessionId}>
|
||||
terminal
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useChat");
|
||||
vi.mock("../../hooks/useChatRooms");
|
||||
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../api")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
|
||||
fetchTasks: vi.fn().mockResolvedValue([]),
|
||||
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
const mockUseChat = vi.mocked(useChatModule.useChat);
|
||||
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
|
||||
|
||||
function makeSession(overrides: Partial<ChatSessionInfo> = {}): ChatSessionInfo {
|
||||
return {
|
||||
id: "sess-1",
|
||||
agentId: "agent-1",
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function chatState(session: ChatSessionInfo): UseChatReturn {
|
||||
return {
|
||||
sessions: [session],
|
||||
activeSession: session,
|
||||
sessionsLoading: false,
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
isStreaming: false,
|
||||
streamingText: "",
|
||||
streamingThinking: "",
|
||||
streamingToolCalls: [],
|
||||
selectSession: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
archiveSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
stopStreaming: vi.fn(),
|
||||
pendingMessage: "",
|
||||
clearPendingMessage: vi.fn(),
|
||||
loadMoreMessages: vi.fn(),
|
||||
hasMoreMessages: false,
|
||||
searchQuery: "",
|
||||
setSearchQuery: vi.fn(),
|
||||
filteredSessions: [session],
|
||||
refreshSessions: vi.fn(),
|
||||
agentsMap: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
const defaultRoomsState: UseChatRoomsResult = {
|
||||
rooms: [],
|
||||
roomsLoading: false,
|
||||
roomsError: null,
|
||||
activeRoom: null,
|
||||
activeRoomMembers: [],
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
selectRoom: vi.fn(),
|
||||
createRoom: vi.fn(),
|
||||
deleteRoom: vi.fn(),
|
||||
sendRoomMessage: vi.fn().mockResolvedValue(undefined),
|
||||
refreshRooms: vi.fn(),
|
||||
};
|
||||
|
||||
describe("ChatView CLI-backed session mount", () => {
|
||||
beforeEach(() => {
|
||||
_resetInitialViewportHeight();
|
||||
vi.clearAllMocks();
|
||||
mockUseChatRooms.mockReturnValue(defaultRoomsState);
|
||||
});
|
||||
|
||||
it("renders CliChatSurface (transcript/terminal toggle) for a cli-backed session", () => {
|
||||
mockUseChat.mockReturnValue(
|
||||
chatState(makeSession({ cliExecutorAdapterId: "claude-code", cliSessionFile: "cli-native-1" })),
|
||||
);
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
// CliChatSurface renders the transcript/terminal toggle tablist.
|
||||
expect(screen.getByRole("tab", { name: /transcript/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /terminal/i })).toBeInTheDocument();
|
||||
// The standard provider send button is NOT rendered as a top-level composer
|
||||
// affordance for the cli surface's default (transcript) view it wraps the
|
||||
// existing composer, but the distinguishing CLI toggle is present.
|
||||
});
|
||||
|
||||
it("attaches the terminal to the native cli session id linkage", () => {
|
||||
mockUseChat.mockReturnValue(
|
||||
chatState(makeSession({ cliExecutorAdapterId: "claude-code", cliSessionFile: "cli-native-1" })),
|
||||
);
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
// Switch to the terminal tab to mount SessionTerminal.
|
||||
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
|
||||
expect(screen.getByTestId("session-terminal").getAttribute("data-session-id")).toBe("cli-native-1");
|
||||
});
|
||||
|
||||
it("generic-tier cli session renders terminal-only (no toggle)", () => {
|
||||
mockUseChat.mockReturnValue(chatState(makeSession({ cliExecutorAdapterId: "generic" })));
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
expect(screen.getByTestId("session-terminal")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /transcript/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the normal provider composer for a regular (non-cli) session", () => {
|
||||
mockUseChat.mockReturnValue(chatState(makeSession({ cliExecutorAdapterId: null })));
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
// Normal composer present, CLI toggle absent.
|
||||
expect(screen.getByPlaceholderText("Type a message...")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /transcript/i })).toBeNull();
|
||||
expect(screen.queryByTestId("session-terminal")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// CLI-backed chat surface tests (CLI Agent Executor, U12).
|
||||
//
|
||||
// Exercises the transcript ↔ terminal toggle, the terminal-owns-composer rule,
|
||||
// the generic-tier terminal-only rendering, and the composer queued indicator.
|
||||
// SessionTerminal is mocked (no xterm / no WS / no PTY / no port 4040) so these
|
||||
// are pure component-behavior assertions.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
// Mock SessionTerminal — it lazy-loads xterm and opens a WS; we only need to
|
||||
// assert presence/absence of the terminal surface.
|
||||
vi.mock("../SessionTerminal", () => ({
|
||||
SessionTerminal: ({ sessionId }: { sessionId: string }) => (
|
||||
<div data-testid="session-terminal" data-session-id={sessionId}>
|
||||
terminal
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { CliChatSurface } from "../CliChatSurface";
|
||||
|
||||
function renderSurface(overrides: Partial<React.ComponentProps<typeof CliChatSurface>> = {}) {
|
||||
return render(
|
||||
<CliChatSurface
|
||||
cliSessionId="cli-1"
|
||||
tier="hybrid"
|
||||
projectId="proj-1"
|
||||
renderTranscript={() => <div data-testid="transcript">transcript-rows</div>}
|
||||
renderComposer={() => <textarea data-testid="composer" />}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CliChatSurface — hybrid tier toggle", () => {
|
||||
it("defaults to the transcript view with the composer visible", () => {
|
||||
renderSurface();
|
||||
expect(screen.getByTestId("transcript")).toBeTruthy();
|
||||
expect(screen.getByTestId("composer")).toBeTruthy();
|
||||
expect(screen.queryByTestId("session-terminal")).toBeNull();
|
||||
});
|
||||
|
||||
it("toggling to terminal swaps the message list for the terminal and HIDES the composer", () => {
|
||||
renderSurface();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
|
||||
expect(screen.getByTestId("session-terminal")).toBeTruthy();
|
||||
// Message list replaced and composer hidden — the terminal owns input.
|
||||
expect(screen.queryByTestId("transcript")).toBeNull();
|
||||
expect(screen.queryByTestId("composer")).toBeNull();
|
||||
});
|
||||
|
||||
it("toggling back restores the transcript and composer (one underlying session)", () => {
|
||||
renderSurface();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
|
||||
const term = screen.getByTestId("session-terminal");
|
||||
expect(term.getAttribute("data-session-id")).toBe("cli-1");
|
||||
fireEvent.click(screen.getByRole("tab", { name: /transcript/i }));
|
||||
expect(screen.getByTestId("transcript")).toBeTruthy();
|
||||
expect(screen.getByTestId("composer")).toBeTruthy();
|
||||
expect(screen.queryByTestId("session-terminal")).toBeNull();
|
||||
});
|
||||
|
||||
it("the terminal attaches to the same cli session id as the toggle reflects", () => {
|
||||
renderSurface({ cliSessionId: "cli-shared" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
|
||||
expect(screen.getByTestId("session-terminal").getAttribute("data-session-id")).toBe(
|
||||
"cli-shared",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CliChatSurface — generic tier", () => {
|
||||
it("renders the terminal only: no toggle, no transcript pane, no composer", () => {
|
||||
renderSurface({ tier: "generic" });
|
||||
expect(screen.getByTestId("session-terminal")).toBeTruthy();
|
||||
expect(screen.queryByRole("tab")).toBeNull();
|
||||
expect(screen.queryByTestId("transcript")).toBeNull();
|
||||
expect(screen.queryByTestId("composer")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CliChatSurface — composer queue indicator", () => {
|
||||
it("shows a queued indicator when messages are queued behind a busy session", () => {
|
||||
renderSurface({ queuedCount: 2 });
|
||||
expect(screen.getByRole("status").textContent).toMatch(/queued/i);
|
||||
});
|
||||
|
||||
it("hides the queued indicator when nothing is queued", () => {
|
||||
renderSurface({ queuedCount: 0 });
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render the queued indicator in raw-terminal mode (composer hidden)", () => {
|
||||
renderSurface({ queuedCount: 3 });
|
||||
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ vi.mock("../../api", () => ({
|
||||
batchUpdateTaskModels: vi.fn(),
|
||||
fetchNodes: vi.fn().mockResolvedValue([]),
|
||||
fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }),
|
||||
api: vi.fn().mockResolvedValue({ sessions: [] }),
|
||||
}));
|
||||
|
||||
import { fetchTaskDetail, batchUpdateTaskModels, fetchNodes } from "../../api";
|
||||
|
||||
@@ -315,3 +315,108 @@ describe("SessionNotificationBanner", () => {
|
||||
expect(screen.queryByText("Error Session")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── CLI agent extensions (CLI Agent Executor, U11) ──────────────────────────
|
||||
function buildCliSession(overrides: Partial<AiSessionSummary>): AiSessionSummary {
|
||||
return {
|
||||
id: overrides.id ?? "cli-1",
|
||||
type: "cli-agent",
|
||||
status: overrides.status ?? "waiting_on_input",
|
||||
title: overrides.title ?? "Implement FN-1",
|
||||
projectId: overrides.projectId ?? "proj-1",
|
||||
lockedByTab: null,
|
||||
updatedAt: overrides.updatedAt ?? new Date().toISOString(),
|
||||
cliVariant: overrides.cliVariant,
|
||||
cliSessionId: overrides.cliSessionId ?? "cli-1",
|
||||
};
|
||||
}
|
||||
|
||||
describe("SessionNotificationBanner — cli-agent (U11)", () => {
|
||||
beforeEach(() => dismissedIds.clear());
|
||||
|
||||
it("renders the cli-agent type without crashing (union regression)", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[buildCliSession({ status: "waiting_on_input" })]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
onDismissAll={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(screen.getByText("Implement FN-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("waiting_on_input surfaces a banner entry; busy clears it (F2)", () => {
|
||||
const { rerender, container } = render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[buildCliSession({ status: "waiting_on_input" })]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
onDismissAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector(".session-notification-banner")).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<SessionNotificationBanner
|
||||
sessions={[buildCliSession({ status: "generating" as AiSessionSummary["status"] })]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
onDismissAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector(".session-notification-banner")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("userExited needs-attention renders pinned copy + Advance/Retry/Cancel task", () => {
|
||||
const onCliAction = vi.fn();
|
||||
render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "userExited" })]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
onDismissAll={vi.fn()}
|
||||
onCliAction={onCliAction}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Agent exited before completing")).toBeInTheDocument();
|
||||
// All three pinned actions render before any action removes the item.
|
||||
expect(screen.getByText("Advance")).toBeInTheDocument();
|
||||
expect(screen.getByText("Retry")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel task")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Advance"));
|
||||
expect(onCliAction).toHaveBeenCalledWith(expect.objectContaining({ id: "cli-1" }), "advance");
|
||||
});
|
||||
|
||||
it("authFailed renders Re-authenticate / Retry", () => {
|
||||
render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "authFailed" })]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
onDismissAll={vi.fn()}
|
||||
onCliAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("CLI authentication failed")).toBeInTheDocument();
|
||||
expect(screen.getByText("Re-authenticate")).toBeInTheDocument();
|
||||
expect(screen.getByText("Retry")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resume-exhausted renders Relaunch fresh / Cancel task", () => {
|
||||
render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "resume-exhausted" })]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
onDismissAll={vi.fn()}
|
||||
onCliAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Couldn't resume the session")).toBeInTheDocument();
|
||||
expect(screen.getByText("Relaunch fresh")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel task")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
||||
|
||||
// ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ──────────
|
||||
const mockTerm = {
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
|
||||
dispose: vi.fn(),
|
||||
unicode: { activeVersion: "6" },
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
};
|
||||
vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(() => mockTerm) }));
|
||||
vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(() => ({ fit: vi.fn() })) }));
|
||||
vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: vi.fn(() => ({})) }));
|
||||
vi.mock("@xterm/addon-webgl", () => ({
|
||||
WebglAddon: vi.fn(() => ({ onContextLoss: vi.fn(), dispose: vi.fn() })),
|
||||
}));
|
||||
|
||||
const apiMock = vi.fn();
|
||||
vi.mock("../../api", () => ({ api: (...args: unknown[]) => apiMock(...args) }));
|
||||
vi.mock("../../auth", () => ({ appendTokenQuery: (u: string) => u }));
|
||||
|
||||
// ── Minimal WebSocket stub ──────────────────────────────────────────────────
|
||||
class FakeWS {
|
||||
static instances: FakeWS[] = [];
|
||||
static OPEN = 1;
|
||||
readyState = 1;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
sent: string[] = [];
|
||||
constructor(public url: string) {
|
||||
FakeWS.instances.push(this);
|
||||
}
|
||||
send(d: string) {
|
||||
this.sent.push(d);
|
||||
}
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
}
|
||||
(globalThis as unknown as { WebSocket: typeof FakeWS }).WebSocket = FakeWS;
|
||||
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
// ── matchMedia mock: drive the mobile breakpoint convention ─────────────────
|
||||
let matchMediaMatches = true;
|
||||
function installMatchMedia(matches: boolean) {
|
||||
matchMediaMatches = matches;
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: vi.fn((query: string) => ({
|
||||
matches: matchMediaMatches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
import { SessionTerminal } from "../SessionTerminal";
|
||||
|
||||
/** Pull the parsed input frames a WS has sent. */
|
||||
function inputFrames(ws: FakeWS): string[] {
|
||||
return ws.sent
|
||||
.map((raw) => JSON.parse(raw))
|
||||
.filter((m) => m.type === "input")
|
||||
.map((m) => m.data as string);
|
||||
}
|
||||
|
||||
/** Render and wait for the WS attach channel to open. */
|
||||
async function renderMobile(props: Record<string, unknown> = {}) {
|
||||
const utils = render(<SessionTerminal sessionId="s1" {...props} />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
return { ...utils, ws: FakeWS.instances[0] };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWS.instances = [];
|
||||
mockTerm.onData.mockReset();
|
||||
mockTerm.write.mockClear();
|
||||
apiMock.mockReset();
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
|
||||
installMatchMedia(true); // mobile by default
|
||||
_resetInitialViewportHeight();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("SessionTerminal (mobile)", () => {
|
||||
it("renders the mobile input bar + accessory key bar on mobile viewports", async () => {
|
||||
await renderMobile();
|
||||
expect(screen.getByTestId("cli-terminal-mobile-bar")).toBeTruthy();
|
||||
expect(screen.getByTestId("cli-terminal-key-bar")).toBeTruthy();
|
||||
expect(screen.getByTestId("cli-terminal-mobile-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render the mobile bar off-mobile (desktop breakpoint)", async () => {
|
||||
installMatchMedia(false);
|
||||
await renderMobile();
|
||||
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render the mobile bar when read-only", async () => {
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: true });
|
||||
await renderMobile({ readOnly: true });
|
||||
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
|
||||
});
|
||||
|
||||
// ── Accessory bar control sequences ───────────────────────────────────────
|
||||
it("Esc key emits 0x1b as an input frame", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
fireEvent.click(screen.getByTestId("cli-key-esc"));
|
||||
expect(inputFrames(ws)).toContain("\x1b");
|
||||
});
|
||||
|
||||
it("Tab key emits 0x09", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
fireEvent.click(screen.getByTestId("cli-key-tab"));
|
||||
expect(inputFrames(ws)).toContain("\x09");
|
||||
});
|
||||
|
||||
it("dedicated Ctrl-C shortcut emits 0x03", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
fireEvent.click(screen.getByTestId("cli-key-ctrl-c"));
|
||||
expect(inputFrames(ws)).toContain("\x03");
|
||||
});
|
||||
|
||||
it("arrow keys emit ANSI CSI cursor sequences", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
fireEvent.click(screen.getByTestId("cli-key-arrow-up"));
|
||||
fireEvent.click(screen.getByTestId("cli-key-arrow-down"));
|
||||
fireEvent.click(screen.getByTestId("cli-key-arrow-right"));
|
||||
fireEvent.click(screen.getByTestId("cli-key-arrow-left"));
|
||||
const frames = inputFrames(ws);
|
||||
expect(frames).toContain("\x1b[A");
|
||||
expect(frames).toContain("\x1b[B");
|
||||
expect(frames).toContain("\x1b[C");
|
||||
expect(frames).toContain("\x1b[D");
|
||||
});
|
||||
|
||||
// ── Sticky Ctrl modifier ──────────────────────────────────────────────────
|
||||
it("sticky Ctrl shows an active visual state", async () => {
|
||||
await renderMobile();
|
||||
const ctrl = screen.getByTestId("cli-key-ctrl");
|
||||
expect(ctrl.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
fireEvent.click(ctrl);
|
||||
expect(ctrl.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(ctrl.className).toContain("cli-terminal-key--active");
|
||||
|
||||
// Tapping again toggles it back off.
|
||||
fireEvent.click(ctrl);
|
||||
expect(ctrl.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it("sticky Ctrl + c → 0x03, + d → 0x04, + z → 0x1a (combined, swallowed from field)", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
const ctrl = screen.getByTestId("cli-key-ctrl");
|
||||
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
|
||||
|
||||
// Ctrl + c
|
||||
fireEvent.click(ctrl);
|
||||
fireEvent.change(input, { target: { value: "c" } });
|
||||
expect(input.value).toBe(""); // combined, not echoed
|
||||
expect(ctrl.getAttribute("aria-pressed")).toBe("false"); // cleared
|
||||
|
||||
// Ctrl + d
|
||||
fireEvent.click(ctrl);
|
||||
fireEvent.change(input, { target: { value: "d" } });
|
||||
|
||||
// Ctrl + z
|
||||
fireEvent.click(ctrl);
|
||||
fireEvent.change(input, { target: { value: "z" } });
|
||||
|
||||
const frames = inputFrames(ws);
|
||||
expect(frames).toContain("\x03"); // Ctrl-C
|
||||
expect(frames).toContain("\x04"); // Ctrl-D
|
||||
expect(frames).toContain("\x1a"); // Ctrl-Z
|
||||
});
|
||||
|
||||
it("without sticky Ctrl, typed letters land in the field (no combine)", async () => {
|
||||
await renderMobile();
|
||||
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "d" } });
|
||||
expect(input.value).toBe("d");
|
||||
});
|
||||
|
||||
it("sticky Ctrl + arrow does not combine (no ctrl combo) but clears modifier", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
const ctrl = screen.getByTestId("cli-key-ctrl");
|
||||
fireEvent.click(ctrl);
|
||||
fireEvent.click(screen.getByTestId("cli-key-arrow-up"));
|
||||
// Arrow has no Ctrl combo → literal CSI sequence is sent, modifier clears.
|
||||
expect(inputFrames(ws)).toContain("\x1b[A");
|
||||
expect(ctrl.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
// ── Input field submit ────────────────────────────────────────────────────
|
||||
it("submitting the input field sends the text then \\r", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "ls -la" } });
|
||||
fireEvent.click(screen.getByTestId("cli-terminal-mobile-send"));
|
||||
const frames = inputFrames(ws);
|
||||
const idx = frames.indexOf("ls -la");
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
expect(frames[idx + 1]).toBe("\r");
|
||||
// Field is cleared after submit.
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("input is user keystrokes — text is forwarded verbatim (no neutralization)", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
|
||||
// An escape sequence typed by the user is sent verbatim (deliberate input).
|
||||
fireEvent.change(input, { target: { value: "echo \x1b[31m" } });
|
||||
fireEvent.click(screen.getByTestId("cli-terminal-mobile-send"));
|
||||
expect(inputFrames(ws)).toContain("echo \x1b[31m");
|
||||
});
|
||||
|
||||
// ── iOS composer pattern: bar keys do not blur the input ──────────────────
|
||||
it("bar key pointerdown preventDefault keeps the input focused", async () => {
|
||||
await renderMobile();
|
||||
const esc = screen.getByTestId("cli-key-esc");
|
||||
const pdEvent = new Event("pointerdown", { bubbles: true, cancelable: true });
|
||||
esc.dispatchEvent(pdEvent);
|
||||
expect(pdEvent.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("send button pointerdown preventDefault keeps the input focused", async () => {
|
||||
await renderMobile();
|
||||
const send = screen.getByTestId("cli-terminal-mobile-send");
|
||||
const pdEvent = new Event("pointerdown", { bubbles: true, cancelable: true });
|
||||
send.dispatchEvent(pdEvent);
|
||||
expect(pdEvent.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
// ── AE6 mobile leg: same live bytes reach term.write ──────────────────────
|
||||
it("mobile attach renders the same live session bytes (data → term.write)", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
const b64 = Buffer.from("live-bytes", "utf8").toString("base64");
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "data", data: b64 }) });
|
||||
await waitFor(() =>
|
||||
expect(mockTerm.write).toHaveBeenCalledWith("live-bytes", expect.any(Function)),
|
||||
);
|
||||
});
|
||||
|
||||
it("xterm onData input is still attached on mobile (bar is primary, not exclusive)", async () => {
|
||||
await renderMobile();
|
||||
expect(mockTerm.onData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Keyboard-open (fixed-footer) + pinch-zoom guard ──────────────────────────
|
||||
describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
|
||||
let savedVisualViewport: typeof window.visualViewport;
|
||||
|
||||
function installVisualViewport({
|
||||
innerHeight,
|
||||
vvHeight,
|
||||
scale = 1,
|
||||
vvOffsetTop = 0,
|
||||
}: {
|
||||
innerHeight: number;
|
||||
vvHeight: number;
|
||||
scale?: number;
|
||||
vvOffsetTop?: number;
|
||||
}) {
|
||||
(window as unknown as { ontouchstart: unknown }).ontouchstart = null;
|
||||
Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
|
||||
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: innerHeight,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
const listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
|
||||
const mockVV = {
|
||||
width: 375,
|
||||
height: vvHeight,
|
||||
offsetTop: vvOffsetTop,
|
||||
offsetLeft: 0,
|
||||
scale,
|
||||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||||
listeners[event]?.push(cb);
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
value: mockVV,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
return { listeners, mockVV };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWS.instances = [];
|
||||
mockTerm.onData.mockReset();
|
||||
apiMock.mockReset();
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
|
||||
installMatchMedia(true);
|
||||
_resetInitialViewportHeight();
|
||||
savedVisualViewport = window.visualViewport;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
value: savedVisualViewport,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
_resetInitialViewportHeight();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keyboard-open applies the fixed-footer class so the bar is not occluded", async () => {
|
||||
installVisualViewport({ innerHeight: 800, vvHeight: 600 });
|
||||
const input = document.createElement("textarea");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
|
||||
await waitFor(() => {
|
||||
const bar = screen.getByTestId("cli-terminal-mobile-bar");
|
||||
expect(bar.className).toContain("cli-session-terminal__mobile-bar--keyboard-open");
|
||||
// Bar lifted above the keyboard by keyboardOverlap (800 - 600 = 200).
|
||||
expect(bar.style.bottom).toBe("200px");
|
||||
});
|
||||
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("pinch-zoom (vv.scale > 1) is NOT treated as keyboard-open", async () => {
|
||||
installVisualViewport({ innerHeight: 800, vvHeight: 600, scale: 2 });
|
||||
const input = document.createElement("textarea");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
|
||||
// Give the keyboard hook a beat to settle; it must stay closed.
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
});
|
||||
|
||||
const bar = screen.getByTestId("cli-terminal-mobile-bar");
|
||||
expect(bar.className).not.toContain("cli-session-terminal__mobile-bar--keyboard-open");
|
||||
expect(bar.getAttribute("data-keyboard-open")).not.toBe("true");
|
||||
|
||||
input.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
// ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ──────────
|
||||
const mockTerm = {
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
|
||||
dispose: vi.fn(),
|
||||
unicode: { activeVersion: "6" },
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
};
|
||||
vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(() => mockTerm) }));
|
||||
vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(() => ({ fit: vi.fn() })) }));
|
||||
vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: vi.fn(() => ({})) }));
|
||||
vi.mock("@xterm/addon-webgl", () => ({
|
||||
WebglAddon: vi.fn(() => ({ onContextLoss: vi.fn(), dispose: vi.fn() })),
|
||||
}));
|
||||
|
||||
const apiMock = vi.fn();
|
||||
vi.mock("../../api", () => ({ api: (...args: unknown[]) => apiMock(...args) }));
|
||||
vi.mock("../../auth", () => ({ appendTokenQuery: (u: string) => u }));
|
||||
|
||||
// ── Minimal WebSocket stub ──────────────────────────────────────────────────
|
||||
class FakeWS {
|
||||
static instances: FakeWS[] = [];
|
||||
static OPEN = 1;
|
||||
readyState = 1;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
sent: string[] = [];
|
||||
constructor(public url: string) {
|
||||
FakeWS.instances.push(this);
|
||||
}
|
||||
send(d: string) {
|
||||
this.sent.push(d);
|
||||
}
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
}
|
||||
(globalThis as unknown as { WebSocket: typeof FakeWS }).WebSocket = FakeWS;
|
||||
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
import { SessionTerminal } from "../SessionTerminal";
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWS.instances = [];
|
||||
mockTerm.onData.mockReset();
|
||||
mockTerm.write.mockClear();
|
||||
apiMock.mockReset();
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("SessionTerminal", () => {
|
||||
it("mints an attach ticket and opens the WS attach channel", async () => {
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
await waitFor(() =>
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
"/cli-sessions/s1/attach-ticket",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
expect(FakeWS.instances[0].url).toContain("sessionId=s1");
|
||||
expect(FakeWS.instances[0].url).toContain("ticket=tkt-1");
|
||||
});
|
||||
|
||||
it("decodes base64 scrollback/data into term.write and ACKs", async () => {
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
const ws = FakeWS.instances[0];
|
||||
const b64 = Buffer.from("hello", "utf8").toString("base64");
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "scrollback", data: b64 }) });
|
||||
await waitFor(() => expect(mockTerm.write).toHaveBeenCalledWith("hello", expect.any(Function)));
|
||||
});
|
||||
|
||||
it("read-only: never registers term.onData (input suppressed)", async () => {
|
||||
render(<SessionTerminal sessionId="s1" readOnly />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
expect(mockTerm.onData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the Read-only badge when readOnly", async () => {
|
||||
render(<SessionTerminal sessionId="s1" readOnly />);
|
||||
expect(await screen.findByText("Read-only")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the session-ended replay state", () => {
|
||||
render(<SessionTerminal sessionId="s1" mode="ended" />);
|
||||
expect(screen.getByText("Session ended")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the session-idle replay state", () => {
|
||||
render(<SessionTerminal sessionId="s1" mode="idle" />);
|
||||
expect(screen.getByText("Session idle")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("posture chip: baseline shows adapter name without elevated styling", () => {
|
||||
render(
|
||||
<SessionTerminal
|
||||
sessionId="s1"
|
||||
posture={{ adapterName: "Claude Code", mode: "default", elevated: false }}
|
||||
/>,
|
||||
);
|
||||
const chip = screen.getByRole("button", { name: /Claude Code/ });
|
||||
expect(chip.getAttribute("data-elevated")).toBe("false");
|
||||
expect(chip.className).not.toContain("cli-posture-chip--elevated");
|
||||
});
|
||||
|
||||
it("posture chip: elevated shows warning styling, the flag, and a tooltip", () => {
|
||||
render(
|
||||
<SessionTerminal
|
||||
sessionId="s1"
|
||||
posture={{
|
||||
adapterName: "Codex",
|
||||
elevated: true,
|
||||
elevatedFlags: ["--dangerously-skip-permissions"],
|
||||
resolved: ["autonomy: full-auto"],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const chip = screen.getByRole("button", { name: /Codex/ });
|
||||
expect(chip.getAttribute("data-elevated")).toBe("true");
|
||||
expect(chip.className).toContain("cli-posture-chip--elevated");
|
||||
expect(screen.getByText("--dangerously-skip-permissions")).toBeTruthy();
|
||||
fireEvent.click(chip);
|
||||
expect(screen.getByRole("tooltip")).toBeTruthy();
|
||||
expect(screen.getByText("autonomy: full-auto")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("confirm-advance strip: Advance posts advance and hides the strip", async () => {
|
||||
const onConfirmAdvance = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<SessionTerminal
|
||||
sessionId="s1"
|
||||
mode="live"
|
||||
showConfirmAdvance
|
||||
onConfirmAdvance={onConfirmAdvance}
|
||||
/>,
|
||||
);
|
||||
const advance = screen.getByText("Advance");
|
||||
fireEvent.click(advance);
|
||||
await waitFor(() => expect(onConfirmAdvance).toHaveBeenCalledWith("advance"));
|
||||
await waitFor(() => expect(screen.queryByText("Advance")).toBeNull());
|
||||
});
|
||||
|
||||
it("confirm-advance strip: Not yet re-arms (calls callback, hides strip)", async () => {
|
||||
const onConfirmAdvance = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<SessionTerminal
|
||||
sessionId="s1"
|
||||
mode="live"
|
||||
showConfirmAdvance
|
||||
onConfirmAdvance={onConfirmAdvance}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("Not yet"));
|
||||
await waitFor(() => expect(onConfirmAdvance).toHaveBeenCalledWith("not-yet"));
|
||||
await waitFor(() => expect(screen.queryByText("Not yet")).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from "react";
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TaskCard, type CliCardState } from "../TaskCard";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
vi.mock("lucide-react", () => {
|
||||
const Stub = () => null;
|
||||
return new Proxy({}, { get: () => Stub });
|
||||
});
|
||||
|
||||
vi.mock("../ProviderIcon", () => ({
|
||||
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`provider-icon-${provider}`} />,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useTaskDiffStats", () => ({
|
||||
useTaskDiffStats: () => ({ stats: null, loading: false }),
|
||||
}));
|
||||
|
||||
const badgeUpdatesMock = new Map<string, unknown>();
|
||||
vi.mock("../../hooks/useBadgeWebSocket", () => ({
|
||||
useBadgeWebSocket: () => ({
|
||||
badgeUpdates: badgeUpdatesMock,
|
||||
isConnected: true,
|
||||
subscribeToBadge: vi.fn(),
|
||||
unsubscribeFromBadge: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useBatchBadgeFetch", () => ({
|
||||
getFreshBatchData: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchTaskDetail: vi.fn(),
|
||||
uploadAttachment: vi.fn(),
|
||||
fetchMission: vi.fn(),
|
||||
fetchAgent: vi.fn(),
|
||||
fetchAgents: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useConfirm", () => ({
|
||||
useConfirm: () => ({ confirm: vi.fn(), confirmWithChoice: vi.fn() }),
|
||||
}));
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-001",
|
||||
title: "Test task",
|
||||
column: "in-progress",
|
||||
status: undefined as never,
|
||||
steps: [],
|
||||
dependencies: [],
|
||||
description: "",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function renderCard(cliSessionState?: CliCardState) {
|
||||
return render(
|
||||
<TaskCard
|
||||
task={makeTask()}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
cliSessionState={cliSessionState}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
badgeUpdatesMock.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("TaskCard CLI agent state badges (U11)", () => {
|
||||
it("renders the waiting-on-input badge when the session is waitingOnInput", () => {
|
||||
renderCard({ agentState: "waitingOnInput" });
|
||||
const badge = screen.getByText("Waiting on input");
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.getAttribute("data-cli-state")).toBe("waitingOnInput");
|
||||
});
|
||||
|
||||
it("renders the needs-attention badge when the session needsAttention", () => {
|
||||
renderCard({ agentState: "needsAttention" });
|
||||
const badge = screen.getByText("Needs attention");
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.getAttribute("data-cli-state")).toBe("needsAttention");
|
||||
});
|
||||
|
||||
it("busy clears both CLI badges (F2 — answering re-arms to busy)", () => {
|
||||
renderCard({ agentState: "busy" });
|
||||
expect(screen.queryByText("Waiting on input")).toBeNull();
|
||||
expect(screen.queryByText("Needs attention")).toBeNull();
|
||||
});
|
||||
|
||||
it("no cli session → no CLI badges (card unchanged)", () => {
|
||||
renderCard(undefined);
|
||||
expect(screen.queryByText("Waiting on input")).toBeNull();
|
||||
expect(screen.queryByText("Needs attention")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
deriveCliTabVisibility,
|
||||
type CliSessionSummaryRecord,
|
||||
} from "../TaskDetailModal";
|
||||
|
||||
function session(
|
||||
agentState: CliSessionSummaryRecord["agentState"],
|
||||
): CliSessionSummaryRecord {
|
||||
return {
|
||||
id: "cli-1",
|
||||
taskId: "FN-1",
|
||||
projectId: "p1",
|
||||
adapterId: "claude-local",
|
||||
agentState,
|
||||
terminationReason: null,
|
||||
autonomyPosture: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TaskDetailModal terminal-tab visibility matrix (U11)", () => {
|
||||
it("no recorded session → tab hidden", () => {
|
||||
expect(deriveCliTabVisibility(null)).toEqual({ kind: "hidden" });
|
||||
});
|
||||
|
||||
it("starting / busy / waitingOnInput → live terminal", () => {
|
||||
for (const s of ["starting", "ready", "busy", "waitingOnInput"] as const) {
|
||||
const v = deriveCliTabVisibility(session(s));
|
||||
expect(v.kind).toBe("live");
|
||||
if (v.kind === "live") {
|
||||
expect(v.mode).toBe("live");
|
||||
expect(v.readOnly).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("one-shot (planning/validator) live → read-only live terminal", () => {
|
||||
const v = deriveCliTabVisibility(session("busy"), { oneShot: true });
|
||||
expect(v.kind).toBe("live");
|
||||
if (v.kind === "live") expect(v.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("generic-tier idle → confirm-advance strip offered on the live terminal", () => {
|
||||
const v = deriveCliTabVisibility(session("busy"), { genericIdle: true });
|
||||
expect(v.kind).toBe("live");
|
||||
if (v.kind === "live") expect(v.showConfirmAdvance).toBe(true);
|
||||
});
|
||||
|
||||
it("execute-done resumable → replay 'session idle'", () => {
|
||||
expect(deriveCliTabVisibility(session("done"))).toEqual({
|
||||
kind: "replay",
|
||||
mode: "idle",
|
||||
});
|
||||
});
|
||||
|
||||
it("reaped (dead / needsAttention) → replay 'session ended'", () => {
|
||||
expect(deriveCliTabVisibility(session("dead"))).toEqual({
|
||||
kind: "replay",
|
||||
mode: "ended",
|
||||
});
|
||||
expect(deriveCliTabVisibility(session("needsAttention"))).toEqual({
|
||||
kind: "replay",
|
||||
mode: "ended",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
||||
import type { WorkflowDefinition } from "@fusion/core";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchWorkflows: vi.fn(),
|
||||
createWorkflow: vi.fn(),
|
||||
updateWorkflow: vi.fn(),
|
||||
deleteWorkflow: vi.fn(),
|
||||
compileWorkflow: vi.fn(),
|
||||
fetchTraits: vi.fn(),
|
||||
fetchStepParsers: vi.fn(),
|
||||
fetchModels: vi.fn(),
|
||||
fetchAgents: vi.fn(),
|
||||
fetchDiscoveredSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
fetchWorkflows,
|
||||
fetchTraits,
|
||||
fetchStepParsers,
|
||||
updateWorkflow,
|
||||
fetchModels,
|
||||
} from "../../api";
|
||||
import type { TraitCatalogEntry } from "../../api";
|
||||
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
|
||||
|
||||
const TRAIT_CATALOG: TraitCatalogEntry[] = [
|
||||
{ id: "intake", name: "Intake", builtin: true, flags: { intake: true } },
|
||||
{ id: "complete", name: "Complete", builtin: true, flags: { complete: true } },
|
||||
];
|
||||
|
||||
function promptDef(): WorkflowDefinition {
|
||||
return {
|
||||
id: "WF-CLI",
|
||||
name: "CLI",
|
||||
description: "",
|
||||
ir: {
|
||||
version: "v2",
|
||||
name: "CLI",
|
||||
columns: [
|
||||
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
|
||||
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "triage" },
|
||||
{ id: "step", kind: "prompt", column: "triage", config: { prompt: "do" } },
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "step", condition: "success" },
|
||||
{ from: "step", to: "end", condition: "success" },
|
||||
],
|
||||
},
|
||||
layout: {
|
||||
start: { x: 0, y: 20 },
|
||||
step: { x: 120, y: 60 },
|
||||
end: { x: 360, y: 240 },
|
||||
},
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowNodeEditor — cli-agent executor (U15)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([promptDef()]);
|
||||
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||
vi.mocked(fetchStepParsers).mockResolvedValue([]);
|
||||
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
|
||||
vi.mocked(updateWorkflow).mockResolvedValue(promptDef());
|
||||
// Stub the adapter-catalog fetch.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.startsWith("/api/cli-agents")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
adapters: [
|
||||
{ id: "claude-code", name: "Claude Code", tier: "native" },
|
||||
{ id: "generic", name: "Generic CLI", tier: "generic" },
|
||||
],
|
||||
}),
|
||||
} as Response;
|
||||
}
|
||||
return { ok: false, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function selectCliAgent() {
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
const node = await screen.findByTestId("wf-node-prompt");
|
||||
fireEvent.click(node);
|
||||
const executorSel = (await screen.findByText("Executor")).parentElement!.querySelector(
|
||||
"select",
|
||||
)! as HTMLSelectElement;
|
||||
fireEvent.change(executorSel, { target: { value: "cli-agent" } });
|
||||
return executorSel;
|
||||
}
|
||||
|
||||
it("surfaces adapter + notification fields when cli-agent is selected", async () => {
|
||||
await selectCliAgent();
|
||||
expect(await screen.findByTestId("cli-agent-config")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cli-agent-adapter")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cli-agent-notify")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cli-agent-autonomy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("populates the adapter picker with tier labels from the API", async () => {
|
||||
await selectCliAgent();
|
||||
const adapterSel = (await screen.findByTestId("cli-agent-adapter")) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect(adapterSel.querySelectorAll("option").length).toBeGreaterThan(2);
|
||||
});
|
||||
const optionText = Array.from(adapterSel.querySelectorAll("option")).map((o) => o.textContent);
|
||||
expect(optionText.some((t) => t?.includes("Claude Code") && t.includes("native"))).toBe(true);
|
||||
expect(optionText.some((t) => t?.includes("Generic CLI") && t.includes("generic"))).toBe(true);
|
||||
});
|
||||
|
||||
it("lands the selected adapter + notify config in the node config", async () => {
|
||||
await selectCliAgent();
|
||||
const adapterSel = (await screen.findByTestId("cli-agent-adapter")) as HTMLSelectElement;
|
||||
fireEvent.change(adapterSel, { target: { value: "claude-code" } });
|
||||
expect(adapterSel.value).toBe("claude-code");
|
||||
|
||||
const notifySel = screen.getByTestId("cli-agent-notify") as HTMLSelectElement;
|
||||
fireEvent.change(notifySel, { target: { value: "banner+notify" } });
|
||||
expect(notifySel.value).toBe("banner+notify");
|
||||
|
||||
// Save and assert the persisted IR carries the cli-agent node config.
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const savedIr = vi.mocked(updateWorkflow).mock.calls.at(-1)![1] as {
|
||||
ir: { nodes: Array<{ id: string; config?: Record<string, unknown> }> };
|
||||
};
|
||||
const stepNode = savedIr.ir.nodes.find((n) => n.id === "step")!;
|
||||
expect(stepNode.config?.executor).toBe("cli-agent");
|
||||
expect(stepNode.config?.cliAdapterId).toBe("claude-code");
|
||||
expect(stepNode.config?.cliNotify).toEqual({ mode: "banner+notify" });
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,14 @@ export interface ChatSessionInfo {
|
||||
lastMessageAt?: string;
|
||||
isGenerating?: boolean;
|
||||
inFlightGeneration?: ChatInFlightGenerationState | null;
|
||||
/**
|
||||
* When set, this chat session is driven by a cli-agent executor (U12). The
|
||||
* message-pane + composer region is delegated to <CliChatSurface> instead of
|
||||
* the standard provider transcript/composer.
|
||||
*/
|
||||
cliExecutorAdapterId?: string | null;
|
||||
/** Native CLI session id linkage (used as the terminal attach id for resume). */
|
||||
cliSessionFile?: string | null;
|
||||
}
|
||||
|
||||
// Re-export shared chat types so existing consumers (`import { ChatMessageInfo } from "../hooks/useChat"`)
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"@types/multer": "^2.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
|
||||
@@ -5,7 +5,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
|
||||
// Mock the engine module to avoid dynamic import issues in tests
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ const { mockChatStreamManager, mockSendMessage, mockCancelGeneration, mockBeginG
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({ createFnAgent: vi.fn(), createWorkflowAuthoringTools: vi.fn(() => []) }));
|
||||
vi.mock("@fusion/engine", () => ({ listCliAdapterDescriptors: () => [], createFnAgent: vi.fn(), createWorkflowAuthoringTools: vi.fn(() => []) }));
|
||||
vi.mock("../planning.js", () => ({
|
||||
getSession: vi.fn(), cleanupSession: vi.fn(), __setCreateFnAgent: vi.fn(), __resetPlanningState: vi.fn(), setAiSessionStore: vi.fn(), rehydrateFromStore: vi.fn().mockReturnValue(0),
|
||||
}));
|
||||
|
||||
375
packages/dashboard/src/__tests__/chat-cli-sessions.test.ts
Normal file
375
packages/dashboard/src/__tests__/chat-cli-sessions.test.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* CLI-backed chat session runner tests (CLI Agent Executor, U12).
|
||||
*
|
||||
* Mocks PTY/adapters entirely: the runner depends only on narrow
|
||||
* `ChatStoreLike` / `CliSessionManagerLike` seams, so these are exercised with
|
||||
* in-memory fakes. No real CliSessionManager, no node-pty, no network, no
|
||||
* port 4040.
|
||||
*
|
||||
* ───────────────────────────────────────────────────────────────────────────
|
||||
* redactSecrets COVERAGE CHARACTERIZATION (U12 deliverable)
|
||||
* ───────────────────────────────────────────────────────────────────────────
|
||||
* The shared @fusion/core `redactSecrets` pass runs on ALL transcript text
|
||||
* before it lands in chat_messages. What it catches today (verified by the
|
||||
* "redaction" describe block below):
|
||||
*
|
||||
* CAUGHT:
|
||||
* - `Authorization: Bearer <token>` and bare `Authorization: <token>` headers.
|
||||
* - Free-standing `Bearer <token>` strings.
|
||||
* - `key=`/`token=`/`secret=`/`password=`/`apikey=`/`access_token=` /
|
||||
* `refresh_token=`/`client_secret=` assignments (`:` or `=`, quoted or bare)
|
||||
* — this is the env-dump (KEY=VALUE) coverage.
|
||||
* - Vendor-prefixed opaque tokens: `sk-…`, `ghp_…`, `gho_…`, `github_pat_…`,
|
||||
* `xoxb-/xoxa-/xoxp-/xoxr-…`, `AKIA…` (>=8 trailing chars).
|
||||
* - Standalone long base64 (>=40 chars) and hex (>=32 chars) blobs.
|
||||
*
|
||||
* KNOWN GAPS (deferred per plan Risks — deeper heuristics are follow-ups):
|
||||
* - Generic short secrets with no recognizable prefix/keyword/length.
|
||||
* - PEM private-key blocks and multi-line credentials are only partially hit
|
||||
* (line-by-line base64 may exceed the length threshold, but headers leak).
|
||||
* - JSON `"token": "..."` survives only via the keyword rule, not structurally.
|
||||
* - Cross-chunk tokens are handled by the ENGINE's TelemetryHub carry-over
|
||||
* window (chunkCarryChars), NOT by redactSecrets alone — see the
|
||||
* "token spanning a chunk split" test which models that carry behavior.
|
||||
* ───────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { redactSecrets } from "@fusion/core";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatMessageCreateInput,
|
||||
ChatSession,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
CliChatSessionRunner,
|
||||
type ChatStoreLike,
|
||||
type CliSessionLike,
|
||||
type CliSessionManagerLike,
|
||||
type ChatTelemetryEvent,
|
||||
} from "../cli-chat.js";
|
||||
|
||||
// ── Fakes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class FakeChatStore implements ChatStoreLike {
|
||||
sessions = new Map<string, ChatSession>();
|
||||
messages: ChatMessage[] = [];
|
||||
private seq = 0;
|
||||
|
||||
putSession(partial: Partial<ChatSession> & { id: string }): ChatSession {
|
||||
const session: ChatSession = {
|
||||
id: partial.id,
|
||||
agentId: "agent-1",
|
||||
title: null,
|
||||
status: "active",
|
||||
projectId: "proj-1",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
cliSessionFile: null,
|
||||
cliExecutorAdapterId: "claude-local",
|
||||
inFlightGeneration: null,
|
||||
...partial,
|
||||
};
|
||||
this.sessions.set(session.id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
getSession(id: string): ChatSession | undefined {
|
||||
return this.sessions.get(id);
|
||||
}
|
||||
|
||||
addMessage(sessionId: string, input: ChatMessageCreateInput): ChatMessage {
|
||||
const msg: ChatMessage = {
|
||||
id: `msg-${++this.seq}`,
|
||||
sessionId,
|
||||
role: input.role,
|
||||
content: input.content ?? "",
|
||||
thinkingOutput: null,
|
||||
metadata: input.metadata ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.messages.push(msg);
|
||||
return msg;
|
||||
}
|
||||
|
||||
setCliExecutorAdapterId(id: string, adapterId: string | null): ChatSession | undefined {
|
||||
const s = this.sessions.get(id);
|
||||
if (!s) return undefined;
|
||||
s.cliExecutorAdapterId = adapterId;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Used by the runner to persist the native session id linkage.
|
||||
setCliSessionFile(id: string, value: string): void {
|
||||
const s = this.sessions.get(id);
|
||||
if (s) s.cliSessionFile = value;
|
||||
}
|
||||
|
||||
messagesFor(sessionId: string): ChatMessage[] {
|
||||
return this.messages.filter((m) => m.sessionId === sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeCliManager implements CliSessionManagerLike {
|
||||
records = new Map<string, CliSessionLike>();
|
||||
injected: { sessionId: string; text: string }[] = [];
|
||||
spawnCalls: unknown[] = [];
|
||||
private seq = 0;
|
||||
|
||||
async spawn(options: Parameters<CliSessionManagerLike["spawn"]>[0]): Promise<CliSessionLike> {
|
||||
this.spawnCalls.push(options);
|
||||
const id = options.resume?.sessionId ?? `cli-${++this.seq}`;
|
||||
const record: CliSessionLike = {
|
||||
id,
|
||||
nativeSessionId: options.resume?.nativeSessionId ?? null,
|
||||
agentState: "ready",
|
||||
};
|
||||
this.records.set(id, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
async inject(sessionId: string, text: string): Promise<void> {
|
||||
this.injected.push({ sessionId, text });
|
||||
}
|
||||
|
||||
getSession(sessionId: string): CliSessionLike | undefined {
|
||||
return this.records.get(sessionId);
|
||||
}
|
||||
|
||||
setState(sessionId: string, state: string): void {
|
||||
const r = this.records.get(sessionId);
|
||||
if (r) r.agentState = state;
|
||||
}
|
||||
}
|
||||
|
||||
function makeRunner() {
|
||||
const store = new FakeChatStore();
|
||||
const manager = new FakeCliManager();
|
||||
const runner = new CliChatSessionRunner({ store, manager });
|
||||
return { store, manager, runner };
|
||||
}
|
||||
|
||||
// ── Session spawn / resume ──────────────────────────────────────────────────
|
||||
|
||||
describe("CliChatSessionRunner — session lifecycle", () => {
|
||||
let ctx: ReturnType<typeof makeRunner>;
|
||||
beforeEach(() => {
|
||||
ctx = makeRunner();
|
||||
});
|
||||
|
||||
it("spawns a chat-purpose CLI session in the configured working directory", async () => {
|
||||
ctx.store.putSession({ id: "chat-1", cliExecutorAdapterId: "claude-local" });
|
||||
const cliId = await ctx.runner.ensureSession("chat-1", {
|
||||
projectId: "proj-1",
|
||||
worktreePath: "/work/dir",
|
||||
});
|
||||
expect(cliId).toBeTruthy();
|
||||
const call = ctx.manager.spawnCalls[0] as Record<string, unknown>;
|
||||
expect(call.purpose).toBe("chat");
|
||||
expect(call.chatSessionId).toBe("chat-1");
|
||||
expect(call.worktreePath).toBe("/work/dir");
|
||||
expect(call.resume).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resumes via the persisted native session id (cliSessionFile linkage)", async () => {
|
||||
ctx.store.putSession({ id: "chat-1", cliSessionFile: "native-abc" });
|
||||
await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
|
||||
const call = ctx.manager.spawnCalls[0] as Record<string, unknown>;
|
||||
expect(call.resume).toEqual({ sessionId: "chat-1", nativeSessionId: "native-abc" });
|
||||
});
|
||||
|
||||
it("reuses an existing live session instead of respawning", async () => {
|
||||
ctx.store.putSession({ id: "chat-1" });
|
||||
const a = await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
|
||||
const b = await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
|
||||
expect(a).toBe(b);
|
||||
expect(ctx.manager.spawnCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects sessions with no cli-agent executor selected", async () => {
|
||||
ctx.store.putSession({ id: "chat-1", cliExecutorAdapterId: null });
|
||||
await expect(ctx.runner.ensureSession("chat-1", { projectId: "proj-1" })).rejects.toThrow(
|
||||
/no cli-agent executor/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Transcript mapping (granularity: user/assistant/tool-summary) ───────────
|
||||
|
||||
describe("CliChatSessionRunner — transcript mapping", () => {
|
||||
let ctx: ReturnType<typeof makeRunner>;
|
||||
let cliId: string;
|
||||
beforeEach(async () => {
|
||||
ctx = makeRunner();
|
||||
ctx.store.putSession({ id: "chat-1" });
|
||||
cliId = await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
|
||||
});
|
||||
|
||||
it("maps a transcript fixture to the expected chat_messages sequence, excluding tool noise", async () => {
|
||||
// Fixture: busy → assistant chunks → a tool-summary → fine-grained tool
|
||||
// noise (must be dropped) → done. Models one assistant turn.
|
||||
const fixture: ChatTelemetryEvent[] = [
|
||||
{ kind: "busy" },
|
||||
{ kind: "transcript", text: "Let me check " },
|
||||
{ kind: "transcript", text: "the config.\n" },
|
||||
{ kind: "toolActivity", text: "Read(config.json)" }, // NOISE — dropped
|
||||
{ kind: "outputProgress", text: "...." }, // NOISE — dropped
|
||||
{ kind: "transcript", toolSummary: "Read config.json (42 lines)" },
|
||||
{ kind: "idle" }, // NOISE — dropped
|
||||
{ kind: "transcript", text: "All good." },
|
||||
{ kind: "done" },
|
||||
];
|
||||
for (const ev of fixture) {
|
||||
await ctx.runner.handleTelemetry("chat-1", ev);
|
||||
}
|
||||
|
||||
const rows = ctx.store.messagesFor("chat-1").map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
kind: (m.metadata as Record<string, unknown> | null)?.kind,
|
||||
}));
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ role: "assistant", content: "Read config.json (42 lines)", kind: "tool-summary" },
|
||||
{ role: "assistant", content: "Let me check the config.\nAll good.", kind: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists native session id on first transcript event carrying it", async () => {
|
||||
await ctx.runner.handleTelemetry("chat-1", {
|
||||
kind: "busy",
|
||||
nativeSessionId: "native-xyz",
|
||||
});
|
||||
expect(ctx.store.getSession("chat-1")?.cliSessionFile).toBe("native-xyz");
|
||||
});
|
||||
|
||||
it("transcript rows persist and reload after the session ends (durable store)", async () => {
|
||||
await ctx.runner.handleTelemetry("chat-1", { kind: "busy" });
|
||||
await ctx.runner.handleTelemetry("chat-1", { kind: "transcript", text: "Done working." });
|
||||
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
|
||||
// Simulate session end + reload: a fresh runner reading the SAME store.
|
||||
const reloaded = new CliChatSessionRunner({ store: ctx.store, manager: ctx.manager });
|
||||
void reloaded;
|
||||
const rows = ctx.store.messagesFor("chat-1");
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].content).toBe("Done working.");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Composer queue (stale-isGenerating learning) ───────────────────────────
|
||||
|
||||
describe("CliChatSessionRunner — composer queue", () => {
|
||||
let ctx: ReturnType<typeof makeRunner>;
|
||||
let cliId: string;
|
||||
beforeEach(async () => {
|
||||
ctx = makeRunner();
|
||||
ctx.store.putSession({ id: "chat-1" });
|
||||
cliId = await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
|
||||
});
|
||||
|
||||
it("injects immediately when the session is idle", async () => {
|
||||
ctx.manager.setState(cliId, "ready");
|
||||
const result = await ctx.runner.send("chat-1", "hello");
|
||||
expect(result).toBe("sent");
|
||||
expect(ctx.manager.injected).toEqual([{ sessionId: cliId, text: "hello" }]);
|
||||
expect(ctx.runner.queuedCount("chat-1")).toBe(0);
|
||||
});
|
||||
|
||||
it("queues with a visible indicator when the session is busy", async () => {
|
||||
ctx.manager.setState(cliId, "busy");
|
||||
const result = await ctx.runner.send("chat-1", "while busy");
|
||||
expect(result).toBe("queued");
|
||||
expect(ctx.manager.injected).toHaveLength(0);
|
||||
expect(ctx.runner.queuedCount("chat-1")).toBe(1);
|
||||
// User message is still persisted even though injection is deferred.
|
||||
expect(ctx.store.messagesFor("chat-1").some((m) => m.role === "user")).toBe(true);
|
||||
});
|
||||
|
||||
it("flushes on done using a RE-FETCHED authoritative state, not a cached flag", async () => {
|
||||
ctx.manager.setState(cliId, "busy");
|
||||
await ctx.runner.send("chat-1", "queued msg");
|
||||
// The 'done' telemetry says the turn ended; flush must re-read the record.
|
||||
ctx.manager.setState(cliId, "ready"); // authoritative state now idle
|
||||
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
|
||||
expect(ctx.manager.injected).toEqual([{ sessionId: cliId, text: "queued msg" }]);
|
||||
expect(ctx.runner.queuedCount("chat-1")).toBe(0);
|
||||
});
|
||||
|
||||
it("does NOT flush if the session turned busy again before the flush (re-fetch wins)", async () => {
|
||||
ctx.manager.setState(cliId, "busy");
|
||||
await ctx.runner.send("chat-1", "queued msg");
|
||||
// 'done' arrives but the authoritative record shows busy again (re-entered turn).
|
||||
ctx.manager.setState(cliId, "busy");
|
||||
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
|
||||
expect(ctx.manager.injected).toHaveLength(0);
|
||||
expect(ctx.runner.queuedCount("chat-1")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Redaction (characterized coverage) ──────────────────────────────────────
|
||||
|
||||
describe("CliChatSessionRunner — redaction before persistence", () => {
|
||||
let ctx: ReturnType<typeof makeRunner>;
|
||||
beforeEach(async () => {
|
||||
ctx = makeRunner();
|
||||
ctx.store.putSession({ id: "chat-1" });
|
||||
await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
|
||||
});
|
||||
|
||||
it("redacts a bearer token in transcript text before it lands in chat_messages", async () => {
|
||||
await ctx.runner.handleTelemetry("chat-1", { kind: "busy" });
|
||||
await ctx.runner.handleTelemetry("chat-1", {
|
||||
kind: "transcript",
|
||||
text: "Authorization: Bearer abcDEF123ghiJKL456mnoPQR789stu",
|
||||
});
|
||||
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
|
||||
const content = ctx.store.messagesFor("chat-1")[0].content;
|
||||
expect(content).toContain("[REDACTED]");
|
||||
expect(content).not.toContain("abcDEF123ghiJKL456mnoPQR789stu");
|
||||
});
|
||||
|
||||
it("redacts an env-dump (KEY=VALUE) before persistence", async () => {
|
||||
await ctx.runner.handleTelemetry("chat-1", { kind: "busy" });
|
||||
await ctx.runner.handleTelemetry("chat-1", {
|
||||
kind: "transcript",
|
||||
text: "API_KEY=sk-livesupersecretvalue9999 TOKEN=ghp_anotherSecretToken12345",
|
||||
});
|
||||
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
|
||||
const content = ctx.store.messagesFor("chat-1")[0].content;
|
||||
expect(content).not.toContain("sk-livesupersecretvalue9999");
|
||||
expect(content).not.toContain("ghp_anotherSecretToken12345");
|
||||
expect(content).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
it("catches a token spanning a chunk split via the engine carry-over model", async () => {
|
||||
// The engine's TelemetryHub keeps a carry tail across chunks so a token
|
||||
// split as `Bearer ` (chunk A) + `<value>` (chunk B) is redacted at the
|
||||
// boundary. We model that carry: the adapter delivers the boundary-joined
|
||||
// text as ONE sanitized transcript event (already redacted upstream), so
|
||||
// the persisted row never contains the value. Here we assert redactSecrets
|
||||
// catches the joined form the carry produces.
|
||||
const chunkA = "here is the Bearer ";
|
||||
const chunkB = "sk-splitTokenAcrossChunks0000abcd";
|
||||
const joined = redactSecrets(chunkA + chunkB);
|
||||
expect(joined).not.toContain("sk-splitTokenAcrossChunks0000abcd");
|
||||
expect(joined).toContain("[REDACTED]");
|
||||
|
||||
// And end-to-end: a transcript event carrying the joined text persists redacted.
|
||||
await ctx.runner.handleTelemetry("chat-1", { kind: "busy" });
|
||||
await ctx.runner.handleTelemetry("chat-1", { kind: "transcript", text: chunkA + chunkB });
|
||||
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
|
||||
const content = ctx.store.messagesFor("chat-1")[0].content;
|
||||
expect(content).not.toContain("sk-splitTokenAcrossChunks0000abcd");
|
||||
});
|
||||
|
||||
it("redacts user composer messages too (users can paste tokens)", async () => {
|
||||
const cliId = ctx.manager.records.keys().next().value as string;
|
||||
ctx.manager.setState(cliId, "ready");
|
||||
await ctx.runner.send("chat-1", "use key=mysupersecretpassword12345 please");
|
||||
const userMsg = ctx.store.messagesFor("chat-1").find((m) => m.role === "user")!;
|
||||
expect(userMsg.content).not.toContain("mysupersecretpassword12345");
|
||||
expect(userMsg.content).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* ChatManager.sendMessage cli-agent send-branch (CLI Agent Executor integration).
|
||||
*
|
||||
* When a chat session selects a cli-agent executor (`cliExecutorAdapterId`),
|
||||
* sendMessage must broker the composer text to the injected CliChatSessionRunner
|
||||
* (ensureSession + send) rather than running the model agent loop. Narrow fakes:
|
||||
* no real ChatStore, no pi-ai agent, no PTY, no network, no port 4040.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { ChatManager } from "../chat.js";
|
||||
|
||||
const mockChatStore = {
|
||||
getSession: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
addMessage: vi.fn(),
|
||||
getMessages: vi.fn(),
|
||||
updateSession: vi.fn(),
|
||||
setCliSessionFile: vi.fn(),
|
||||
setInFlightGeneration: vi.fn(),
|
||||
getRoomMessages: vi.fn(),
|
||||
};
|
||||
|
||||
function makeManager(): ChatManager {
|
||||
return new ChatManager(mockChatStore as never, "/tmp/test");
|
||||
}
|
||||
|
||||
describe("ChatManager.sendMessage — cli-agent send branch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("routes a cli-executor chat session's composer send to runner.send", async () => {
|
||||
mockChatStore.getSession.mockReturnValue({
|
||||
id: "chat-cli",
|
||||
cliExecutorAdapterId: "claude-code",
|
||||
projectId: "proj-1",
|
||||
});
|
||||
|
||||
const ensureSession = vi.fn(async () => "cli-session-1");
|
||||
const send = vi.fn(async () => "sent" as const);
|
||||
const manager = makeManager();
|
||||
manager.setCliChatRunner({ ensureSession, send }, "proj-1");
|
||||
|
||||
await manager.sendMessage("chat-cli", "hello agent");
|
||||
|
||||
expect(ensureSession).toHaveBeenCalledWith("chat-cli", { projectId: "proj-1" });
|
||||
expect(send).toHaveBeenCalledWith("chat-cli", "hello agent");
|
||||
// The model-agent path persists in-flight generation state; the cli branch
|
||||
// must NOT touch it.
|
||||
expect(mockChatStore.setInFlightGeneration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the session's projectId when no explicit runner projectId is set", async () => {
|
||||
mockChatStore.getSession.mockReturnValue({
|
||||
id: "chat-cli2",
|
||||
cliExecutorAdapterId: "codex",
|
||||
projectId: "proj-from-session",
|
||||
});
|
||||
const ensureSession = vi.fn(async () => "cli-session-2");
|
||||
const send = vi.fn(async () => "queued" as const);
|
||||
const manager = makeManager();
|
||||
// No projectId passed to setCliChatRunner → falls back to session.projectId.
|
||||
manager.setCliChatRunner({ ensureSession, send });
|
||||
|
||||
await manager.sendMessage("chat-cli2", "queued please");
|
||||
|
||||
expect(ensureSession).toHaveBeenCalledWith("chat-cli2", { projectId: "proj-from-session" });
|
||||
expect(send).toHaveBeenCalledWith("chat-cli2", "queued please");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// @vitest-environment node
|
||||
|
||||
/**
|
||||
* CLI Agent Executor server-wiring contract (integration bootstrap).
|
||||
*
|
||||
* Proves that a real `createCliAgentRuntime` bundle (over a temp in-memory DB,
|
||||
* PTY mocked at the loadPty seam) satisfies the shapes the dashboard ServerOptions
|
||||
* consume:
|
||||
* - `cliAgentHubResolver(projectId, sessionId)` resolves the project's live
|
||||
* TelemetryHub from the runtime bundle.
|
||||
* - `cliSessionTransport` accepts the runtime's manager + store and the
|
||||
* transport-owned ticket/attribution/confirm singletons, and the
|
||||
* cli-sessions router mounts against that dep without error.
|
||||
*
|
||||
* No real PTY, no network, no port 4040.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { Database } from "@fusion/core";
|
||||
import type { IPty } from "node-pty";
|
||||
import { createCliAgentRuntime, type BootstrappedCliAgentRuntime } from "@fusion/engine";
|
||||
import {
|
||||
AttachTicketStore,
|
||||
CliInputAttributionLog,
|
||||
CliConfirmAdvanceRegistry,
|
||||
} from "../cli-session-transport.js";
|
||||
import { createCliSessionsRouter } from "../routes/cli-sessions.js";
|
||||
import { request } from "../test-request.js";
|
||||
|
||||
function mockPty(): typeof import("node-pty") {
|
||||
return {
|
||||
spawn() {
|
||||
return {
|
||||
pid: 1,
|
||||
onData: () => ({ dispose() {} }),
|
||||
onExit: () => ({ dispose() {} }),
|
||||
write() {},
|
||||
resize() {},
|
||||
pause() {},
|
||||
resume() {},
|
||||
kill() {},
|
||||
clear() {},
|
||||
} as unknown as IPty;
|
||||
},
|
||||
} as unknown as typeof import("node-pty");
|
||||
}
|
||||
|
||||
describe("cli-agent runtime server wiring", () => {
|
||||
let runtime: BootstrappedCliAgentRuntime;
|
||||
let db: Database;
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "fn-cli-wiring-"));
|
||||
const fusionDir = join(tmpDir, ".fusion");
|
||||
db = new Database(fusionDir, { inMemory: true });
|
||||
db.init();
|
||||
runtime = createCliAgentRuntime({
|
||||
fusionDir,
|
||||
db,
|
||||
projectId: "proj-a",
|
||||
hookEndpointUrl: "http://127.0.0.1:4040/api/cli-agent/hooks",
|
||||
managerOptions: { loadPty: async () => mockPty() },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
runtime.dispose();
|
||||
db.close();
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("cliAgentHubResolver resolves the project's hub from the runtime bundle", () => {
|
||||
const engines = new Map([["proj-a", { getCliAgentRuntime: () => runtime }]]);
|
||||
const cliAgentHubResolver = (projectId: string | undefined, _sessionId: string) => {
|
||||
const engine = projectId ? engines.get(projectId) : undefined;
|
||||
return engine?.getCliAgentRuntime()?.bundle.hub;
|
||||
};
|
||||
|
||||
expect(cliAgentHubResolver("proj-a", "cli-1")).toBe(runtime.bundle.hub);
|
||||
expect(cliAgentHubResolver("missing", "cli-1")).toBeUndefined();
|
||||
expect(cliAgentHubResolver(undefined, "cli-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cliSessionTransport dep is satisfied by the runtime manager + store, and the router mounts", async () => {
|
||||
// Seed a session so a transport-backed list route returns it.
|
||||
runtime.bundle.store.createSession({
|
||||
adapterId: runtime.bundle.registry.ids()[0],
|
||||
projectId: "proj-a",
|
||||
purpose: "execute",
|
||||
taskId: "FN-1",
|
||||
worktreePath: "/tmp/wt",
|
||||
agentState: "busy",
|
||||
});
|
||||
|
||||
const transport = {
|
||||
manager: runtime.bundle.manager,
|
||||
store: runtime.bundle.store,
|
||||
ticketStore: new AttachTicketStore(),
|
||||
attributionLog: new CliInputAttributionLog(),
|
||||
confirmAdvance: new CliConfirmAdvanceRegistry(),
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/cli-sessions", createCliSessionsRouter(transport));
|
||||
|
||||
const res = await request(
|
||||
app as unknown as (req: import("http").IncomingMessage, res: import("http").ServerResponse) => void,
|
||||
"GET",
|
||||
"/api/cli-sessions?projectId=proj-a",
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const sessions = res.body.sessions as Array<{ taskId?: string }>;
|
||||
expect(sessions.some((s) => s.taskId === "FN-1")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
// @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("drops (not flushes) an overflowing OSC 52 prefix so it cannot reconstruct across chunks", () => {
|
||||
// An unterminated OSC 52 grows past MAX_CARRY. The dangerous introducer must
|
||||
// NOT be emitted as literal — otherwise a terminator in the next chunk would
|
||||
// recombine at the client into a working OSC 52 clipboard write.
|
||||
const huge = `${ESC}]52;c;` + "A".repeat(MAX_CARRY_LENGTH + 100);
|
||||
const r1 = neutralizeTerminalOutput(huge, "");
|
||||
// Nothing reconstructable was emitted, and the carry was dropped.
|
||||
expect(r1.output).not.toContain(`${ESC}]`);
|
||||
expect(r1.output).not.toContain("52;");
|
||||
expect(r1.carry).toBe("");
|
||||
// The terminator arriving next has no held introducer to recombine with.
|
||||
const r2 = neutralizeTerminalOutput(`${BEL}visible`, r1.carry);
|
||||
const combined = r1.output + r2.output;
|
||||
expect(combined).not.toContain(`${ESC}]52;`);
|
||||
expect(r2.output).toContain("visible");
|
||||
});
|
||||
|
||||
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`);
|
||||
});
|
||||
});
|
||||
625
packages/dashboard/src/__tests__/cli-session-ws.test.ts
Normal file
625
packages/dashboard/src/__tests__/cli-session-ws.test.ts
Normal file
@@ -0,0 +1,625 @@
|
||||
// @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");
|
||||
});
|
||||
});
|
||||
211
packages/dashboard/src/__tests__/cli-sessions-routes.test.ts
Normal file
211
packages/dashboard/src/__tests__/cli-sessions-routes.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import express from "express";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { request } from "../test-request.js";
|
||||
import type { CliSession } from "@fusion/core";
|
||||
|
||||
type App = (req: import("http").IncomingMessage, res: import("http").ServerResponse) => void;
|
||||
|
||||
function getJson(app: App, path: string) {
|
||||
return request(app, "GET", path);
|
||||
}
|
||||
function postJson(app: App, path: string, body: unknown) {
|
||||
return request(app, "POST", path, JSON.stringify(body), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
}
|
||||
import { createCliSessionsRouter } from "../routes/cli-sessions.js";
|
||||
import {
|
||||
AttachTicketStore,
|
||||
CliInputAttributionLog,
|
||||
CliConfirmAdvanceRegistry,
|
||||
type CliSessionManagerLike,
|
||||
} from "../cli-session-transport.js";
|
||||
|
||||
function makeSession(overrides: Partial<CliSession> = {}): CliSession {
|
||||
return {
|
||||
id: "cli-1",
|
||||
taskId: "FN-1",
|
||||
chatSessionId: null,
|
||||
purpose: "execute",
|
||||
projectId: "proj-a",
|
||||
adapterId: "claude-code",
|
||||
agentState: "busy",
|
||||
terminationReason: null,
|
||||
nativeSessionId: null,
|
||||
resumeAttempts: 0,
|
||||
autonomyPosture: null,
|
||||
worktreePath: "/tmp/wt",
|
||||
createdAt: "2026-06-04T00:00:00.000Z",
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeStore(sessions: CliSession[]) {
|
||||
const map = new Map(sessions.map((s) => [s.id, s]));
|
||||
return {
|
||||
getSession: (id: string) => map.get(id),
|
||||
listSessions: (opts?: {
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
chatSessionId?: string;
|
||||
}) =>
|
||||
[...map.values()].filter(
|
||||
(s) =>
|
||||
(opts?.projectId === undefined || s.projectId === opts.projectId) &&
|
||||
(opts?.taskId === undefined || s.taskId === opts.taskId) &&
|
||||
(opts?.chatSessionId === undefined || s.chatSessionId === opts.chatSessionId),
|
||||
),
|
||||
_map: map,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApp(opts: {
|
||||
store: ReturnType<typeof makeStore>;
|
||||
manager: CliSessionManagerLike;
|
||||
ticketStore: AttachTicketStore;
|
||||
attributionLog: CliInputAttributionLog;
|
||||
confirmAdvance: CliConfirmAdvanceRegistry;
|
||||
}): express.Express {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
"/api/cli-sessions",
|
||||
createCliSessionsRouter({
|
||||
store: opts.store,
|
||||
manager: opts.manager,
|
||||
ticketStore: opts.ticketStore,
|
||||
attributionLog: opts.attributionLog,
|
||||
confirmAdvance: opts.confirmAdvance,
|
||||
}),
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("cli-sessions routes", () => {
|
||||
let store: ReturnType<typeof makeStore>;
|
||||
let manager: CliSessionManagerLike;
|
||||
let injectSpy: ReturnType<typeof vi.fn>;
|
||||
let ticketStore: AttachTicketStore;
|
||||
let attributionLog: CliInputAttributionLog;
|
||||
let confirmAdvance: CliConfirmAdvanceRegistry;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
store = makeStore([
|
||||
makeSession(),
|
||||
makeSession({ id: "cli-2", projectId: "proj-b", taskId: "FN-2" }),
|
||||
makeSession({ id: "cli-ro", purpose: "validator", projectId: "proj-a", taskId: "FN-3" }),
|
||||
]);
|
||||
injectSpy = vi.fn().mockResolvedValue(undefined);
|
||||
manager = {
|
||||
isLive: () => true,
|
||||
attach: () => {
|
||||
throw new Error("not used in route tests");
|
||||
},
|
||||
inject: injectSpy,
|
||||
requestPause: vi.fn(),
|
||||
requestResume: vi.fn(),
|
||||
};
|
||||
ticketStore = new AttachTicketStore();
|
||||
attributionLog = new CliInputAttributionLog();
|
||||
confirmAdvance = new CliConfirmAdvanceRegistry();
|
||||
app = buildApp({ store, manager, ticketStore, attributionLog, confirmAdvance });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("lists sessions filtered by project / task / chat", async () => {
|
||||
const res = await getJson(app, "/api/cli-sessions?projectId=proj-a");
|
||||
expect(res.status).toBe(200);
|
||||
const ids = res.body.sessions.map((s: CliSession) => s.id).sort();
|
||||
expect(ids).toEqual(["cli-1", "cli-ro"]);
|
||||
|
||||
const byTask = await getJson(app, "/api/cli-sessions?taskId=FN-2");
|
||||
expect(byTask.body.sessions.map((s: CliSession) => s.id)).toEqual(["cli-2"]);
|
||||
});
|
||||
|
||||
it("returns a single session record", async () => {
|
||||
const res = await getJson(app, "/api/cli-sessions/cli-1");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.session.id).toBe("cli-1");
|
||||
});
|
||||
|
||||
it("404s for unknown session", async () => {
|
||||
const res = await getJson(app, "/api/cli-sessions/nope");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects cross-project access (project scope check)", async () => {
|
||||
const res = await getJson(app, "/api/cli-sessions/cli-1?projectId=proj-b");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("mints a single-use attach ticket with expiry", async () => {
|
||||
const res = await postJson(app, "/api/cli-sessions/cli-1/attach-ticket", {});
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.ticket).toBe("string");
|
||||
expect(res.body.ticket.length).toBeGreaterThan(20);
|
||||
expect(new Date(res.body.expiresAt).getTime()).toBeGreaterThan(Date.now());
|
||||
expect(res.body.readOnly).toBe(false);
|
||||
|
||||
// The ticket consumes exactly once and is bound to its session.
|
||||
const entry = ticketStore.consume(res.body.ticket, "cli-1");
|
||||
expect(entry).not.toBeNull();
|
||||
expect(ticketStore.consume(res.body.ticket, "cli-1")).toBeNull(); // single-use
|
||||
});
|
||||
|
||||
it("marks a read-only (validator) session's ticket as readOnly", async () => {
|
||||
const res = await postJson(app, "/api/cli-sessions/cli-ro/attach-ticket", {});
|
||||
expect(res.body.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("injects text and records attribution", async () => {
|
||||
const res = await postJson(app, "/api/cli-sessions/cli-1/inject", { text: "hello agent" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(injectSpy).toHaveBeenCalledWith("cli-1", "hello agent");
|
||||
const log = attributionLog.list("cli-1");
|
||||
expect(log).toHaveLength(1);
|
||||
expect(log[0].source).toBe("inject");
|
||||
});
|
||||
|
||||
it("rejects inject on a read-only session (server-side enforcement)", async () => {
|
||||
const res = await postJson(app, "/api/cli-sessions/cli-ro/inject", { text: "nope" });
|
||||
expect(res.status).toBe(403);
|
||||
expect(injectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects empty inject body", async () => {
|
||||
const res = await postJson(app, "/api/cli-sessions/cli-1/inject", { text: "" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("409s inject when session is not live", async () => {
|
||||
manager.isLive = () => false;
|
||||
const res = await postJson(app, "/api/cli-sessions/cli-1/inject", { text: "hi" });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("records a confirm-advance decision and emits an event", async () => {
|
||||
const seen: string[] = [];
|
||||
confirmAdvance.on((info) => seen.push(`${info.sessionId}:${info.decision}`));
|
||||
const res = await postJson(app, "/api/cli-sessions/cli-1/confirm-advance", {
|
||||
decision: "advance",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.decision).toBe("advance");
|
||||
expect(confirmAdvance.getLatest("cli-1")).toBe("advance");
|
||||
expect(seen).toContain("cli-1:advance");
|
||||
});
|
||||
|
||||
it("rejects an invalid confirm-advance decision", async () => {
|
||||
const res = await postJson(app, "/api/cli-sessions/cli-1/confirm-advance", {
|
||||
decision: "maybe",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ const mockErrors = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
defaultGitOps: vi.fn(() => ({})),
|
||||
ExperimentFinalizeService: vi.fn(() => ({ previewPlan: previewPlanMock, finalize: finalizeMock })),
|
||||
ExperimentFinalizeStateError: mockErrors.StateError,
|
||||
|
||||
@@ -7,7 +7,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("node:child_process", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: vi.fn(async () => ({
|
||||
session: {
|
||||
prompt: promptMock,
|
||||
|
||||
@@ -33,7 +33,7 @@ vi.mock("@fusion/core", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: vi.fn(async () => ({
|
||||
session: {
|
||||
state: { messages: [] as Array<{ role: string; content: string }> },
|
||||
|
||||
@@ -55,7 +55,7 @@ vi.mock("@fusion/core", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
executeApprovedAgentProvisioning: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class MockApprovalRequestStore {
|
||||
vi.mock("@fusion/core", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@fusion/core")>()), ApprovalRequestStore: MockApprovalRequestStore, AgentStore: class { async init() {} async getAgent() { return null; } } }));
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
executeApprovedAgentProvisioning: vi.fn(),
|
||||
executeApprovedWorktrunkInstall: vi.fn(),
|
||||
assertNoSecretPlaintext: (metadata?: Record<string, unknown>) => {
|
||||
|
||||
@@ -86,7 +86,7 @@ vi.mock("@fusion/core", async (importOriginal) => ({
|
||||
const executeApprovedWorktrunkInstall = vi.fn(async () => ({ binaryPath: "~/.fusion/bin/wt", source: "installed-release" }));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
executeApprovedAgentProvisioning,
|
||||
executeApprovedWorktrunkInstall,
|
||||
}));
|
||||
|
||||
@@ -26,7 +26,7 @@ class MockApprovalRequestStore {
|
||||
}
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
WORKTRUNK_INSTALL_PATH: "~/.fusion/bin/wt",
|
||||
WORKTRUNK_PINNED_RELEASE: {
|
||||
source: "upstream-pending-verification",
|
||||
|
||||
@@ -45,7 +45,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
createResolvedAgentSession: vi.fn(async () => ({
|
||||
session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() },
|
||||
|
||||
@@ -38,7 +38,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ vi.mock("@fusion/core", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: vi.fn(async () => ({
|
||||
session: {
|
||||
state: { messages: [] as Array<{ role: string; content: string }> },
|
||||
|
||||
@@ -9,7 +9,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -16,8 +16,18 @@ import type { IncomingMessage } from "node:http";
|
||||
*/
|
||||
export const TOKEN_QUERY_PARAM = "fn_token";
|
||||
|
||||
/** Paths that are exempt from authentication (liveness probes). */
|
||||
const EXEMPT_PATHS = ["/api/health"];
|
||||
/**
|
||||
* Paths exempt from the daemon bearer-token middleware.
|
||||
*
|
||||
* - `/api/health` — liveness probes.
|
||||
* - `/api/cli-agent/hooks` — the CLI-agent hook ingestion route (U17). Hook
|
||||
* scripts run inside the spawned CLI process and only hold the per-session hook
|
||||
* token, NOT the daemon bearer token. That route does its OWN authentication:
|
||||
* it validates the per-session token against the engine-held registry
|
||||
* (constant-time) and rejects browser-context requests (Origin/Host CSRF
|
||||
* defense). It must therefore bypass the daemon-token gate, not weaken it.
|
||||
*/
|
||||
const EXEMPT_PATHS = ["/api/health", "/api/cli-agent/hooks"];
|
||||
|
||||
/**
|
||||
* Only /api/* paths are gated by this middleware. The SPA shell (index.html,
|
||||
|
||||
@@ -741,6 +741,29 @@ export class ChatManager {
|
||||
private taskStore?: TaskStore,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Runner for CLI-agent-backed chat sessions (CLI Agent Executor). When a chat
|
||||
* session selects a cli-agent executor (`cliExecutorAdapterId`), composer sends
|
||||
* are brokered to the live PTY through this runner instead of the model agent
|
||||
* loop. Injected post-construction (the runtime is built per-project at boot,
|
||||
* after the ChatManager) so the positional ctor stays stable.
|
||||
*/
|
||||
private cliChatRunner?: {
|
||||
ensureSession(chatSessionId: string, opts: { projectId: string; worktreePath?: string | null }): Promise<string>;
|
||||
send(chatSessionId: string, text: string): Promise<"sent" | "queued">;
|
||||
};
|
||||
/** Project id used when the runner spawns a CLI session for a chat. */
|
||||
private cliChatProjectId?: string;
|
||||
|
||||
/** Wire (or clear) the CLI-agent chat runner and its owning project id. */
|
||||
setCliChatRunner(
|
||||
runner: ChatManager["cliChatRunner"] | undefined,
|
||||
projectId?: string,
|
||||
): void {
|
||||
this.cliChatRunner = runner;
|
||||
this.cliChatProjectId = projectId;
|
||||
}
|
||||
|
||||
private queueInFlightGenerationPersist(sessionId: string, snapshot: ChatInFlightGenerationState | null): void {
|
||||
const existingTimer = this.inFlightPersistTimers.get(sessionId);
|
||||
if (existingTimer) {
|
||||
@@ -1398,6 +1421,26 @@ export class ChatManager {
|
||||
const broadcastOptions = { generationId };
|
||||
|
||||
const session = this.chatStore.getSession(sessionId);
|
||||
|
||||
// CLI-agent-backed chat: a session that selected a cli-agent executor brokers
|
||||
// its composer sends to the live PTY (via the runner) rather than running the
|
||||
// model agent loop. The runner persists the user message + the transcript.
|
||||
if (session?.cliExecutorAdapterId && this.cliChatRunner) {
|
||||
const runner = this.cliChatRunner;
|
||||
try {
|
||||
await runner.ensureSession(sessionId, {
|
||||
projectId: this.cliChatProjectId ?? session.projectId ?? "",
|
||||
});
|
||||
await runner.send(sessionId, content);
|
||||
} finally {
|
||||
const current = this.activeGenerations.get(sessionId);
|
||||
if (current?.generationId === generationId) {
|
||||
this.activeGenerations.delete(sessionId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let agentResult: AgentResult | undefined;
|
||||
let accumulatedThinking = "";
|
||||
let accumulatedText = "";
|
||||
|
||||
332
packages/dashboard/src/cli-chat.ts
Normal file
332
packages/dashboard/src/cli-chat.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* CLI-backed chat session runner (CLI Agent Executor, U12).
|
||||
*
|
||||
* When a chat session selects a cli-agent executor (`ChatSession.cliExecutorAdapterId`),
|
||||
* the chat is driven by a long-lived CLI agent process instead of the standard
|
||||
* model-provider path. This runner is the server-side bridge between that CLI
|
||||
* session and the durable chat transcript:
|
||||
*
|
||||
* - It spawns (or resumes) a `CliSessionManager` session with purpose "chat",
|
||||
* cwd = the configured working directory (or the project root), persisting the
|
||||
* native session id back onto the chat session for resume.
|
||||
* - Composer messages route through the inject path (FIFO, serialized by the
|
||||
* manager's write queue). While the session is busy, sends queue; the flush
|
||||
* decision re-fetches authoritative session state from the store rather than
|
||||
* trusting a cached/streamed busy flag (the stale-isGenerating learning,
|
||||
* docs/solutions/logic-errors/queued-chat-message-flush-trusts-stale-isgenerating.md).
|
||||
* - Adapter transcript telemetry events map to `chat_messages` rows at
|
||||
* user/assistant/tool-summary granularity. Fine-grained tool noise
|
||||
* (toolActivity, outputProgress, idle) stays in the terminal and is NOT
|
||||
* persisted — the durable transcript is the readable conversation, not the
|
||||
* raw scrollback.
|
||||
*
|
||||
* Secret hygiene: the shared `redactSecrets` pass runs on transcript text
|
||||
* BEFORE persistence. Durable chat rows must not become a secret store — CLI
|
||||
* agents routinely print bearer tokens and env dumps. See the test block in
|
||||
* packages/dashboard/src/__tests__/chat-cli-sessions.test.ts for the
|
||||
* characterized coverage of what `redactSecrets` catches and its known gaps.
|
||||
*
|
||||
* This module owns no PTY/adapter internals directly: it depends on narrow
|
||||
* interfaces (`ChatStoreLike`, `CliSessionManagerLike`) so it is unit-testable
|
||||
* with mocked PTY/adapters per the U12 constraints.
|
||||
*/
|
||||
|
||||
import { redactSecrets } from "@fusion/core";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatMessageCreateInput,
|
||||
ChatSession,
|
||||
} from "@fusion/core";
|
||||
|
||||
// ── Narrow dependency interfaces (testable seams) ──────────────────────────
|
||||
|
||||
/** The slice of ChatStore this runner needs. */
|
||||
export interface ChatStoreLike {
|
||||
getSession(id: string): ChatSession | undefined;
|
||||
addMessage(sessionId: string, input: ChatMessageCreateInput): ChatMessage;
|
||||
setCliExecutorAdapterId(id: string, adapterId: string | null): ChatSession | undefined;
|
||||
}
|
||||
|
||||
/** A durable cli_sessions record (subset used here). */
|
||||
export interface CliSessionLike {
|
||||
id: string;
|
||||
nativeSessionId: string | null;
|
||||
agentState: string;
|
||||
}
|
||||
|
||||
/** The slice of CliSessionManager this runner needs. */
|
||||
export interface CliSessionManagerLike {
|
||||
spawn(options: {
|
||||
adapterId: string;
|
||||
projectId: string;
|
||||
purpose: "chat";
|
||||
chatSessionId: string;
|
||||
worktreePath?: string | null;
|
||||
resume?: { sessionId: string; nativeSessionId: string };
|
||||
}): Promise<CliSessionLike>;
|
||||
inject(sessionId: string, text: string): Promise<void>;
|
||||
/** Authoritative, freshly-read session record (used for flush decisions). */
|
||||
getSession(sessionId: string): CliSessionLike | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitized telemetry event shape (mirrors engine's SanitizedTelemetryEvent,
|
||||
* duplicated as a structural type to avoid a dashboard→engine import edge).
|
||||
*/
|
||||
export interface ChatTelemetryEvent {
|
||||
kind:
|
||||
| "sessionStart"
|
||||
| "busy"
|
||||
| "waitingOnInput"
|
||||
| "done"
|
||||
| "idle"
|
||||
| "toolActivity"
|
||||
| "outputProgress"
|
||||
| "transcript";
|
||||
text?: string;
|
||||
nativeSessionId?: string;
|
||||
/** Transcript role hint when the adapter distinguishes turns. */
|
||||
role?: "user" | "assistant";
|
||||
/** A tool-summary line (one human-readable line, not raw tool noise). */
|
||||
toolSummary?: string;
|
||||
}
|
||||
|
||||
/** Busy-equivalent states: composer sends must queue, not flush. */
|
||||
const BUSY_STATES = new Set(["starting", "busy", "waitingOnInput"]);
|
||||
|
||||
export interface CliChatSessionRunnerOptions {
|
||||
store: ChatStoreLike;
|
||||
manager: CliSessionManagerLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one CLI-backed chat session to its durable transcript and brokers
|
||||
* composer injection with FIFO queueing.
|
||||
*/
|
||||
export class CliChatSessionRunner {
|
||||
private readonly store: ChatStoreLike;
|
||||
private readonly manager: CliSessionManagerLike;
|
||||
|
||||
/** chatSessionId → live cli session id. */
|
||||
private readonly cliSessionByChat = new Map<string, string>();
|
||||
/** chatSessionId → FIFO queue of composer texts awaiting a flush. */
|
||||
private readonly queue = new Map<string, string[]>();
|
||||
/** chatSessionId → assistant text being accumulated across transcript chunks. */
|
||||
private readonly assistantBuffer = new Map<string, string>();
|
||||
|
||||
constructor(opts: CliChatSessionRunnerOptions) {
|
||||
this.store = opts.store;
|
||||
this.manager = opts.manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a live CLI session exists for the chat, spawning (or resuming via a
|
||||
* persisted native session id) as needed. Returns the cli session id.
|
||||
*/
|
||||
async ensureSession(
|
||||
chatSessionId: string,
|
||||
opts: { projectId: string; worktreePath?: string | null },
|
||||
): Promise<string> {
|
||||
const existing = this.cliSessionByChat.get(chatSessionId);
|
||||
if (existing) return existing;
|
||||
|
||||
const chat = this.store.getSession(chatSessionId);
|
||||
if (!chat) throw new Error(`Unknown chat session: ${chatSessionId}`);
|
||||
const adapterId = chat.cliExecutorAdapterId;
|
||||
if (!adapterId) {
|
||||
throw new Error(`Chat session ${chatSessionId} has no cli-agent executor`);
|
||||
}
|
||||
|
||||
// Resume if we previously recorded a native session id (cliSessionFile-style
|
||||
// linkage; here the native id lives on the cli_sessions record).
|
||||
const resumeNative = chat.cliSessionFile; // native session id persisted on the chat
|
||||
const cli = await this.manager.spawn({
|
||||
adapterId,
|
||||
projectId: opts.projectId,
|
||||
purpose: "chat",
|
||||
chatSessionId,
|
||||
worktreePath: opts.worktreePath ?? null,
|
||||
...(resumeNative
|
||||
? { resume: { sessionId: chatSessionId, nativeSessionId: resumeNative } }
|
||||
: {}),
|
||||
});
|
||||
|
||||
this.cliSessionByChat.set(chatSessionId, cli.id);
|
||||
return cli.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a composer message. If the underlying CLI session is busy (per a
|
||||
* freshly re-fetched store record — never a cached flag), the text is queued
|
||||
* with a visible queued state instead of injected. Returns whether the
|
||||
* message was injected immediately (`"sent"`) or queued (`"queued"`).
|
||||
*
|
||||
* The user message is persisted to the transcript immediately in both cases
|
||||
* so the conversation reflects intent regardless of timing.
|
||||
*/
|
||||
async send(chatSessionId: string, text: string): Promise<"sent" | "queued"> {
|
||||
const cliSessionId = this.cliSessionByChat.get(chatSessionId);
|
||||
if (!cliSessionId) throw new Error(`No live CLI session for chat ${chatSessionId}`);
|
||||
|
||||
// Persist the user's message immediately (redacted — users can paste tokens too).
|
||||
this.store.addMessage(chatSessionId, {
|
||||
role: "user",
|
||||
content: redactSecrets(text),
|
||||
metadata: { source: "cli-agent", origin: "composer" },
|
||||
});
|
||||
|
||||
if (this.isBusy(cliSessionId)) {
|
||||
this.enqueue(chatSessionId, text);
|
||||
return "queued";
|
||||
}
|
||||
|
||||
await this.manager.inject(cliSessionId, text);
|
||||
return "sent";
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative busy check: re-reads the session record from the manager/store
|
||||
* so flush decisions never trust a stale SSE/cached `isGenerating` flag.
|
||||
*/
|
||||
private isBusy(cliSessionId: string): boolean {
|
||||
const record = this.manager.getSession(cliSessionId);
|
||||
if (!record) return false;
|
||||
return BUSY_STATES.has(record.agentState);
|
||||
}
|
||||
|
||||
private enqueue(chatSessionId: string, text: string): void {
|
||||
const q = this.queue.get(chatSessionId) ?? [];
|
||||
q.push(text);
|
||||
this.queue.set(chatSessionId, q);
|
||||
}
|
||||
|
||||
/** Number of composer messages currently queued for a chat (UI indicator). */
|
||||
queuedCount(chatSessionId: string): number {
|
||||
return this.queue.get(chatSessionId)?.length ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to flush one queued composer message. Called when the session
|
||||
* reports `done`. Re-fetches authoritative state before injecting — if the
|
||||
* session turned busy again between the SSE event and this call, the flush
|
||||
* is skipped and the message stays queued (the stale-isGenerating learning).
|
||||
*/
|
||||
async flushNext(chatSessionId: string): Promise<boolean> {
|
||||
const cliSessionId = this.cliSessionByChat.get(chatSessionId);
|
||||
if (!cliSessionId) return false;
|
||||
const q = this.queue.get(chatSessionId);
|
||||
if (!q || q.length === 0) return false;
|
||||
|
||||
// Authoritative re-fetch — do NOT trust a cached/streamed flag here.
|
||||
if (this.isBusy(cliSessionId)) return false;
|
||||
|
||||
const text = q.shift()!;
|
||||
if (q.length === 0) this.queue.delete(chatSessionId);
|
||||
await this.manager.inject(cliSessionId, text);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a sanitized adapter telemetry event to transcript persistence.
|
||||
*
|
||||
* Granularity (KTD): only user / assistant / tool-summary land in
|
||||
* chat_messages. `toolActivity`, `outputProgress`, and `idle` are terminal
|
||||
* noise and are dropped here. `redactSecrets` runs on all persisted text.
|
||||
*
|
||||
* - `busy` → starts a new assistant turn (flushes any prior buffer).
|
||||
* - `transcript` (role assistant or unspecified) → accumulates assistant text.
|
||||
* - `transcript` (role user) → a user-echo turn (rare; adapters that surface it).
|
||||
* - `transcript` with `toolSummary` → a single tool-summary row (no raw noise).
|
||||
* - `done` → flushes the accumulated assistant turn, then tries a queue flush.
|
||||
*
|
||||
* Returns the chat_messages rows it created (for tests / SSE fan-out is the
|
||||
* store's responsibility via `chat:message:added`).
|
||||
*/
|
||||
async handleTelemetry(
|
||||
chatSessionId: string,
|
||||
event: ChatTelemetryEvent,
|
||||
): Promise<ChatMessage[]> {
|
||||
const created: ChatMessage[] = [];
|
||||
|
||||
// Persist the native session id for resume the first time we learn it.
|
||||
if (event.nativeSessionId) {
|
||||
const chat = this.store.getSession(chatSessionId);
|
||||
if (chat && chat.cliSessionFile !== event.nativeSessionId) {
|
||||
// Reuse cliSessionFile column as the native-session linkage (KTD:
|
||||
// cliSessionFile-style column or session metadata). setCliSessionFile is
|
||||
// internal plumbing; we route through the public setter on the runner's
|
||||
// store slice when available, else fall through.
|
||||
(this.store as { setCliSessionFile?: (id: string, v: string) => void }).setCliSessionFile?.(
|
||||
chatSessionId,
|
||||
event.nativeSessionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
switch (event.kind) {
|
||||
case "busy": {
|
||||
// New assistant turn begins — flush any stale buffer defensively.
|
||||
this.flushAssistantBuffer(chatSessionId, created);
|
||||
this.assistantBuffer.set(chatSessionId, "");
|
||||
break;
|
||||
}
|
||||
case "transcript": {
|
||||
if (event.toolSummary) {
|
||||
// One readable tool-summary row. Raw per-call tool noise never reaches here.
|
||||
const row = this.store.addMessage(chatSessionId, {
|
||||
role: "assistant",
|
||||
content: redactSecrets(event.toolSummary),
|
||||
metadata: { source: "cli-agent", kind: "tool-summary" },
|
||||
});
|
||||
created.push(row);
|
||||
break;
|
||||
}
|
||||
const text = event.text ?? "";
|
||||
if (event.role === "user") {
|
||||
// Adapter-surfaced user echo — persist as a user row (deduped by caller).
|
||||
const row = this.store.addMessage(chatSessionId, {
|
||||
role: "user",
|
||||
content: redactSecrets(text),
|
||||
metadata: { source: "cli-agent", origin: "transcript" },
|
||||
});
|
||||
created.push(row);
|
||||
break;
|
||||
}
|
||||
// Default: assistant transcript text — accumulate across chunks.
|
||||
const buf = this.assistantBuffer.get(chatSessionId) ?? "";
|
||||
this.assistantBuffer.set(chatSessionId, buf + text);
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
this.flushAssistantBuffer(chatSessionId, created);
|
||||
// Session idle → attempt to flush one queued composer message.
|
||||
await this.flushNext(chatSessionId);
|
||||
break;
|
||||
}
|
||||
// Terminal-only noise — intentionally NOT persisted to the transcript.
|
||||
case "toolActivity":
|
||||
case "outputProgress":
|
||||
case "idle":
|
||||
case "sessionStart":
|
||||
case "waitingOnInput":
|
||||
break;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Persist the accumulated assistant turn as one row, if non-empty. */
|
||||
private flushAssistantBuffer(chatSessionId: string, into: ChatMessage[]): void {
|
||||
const buf = this.assistantBuffer.get(chatSessionId);
|
||||
if (buf == null) return;
|
||||
this.assistantBuffer.delete(chatSessionId);
|
||||
const trimmed = buf.trim();
|
||||
if (trimmed.length === 0) return;
|
||||
const row = this.store.addMessage(chatSessionId, {
|
||||
role: "assistant",
|
||||
content: redactSecrets(trimmed),
|
||||
metadata: { source: "cli-agent" },
|
||||
});
|
||||
into.push(row);
|
||||
}
|
||||
}
|
||||
253
packages/dashboard/src/cli-session-output-filter.ts
Normal file
253
packages/dashboard/src/cli-session-output-filter.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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 {
|
||||
const input = carry + chunk;
|
||||
let out = "";
|
||||
let i = 0;
|
||||
|
||||
while (i < input.length) {
|
||||
const ch = input[i];
|
||||
if (ch !== ESC) {
|
||||
out += ch;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// We're at an ESC. Examine the remainder.
|
||||
const rest = input.slice(i);
|
||||
|
||||
// ── OSC: ESC ] … BEL|ST ──
|
||||
if (rest.startsWith(`${ESC}]`)) {
|
||||
const belIdx = rest.indexOf(BEL);
|
||||
const stIdx = rest.indexOf(ST);
|
||||
let endIdx = -1;
|
||||
let termLen = 0;
|
||||
if (belIdx !== -1 && (stIdx === -1 || belIdx < stIdx)) {
|
||||
endIdx = belIdx;
|
||||
termLen = BEL.length;
|
||||
} else if (stIdx !== -1) {
|
||||
endIdx = stIdx;
|
||||
termLen = ST.length;
|
||||
}
|
||||
if (endIdx === -1) {
|
||||
// Unterminated — withhold as carry (bounded).
|
||||
break;
|
||||
}
|
||||
const body = rest.slice(2, endIdx);
|
||||
out += neutralizeOsc(body);
|
||||
i += endIdx + termLen;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── DCS (DECRQSS): ESC P … ESC \ ──
|
||||
if (rest.startsWith(`${ESC}P`)) {
|
||||
const stIdx = rest.indexOf(ST, 2);
|
||||
if (stIdx === -1) {
|
||||
break; // unterminated — carry
|
||||
}
|
||||
// DECRQSS is a query (ESC P $ q …). Strip the whole DCS.
|
||||
i += stIdx + ST.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── CSI: ESC [ … final(@-~) ──
|
||||
if (rest.startsWith(`${ESC}[`)) {
|
||||
const finalMatch = rest.slice(2).search(/[@-~]/);
|
||||
if (finalMatch === -1) {
|
||||
break; // unterminated — carry
|
||||
}
|
||||
const seq = rest.slice(0, 2 + finalMatch + 1);
|
||||
if (isForgingQueryCsi(seq)) {
|
||||
// Strip the query.
|
||||
} else {
|
||||
out += seq;
|
||||
}
|
||||
i += seq.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Lone ESC at end of buffer: could begin a sequence next chunk. ──
|
||||
if (rest.length === 1) {
|
||||
break; // carry the ESC
|
||||
}
|
||||
|
||||
// ── Some other 2-byte ESC sequence (ESC <byte>) — pass through. ──
|
||||
out += rest.slice(0, 2);
|
||||
i += 2;
|
||||
}
|
||||
|
||||
let newCarry = input.slice(i);
|
||||
// Bound the carry: if a "sequence" never terminates, don't buffer forever.
|
||||
if (newCarry.length > MAX_CARRY_LENGTH) {
|
||||
// If the overflowing carry begins with a recognized dangerous introducer
|
||||
// (OSC `ESC ]` or DCS `ESC P`), do NOT flush it as literal — emitting the
|
||||
// raw `ESC ]52;…` prefix would let it recombine with a terminator that
|
||||
// arrives in a later chunk and reconstruct the hazardous sequence at the
|
||||
// client. Drop the introducer (and everything held with it) so it can never
|
||||
// be reassembled. Harmless overflow (anything else) is flushed as before.
|
||||
if (newCarry.startsWith(`${ESC}]`) || newCarry.startsWith(`${ESC}P`)) {
|
||||
// Strip the dangerous prefix entirely.
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
344
packages/dashboard/src/cli-session-transport.ts
Normal file
344
packages/dashboard/src/cli-session-transport.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* cli-session-transport — shared transport primitives for CLI agent sessions
|
||||
* (CLI Agent Executor, U10).
|
||||
*
|
||||
* Holds the pieces the REST router (cli-sessions.ts) and the WS attach handler
|
||||
* (server.ts wiring) both depend on:
|
||||
* - the narrow manager/store/hub interfaces the transport needs (so tests can
|
||||
* supply a fake CliSessionManager without a real PTY),
|
||||
* - the short-lived, single-use, session-scoped ATTACH TICKET store,
|
||||
* - input-source ATTRIBUTION bookkeeping (ticket id → session input log),
|
||||
* - the generic-tier CONFIRM-ADVANCE flag/event seam,
|
||||
* - the ORIGIN allowlist check for the WS upgrade.
|
||||
*
|
||||
* Attach auth (KTD): the long-lived daemon token alone never authorizes PTY
|
||||
* WRITE access. A surface must first call the authenticated attach-ticket route
|
||||
* (daemon-token gated), then present the single-use ticket on the WS upgrade —
|
||||
* which ALSO re-checks the daemon token and an Origin allowlist. Keystroke
|
||||
* injection into a privileged agent PTY warrants the stronger posture.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { CliSession, CliSessionStore } from "@fusion/core";
|
||||
import {
|
||||
stripAnsiControl,
|
||||
type CliSessionAttachment,
|
||||
type CliStateChange,
|
||||
} from "@fusion/engine";
|
||||
import { emitCliSessionStateSseEvent } from "./sse.js";
|
||||
|
||||
// ── Narrow interfaces the transport needs ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The subset of the engine `CliSessionManager` the transport touches. The real
|
||||
* manager satisfies this; WS tests supply a fake (no PTY) implementing exactly
|
||||
* these members.
|
||||
*/
|
||||
export interface CliSessionManagerLike {
|
||||
/** Whether a session id is currently live (attachable). */
|
||||
isLive(sessionId: string): boolean;
|
||||
/** Attach a client: scrollback + live stream + write/resize/detach. */
|
||||
attach(sessionId: string): CliSessionAttachment;
|
||||
/** Inject a composed/engine prompt (neutralized) onto the shared FIFO. */
|
||||
inject(sessionId: string, text: string): Promise<void>;
|
||||
/** High-watermark backpressure: pause the PTY. */
|
||||
requestPause(sessionId: string): void;
|
||||
/** Low-watermark backpressure release: resume the PTY. */
|
||||
requestResume(sessionId: string): void;
|
||||
}
|
||||
|
||||
/** Dependencies the transport binds to (engine-owned, supplied at setup). */
|
||||
export interface CliSessionTransportDeps {
|
||||
manager: CliSessionManagerLike;
|
||||
store: Pick<CliSessionStore, "getSession" | "listSessions">;
|
||||
}
|
||||
|
||||
// ── Attach tickets ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Default attach-ticket TTL (ms). Short-lived: enough to open a WS, no more. */
|
||||
export const DEFAULT_ATTACH_TICKET_TTL_MS = 60_000;
|
||||
|
||||
interface AttachTicketEntry {
|
||||
ticket: string;
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
/** Whether write (input/inject over WS) is permitted (read-only sessions: false). */
|
||||
readOnly: boolean;
|
||||
expiresAt: number;
|
||||
consumed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory, single-use, session-scoped attach-ticket store. A ticket is minted
|
||||
* by the authenticated REST route, then consumed exactly once on the WS upgrade.
|
||||
* Expired/consumed tickets never validate, and a ticket for session A can never
|
||||
* attach session B (the session id is bound into the ticket).
|
||||
*/
|
||||
export class AttachTicketStore {
|
||||
private readonly tickets = new Map<string, AttachTicketEntry>();
|
||||
private readonly ttlMs: number;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(opts?: { ttlMs?: number; now?: () => number }) {
|
||||
this.ttlMs = opts?.ttlMs ?? DEFAULT_ATTACH_TICKET_TTL_MS;
|
||||
this.now = opts?.now ?? (() => Date.now());
|
||||
}
|
||||
|
||||
/** Mint a ticket for a session. Returns the opaque ticket + its expiry. */
|
||||
mint(input: {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
readOnly: boolean;
|
||||
}): { ticket: string; expiresAt: number } {
|
||||
const ticket = randomBytes(24).toString("hex");
|
||||
const expiresAt = this.now() + this.ttlMs;
|
||||
this.tickets.set(ticket, {
|
||||
ticket,
|
||||
sessionId: input.sessionId,
|
||||
projectId: input.projectId,
|
||||
readOnly: input.readOnly,
|
||||
expiresAt,
|
||||
consumed: false,
|
||||
});
|
||||
return { ticket, expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a ticket for a specific session (single-use). Returns the entry on
|
||||
* success, or null when: unknown, already consumed, expired, or bound to a
|
||||
* DIFFERENT session than `sessionId`.
|
||||
*/
|
||||
consume(ticket: string | null | undefined, sessionId: string): AttachTicketEntry | null {
|
||||
if (!ticket) return null;
|
||||
const entry = this.tickets.get(ticket);
|
||||
if (!entry) return null;
|
||||
if (entry.consumed) return null;
|
||||
if (this.now() > entry.expiresAt) {
|
||||
this.tickets.delete(ticket);
|
||||
return null;
|
||||
}
|
||||
if (entry.sessionId !== sessionId) return null;
|
||||
entry.consumed = true;
|
||||
// Keep briefly for attribution, but it can never be reused.
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Sweep expired tickets (best-effort housekeeping). */
|
||||
sweepExpired(): void {
|
||||
const now = this.now();
|
||||
for (const [ticket, entry] of this.tickets) {
|
||||
if (now > entry.expiresAt) this.tickets.delete(ticket);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Input-source attribution ─────────────────────────────────────────────────
|
||||
|
||||
export interface CliInputAttributionEntry {
|
||||
/** The ticket id the input arrived under (the accountability floor in v1). */
|
||||
ticketId: string;
|
||||
/** "ws" for WS input frames, "inject" for the REST inject route. */
|
||||
source: "ws" | "inject";
|
||||
/** Byte length of the input (not the content — content is not retained). */
|
||||
byteLength: number;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory per-session input-attribution log. v1 has no per-user arbitration,
|
||||
* so logging which ticket each input frame arrived under is the accountability
|
||||
* floor for post-incident attribution.
|
||||
*/
|
||||
export class CliInputAttributionLog {
|
||||
private readonly bySession = new Map<string, CliInputAttributionEntry[]>();
|
||||
private readonly cap: number;
|
||||
|
||||
constructor(opts?: { capPerSession?: number }) {
|
||||
this.cap = opts?.capPerSession ?? 1000;
|
||||
}
|
||||
|
||||
record(sessionId: string, entry: CliInputAttributionEntry): void {
|
||||
let list = this.bySession.get(sessionId);
|
||||
if (!list) {
|
||||
list = [];
|
||||
this.bySession.set(sessionId, list);
|
||||
}
|
||||
list.push(entry);
|
||||
if (list.length > this.cap) list.splice(0, list.length - this.cap);
|
||||
}
|
||||
|
||||
list(sessionId: string): CliInputAttributionEntry[] {
|
||||
return [...(this.bySession.get(sessionId) ?? [])];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Confirm-advance (generic-tier R20 affordance) ────────────────────────────
|
||||
|
||||
export type CliConfirmAdvanceListener = (info: {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
decision: "advance" | "not-yet";
|
||||
}) => void;
|
||||
|
||||
/**
|
||||
* The generic-tier "this session looks idle — advance to review?" affordance.
|
||||
* The engine pipeline layer acts on the event later; for now the transport
|
||||
* persists the latest decision per session and emits to subscribers (the engine
|
||||
* seam wires a listener in a later unit).
|
||||
*/
|
||||
export class CliConfirmAdvanceRegistry {
|
||||
private readonly latest = new Map<string, "advance" | "not-yet">();
|
||||
private readonly listeners = new Set<CliConfirmAdvanceListener>();
|
||||
|
||||
on(listener: CliConfirmAdvanceListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
record(sessionId: string, projectId: string, decision: "advance" | "not-yet"): void {
|
||||
this.latest.set(sessionId, decision);
|
||||
for (const listener of this.listeners) {
|
||||
listener({ sessionId, projectId, decision });
|
||||
}
|
||||
}
|
||||
|
||||
getLatest(sessionId: string): "advance" | "not-yet" | undefined {
|
||||
return this.latest.get(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read-only enforcement ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether a session is read-only (one-shot validator/planning, U9). Server-side
|
||||
* enforcement (not just client) — input/inject is rejected for these.
|
||||
*
|
||||
* U9's one-shot sessions (validator/planning/CE) are wired via the engine's
|
||||
* `runOneShotSession`, which persists the autonomy posture `readOnly` flag on
|
||||
* the session record. This check honors that flag plus validator/planning
|
||||
* purposes which are inherently read-only.
|
||||
*/
|
||||
export function isReadOnlySession(session: CliSession): boolean {
|
||||
if (session.autonomyPosture && session.autonomyPosture.readOnly === true) return true;
|
||||
return session.purpose === "validator" || session.purpose === "planning";
|
||||
}
|
||||
|
||||
// ── Origin allowlist ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Origin allowlist check for the WS upgrade. Rejects a foreign or absent Origin
|
||||
* (a privileged keystroke channel must not be CSRF-attachable from another
|
||||
* page). Allowed:
|
||||
* - no Origin header AND not a browser context (native clients: TUI, our own WS
|
||||
* client set no Origin) — these are allowed because they aren't subject to the
|
||||
* browser same-origin model and authenticate via the daemon token + ticket.
|
||||
* We detect "browser context" by the presence of `Sec-Fetch-Site` / a
|
||||
* `User-Agent` claiming a browser; absent those, an absent Origin is a native
|
||||
* client.
|
||||
* - an Origin whose host matches the request Host (same-host), or
|
||||
* - an Origin in the configured extras allowlist.
|
||||
*
|
||||
* Per the plan, a FOREIGN or ABSENT Origin from a browser is rejected.
|
||||
*/
|
||||
export interface OriginCheckInput {
|
||||
origin: string | undefined;
|
||||
host: string | undefined;
|
||||
/** Sec-Fetch-Site header (present on browser-issued requests). */
|
||||
secFetchSite?: string | undefined;
|
||||
/** Extra allowed origins (exact, scheme+host[:port]) from config. */
|
||||
extraAllowedOrigins?: string[];
|
||||
}
|
||||
|
||||
// ── SSE state bridge ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Default cap (chars) on the SSE last-output preview. */
|
||||
export const DEFAULT_OUTPUT_PREVIEW_CHARS = 200;
|
||||
|
||||
/**
|
||||
* Bound + sanitize a last-output preview for the SSE `cli:session:state` event:
|
||||
* ANSI/control-stripped, redacted, and capped (~200 chars). The text is already
|
||||
* expected to be recent output (e.g. the scrollback tail); we strip first, then
|
||||
* the engine's redaction is applied by the supplier — but we strip here defensively.
|
||||
*/
|
||||
export function buildOutputPreview(
|
||||
raw: string | undefined,
|
||||
maxChars = DEFAULT_OUTPUT_PREVIEW_CHARS,
|
||||
): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
const stripped = stripAnsiControl(raw).replace(/\s+/g, " ").trim();
|
||||
if (stripped.length === 0) return undefined;
|
||||
return stripped.length > maxChars ? stripped.slice(-maxChars) : stripped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an engine state machine's throttled `onStateChange` and forward
|
||||
* each transition onto the SSE bus as a `cli:session:state` event. The engine
|
||||
* applies the ~500ms throttle (via `stateChangeThrottleMs`); this bridge only
|
||||
* shapes the payload (owning entity + bounded redacted preview) and routes it
|
||||
* project-scoped.
|
||||
*
|
||||
* Returns an unsubscribe handle.
|
||||
*/
|
||||
export function bridgeCliStateToSse(
|
||||
machine: { onStateChange(listener: (change: CliStateChange) => void): () => void },
|
||||
deps: {
|
||||
store: Pick<CliSessionStore, "getSession">;
|
||||
/** Recent (raw) output supplier for the bounded preview (e.g. scrollback tail). */
|
||||
getRecentOutput?: (sessionId: string) => string | undefined;
|
||||
},
|
||||
): () => void {
|
||||
return machine.onStateChange((change) => {
|
||||
const session = deps.store.getSession(change.sessionId);
|
||||
const preview = buildOutputPreview(deps.getRecentOutput?.(change.sessionId));
|
||||
emitCliSessionStateSseEvent(
|
||||
{
|
||||
sessionId: change.sessionId,
|
||||
taskId: session?.taskId ?? null,
|
||||
chatSessionId: session?.chatSessionId ?? null,
|
||||
state: change.state,
|
||||
terminationReason: change.terminationReason,
|
||||
lastOutputPreview: preview,
|
||||
at: change.at,
|
||||
},
|
||||
session?.projectId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isOriginAllowed(input: OriginCheckInput): boolean {
|
||||
const { origin, host, secFetchSite, extraAllowedOrigins } = input;
|
||||
|
||||
// No Origin header.
|
||||
if (!origin) {
|
||||
// A browser ALWAYS sends Origin on a cross-site WS and sets Sec-Fetch-Site;
|
||||
// an absent Origin together with a browser signal is suspicious → reject.
|
||||
if (secFetchSite && secFetchSite !== "none" && secFetchSite !== "same-origin") {
|
||||
return false;
|
||||
}
|
||||
// Native client (TUI / our WS client) — allowed (token + ticket gated).
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse the Origin.
|
||||
let originUrl: URL;
|
||||
try {
|
||||
originUrl = new URL(origin);
|
||||
} catch {
|
||||
return false; // malformed Origin → reject
|
||||
}
|
||||
|
||||
// Same-host: the Origin's host:port matches the request Host header.
|
||||
if (host && originUrl.host === host) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extras allowlist (exact scheme+host[:port]).
|
||||
if (extraAllowedOrigins && extraAllowedOrigins.length > 0) {
|
||||
const normalized = `${originUrl.protocol}//${originUrl.host}`;
|
||||
if (extraAllowedOrigins.some((o) => o === normalized || o === origin)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
351
packages/dashboard/src/cli-session-ws.ts
Normal file
351
packages/dashboard/src/cli-session-ws.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* cli-session-ws — distinct WebSocket attach handler for CLI agent sessions
|
||||
* (CLI Agent Executor, U10).
|
||||
*
|
||||
* This is a SEPARATE connection handler from the existing terminal WS
|
||||
* (`/api/terminal/ws`): it shares only the upgrade gate shape (daemon-token
|
||||
* auth) and the JSON frame protocol style. The connection body resolves the
|
||||
* session from the engine's `CliSessionManager` (the explicit async attach
|
||||
* interface), never the dashboard-local terminal service.
|
||||
*
|
||||
* Path: /api/cli-sessions/ws?sessionId=<id>&ticket=<ticket>[&fn_token=<token>]
|
||||
*
|
||||
* Upgrade gate (stronger than the terminal WS — this channel injects keystrokes
|
||||
* into privileged agent PTYs):
|
||||
* 1. daemon-token auth (authenticateUpgradeRequest), AND
|
||||
* 2. an Origin allowlist check (reject foreign/absent browser Origin), AND
|
||||
* 3. a valid, unconsumed, single-use ticket bound to this exact session.
|
||||
*
|
||||
* Frame protocol (JSON, mirrors the terminal WS shape):
|
||||
* server→client: {type:"scrollback", data} (base64 bytes) — once, on connect
|
||||
* {type:"data", data} (base64 bytes) — live, neutralized
|
||||
* {type:"state", ...} (optional state hints)
|
||||
* {type:"error", message} (e.g. read-only input rejected)
|
||||
* client→server: {type:"input", data} (utf8 keystrokes → PTY write)
|
||||
* {type:"resize", cols, rows} (latest-active-client resize)
|
||||
* {type:"ack", bytes} (ACK-credit flow control)
|
||||
*
|
||||
* Flow control: the client ACKs bytes it has consumed; the server tracks
|
||||
* outstanding unacked bytes and, above the high watermark, calls
|
||||
* `manager.requestPause`; back below the low watermark, `manager.requestResume`.
|
||||
* The server never buffers PTY bytes — the engine owns the ring + pause/resume.
|
||||
*
|
||||
* Output hardening: every live `data` frame is passed through
|
||||
* `neutralizeTerminalOutput` (carry threaded across chunks) BEFORE send.
|
||||
*/
|
||||
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import type { IncomingMessage, Server as HttpServer } from "node:http";
|
||||
import { authenticateUpgradeRequest } from "./auth-middleware.js";
|
||||
import { neutralizeTerminalOutput, flushTerminalOutput } from "./cli-session-output-filter.js";
|
||||
import {
|
||||
isOriginAllowed,
|
||||
isReadOnlySession,
|
||||
type AttachTicketStore,
|
||||
type CliInputAttributionLog,
|
||||
type CliSessionTransportDeps,
|
||||
} from "./cli-session-transport.js";
|
||||
|
||||
/** Default high/low watermark (bytes) for ACK-credit backpressure. */
|
||||
export const DEFAULT_HIGH_WATERMARK_BYTES = 128 * 1024;
|
||||
export const DEFAULT_LOW_WATERMARK_BYTES = 16 * 1024;
|
||||
|
||||
export const CLI_SESSION_WS_PATH = "/api/cli-sessions/ws";
|
||||
|
||||
export interface CliSessionWebSocketOptions extends CliSessionTransportDeps {
|
||||
ticketStore: AttachTicketStore;
|
||||
attributionLog: CliInputAttributionLog;
|
||||
/** Daemon token; when set (and not noAuth) the upgrade requires it. */
|
||||
daemonToken?: string;
|
||||
noAuth?: boolean;
|
||||
/** Extra allowed WS Origins (scheme+host[:port]). */
|
||||
extraAllowedOrigins?: string[];
|
||||
highWatermarkBytes?: number;
|
||||
lowWatermarkBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the CLI-session WebSocket handler to an HTTP server. Adds its OWN
|
||||
* `upgrade` listener filtered to the cli-sessions path, so it coexists with the
|
||||
* terminal/badge WS upgrade listeners (each ignores non-matching paths).
|
||||
*/
|
||||
export function setupCliSessionWebSocket(
|
||||
server: HttpServer,
|
||||
options: CliSessionWebSocketOptions,
|
||||
): WebSocketServer {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const highWatermark = options.highWatermarkBytes ?? DEFAULT_HIGH_WATERMARK_BYTES;
|
||||
const lowWatermark = options.lowWatermarkBytes ?? DEFAULT_LOW_WATERMARK_BYTES;
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const pathname = new URL(req.url || "", `http://${req.headers.host}`).pathname;
|
||||
if (pathname !== CLI_SESSION_WS_PATH) return;
|
||||
|
||||
const reject = (code: number, reason: string) => {
|
||||
socket.write(`HTTP/1.1 ${code} ${reason}\r\nConnection: close\r\n\r\n`);
|
||||
socket.destroy();
|
||||
};
|
||||
|
||||
// 1. Daemon-token auth at the upgrade.
|
||||
if (options.daemonToken && !options.noAuth) {
|
||||
if (!authenticateUpgradeRequest(options.daemonToken, req)) {
|
||||
reject(401, "Unauthorized");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Origin allowlist.
|
||||
const originOk = isOriginAllowed({
|
||||
origin: headerStr(req, "origin"),
|
||||
host: headerStr(req, "host"),
|
||||
secFetchSite: headerStr(req, "sec-fetch-site"),
|
||||
extraAllowedOrigins: options.extraAllowedOrigins,
|
||||
});
|
||||
if (!originOk) {
|
||||
reject(403, "Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (upgraded) => {
|
||||
wss.emit("connection", upgraded, req);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
|
||||
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
||||
const sessionId = url.searchParams.get("sessionId");
|
||||
const ticket = url.searchParams.get("ticket");
|
||||
|
||||
if (!sessionId) {
|
||||
ws.close(4000, "Missing sessionId");
|
||||
return;
|
||||
}
|
||||
|
||||
const session = options.store.getSession(sessionId);
|
||||
if (!session) {
|
||||
ws.close(4004, "Session not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Single-use, session-scoped ticket. Consume here (after upgrade) so a
|
||||
// replayed/consumed ticket, or a ticket for a different session, is rejected.
|
||||
const consumed = options.ticketStore.consume(ticket, sessionId);
|
||||
if (!consumed) {
|
||||
ws.close(4401, "Invalid or expired attach ticket");
|
||||
return;
|
||||
}
|
||||
if (consumed.projectId !== session.projectId) {
|
||||
ws.close(4503, "Ticket does not match session project");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.manager.isLive(sessionId)) {
|
||||
ws.close(4409, "Session is not live");
|
||||
return;
|
||||
}
|
||||
|
||||
const readOnly = isReadOnlySession(session) || consumed.readOnly;
|
||||
const ticketId = consumed.ticket;
|
||||
|
||||
// Attach to the engine manager.
|
||||
let attachment;
|
||||
try {
|
||||
attachment = options.manager.attach(sessionId);
|
||||
} catch {
|
||||
ws.close(4500, "Failed to attach");
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Flow control state ──
|
||||
let unacked = 0;
|
||||
let paused = false;
|
||||
const onSentBytes = (n: number) => {
|
||||
unacked += n;
|
||||
if (!paused && unacked >= highWatermark) {
|
||||
paused = true;
|
||||
try {
|
||||
options.manager.requestPause(sessionId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
const onAck = (n: number) => {
|
||||
if (!Number.isFinite(n) || n <= 0) return;
|
||||
unacked = Math.max(0, unacked - n);
|
||||
if (paused && unacked <= lowWatermark) {
|
||||
paused = false;
|
||||
try {
|
||||
options.manager.requestResume(sessionId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── Outbound: scrollback (neutralized) then live stream (neutralized) ──
|
||||
let carry = "";
|
||||
const sendData = (bytes: Uint8Array) => {
|
||||
if (ws.readyState !== ws.OPEN) return;
|
||||
const text = Buffer.from(bytes).toString("utf8");
|
||||
const result = neutralizeTerminalOutput(text, carry);
|
||||
carry = result.carry;
|
||||
if (result.output.length === 0) return;
|
||||
const payload = Buffer.from(result.output, "utf8");
|
||||
try {
|
||||
ws.send(
|
||||
JSON.stringify({ type: "data", data: payload.toString("base64") }),
|
||||
);
|
||||
onSentBytes(payload.byteLength);
|
||||
} catch {
|
||||
/* socket closing */
|
||||
}
|
||||
};
|
||||
|
||||
// Scrollback replay (run through the same neutralizer so a hostile sequence
|
||||
// recorded in scrollback is also stripped). Sent as its own frame so the
|
||||
// client can clear before replay.
|
||||
{
|
||||
const scrollText = Buffer.from(attachment.scrollback).toString("utf8");
|
||||
const result = neutralizeTerminalOutput(scrollText, "");
|
||||
// Thread the carry across the scrollback→live seam. Do NOT flush the
|
||||
// scrollback carry verbatim: if a dangerous sequence (e.g. OSC 52) is
|
||||
// split so its introducer lands at the tail of scrollback and its
|
||||
// terminator arrives in the first live chunk, flushing the held prefix
|
||||
// here would let it recombine at the client and reconstruct the hazard.
|
||||
// Instead we hand the unterminated tail to the live `sendData` carry so
|
||||
// the neutralizer sees the full sequence and strips it. Only the safe,
|
||||
// fully-neutralized prefix is sent in the scrollback frame.
|
||||
carry = result.carry;
|
||||
try {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "scrollback",
|
||||
data: Buffer.from(result.output, "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 */
|
||||
}
|
||||
// Flush any residual carry at true stream end. The held bytes are an
|
||||
// unterminated tail; no further chunk can arrive to recombine with them,
|
||||
// so emitting them as literal is safe and avoids losing trailing output.
|
||||
if (!streamClosed && ws.readyState === ws.OPEN && carry.length > 0) {
|
||||
const tail = flushTerminalOutput(carry);
|
||||
carry = "";
|
||||
if (tail.length > 0) {
|
||||
try {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "data",
|
||||
data: Buffer.from(tail, "utf8").toString("base64"),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!streamClosed && ws.readyState === ws.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: "exit" }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Inbound frames ──
|
||||
ws.on("message", (raw: Buffer) => {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return; // ignore malformed
|
||||
}
|
||||
switch (msg.type) {
|
||||
case "input": {
|
||||
if (typeof msg.data !== "string") return;
|
||||
if (readOnly) {
|
||||
try {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
code: "READ_ONLY",
|
||||
message: "Session is read-only — input is not permitted",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
attachment.write(msg.data);
|
||||
options.attributionLog.record(sessionId, {
|
||||
ticketId,
|
||||
source: "ws",
|
||||
byteLength: Buffer.byteLength(msg.data, "utf8"),
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "resize": {
|
||||
if (typeof msg.cols === "number" && typeof msg.rows === "number") {
|
||||
// Latest-active-client policy is enforced by the manager (latest wins).
|
||||
attachment.resize(msg.cols, msg.rows);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ack": {
|
||||
if (typeof msg.bytes === "number") onAck(msg.bytes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const teardown = () => {
|
||||
if (streamClosed) return;
|
||||
streamClosed = true;
|
||||
try {
|
||||
attachment.detach(); // NEVER kills the session (other clients keep it).
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Release backpressure so a remaining client isn't stuck paused.
|
||||
if (paused) {
|
||||
try {
|
||||
options.manager.requestResume(sessionId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.on("close", teardown);
|
||||
ws.on("error", teardown);
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
function headerStr(req: IncomingMessage, name: string): string | undefined {
|
||||
const v = req.headers[name];
|
||||
if (typeof v === "string") return v;
|
||||
if (Array.isArray(v)) return v[0];
|
||||
return undefined;
|
||||
}
|
||||
@@ -66,3 +66,25 @@ export {
|
||||
} from "./badge-pubsub.js";
|
||||
|
||||
export * from "./plugins/index.js";
|
||||
|
||||
// CLI-session terminal-output hardening — re-exported so the TUI passthrough
|
||||
// (packages/cli, U14) applies the IDENTICAL neutralization set as the dashboard
|
||||
// WS bridge (U10). The host TTY honors more escape sequences than xterm.js, so
|
||||
// the TUI MUST reuse this single implementation rather than fork it.
|
||||
export {
|
||||
neutralizeTerminalOutput,
|
||||
flushTerminalOutput,
|
||||
MAX_CARRY_LENGTH,
|
||||
type NeutralizeResult,
|
||||
} from "./cli-session-output-filter.js";
|
||||
|
||||
// CLI Agent Executor transport dependencies — re-exported so the CLI boot
|
||||
// (packages/cli dashboard command) can construct the per-session attach-ticket
|
||||
// store, input-attribution log, and confirm-advance registry that the
|
||||
// cli-sessions transport routes require, then thread them into ServerOptions.
|
||||
export {
|
||||
AttachTicketStore,
|
||||
CliInputAttributionLog,
|
||||
CliConfirmAdvanceRegistry,
|
||||
type CliSessionTransportDeps,
|
||||
} from "./cli-session-transport.js";
|
||||
|
||||
@@ -173,6 +173,8 @@ import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provide
|
||||
import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js";
|
||||
import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js";
|
||||
import { registerDiagnosticsRoutes } from "./routes/register-diagnostics-routes.js";
|
||||
import { registerCliAgentHooksRoute } from "./routes/cli-agent-hooks.js";
|
||||
import { registerCliAgentSettingsRoutes } from "./routes/cli-agent-settings.js";
|
||||
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
|
||||
import { registerApprovalRoutes } from "./routes/register-approval-routes.js";
|
||||
import { registerWorktrunkRoutes } from "./routes/register-worktrunk-routes.js";
|
||||
@@ -1953,6 +1955,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
registerUsageRoutes(routeContext);
|
||||
registerUpdateCheckRoutes(routeContext);
|
||||
registerDiagnosticsRoutes(routeContext);
|
||||
// CLI Agent Executor hook ingestion (U17) — per-session token auth, exempt from
|
||||
// the daemon bearer-token middleware (hook scripts only hold the session token).
|
||||
registerCliAgentHooksRoute(routeContext);
|
||||
|
||||
// CLI Agent Executor adapter settings + autonomy approval (U15) — daemon-token
|
||||
// authed like the rest of /api (the approving principal is the token holder).
|
||||
registerCliAgentSettingsRoutes(routeContext);
|
||||
|
||||
// ── Automation / Scheduled Task Routes ────────────────────────────
|
||||
//
|
||||
|
||||
@@ -49,7 +49,7 @@ vi.mock("@fusion/core", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: vi.fn(async () => ({ session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() } })),
|
||||
createResolvedAgentSession: vi.fn(async () => ({
|
||||
session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() },
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import express from "express";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CliSessionStore, Database } from "@fusion/core";
|
||||
import { TelemetryHub } from "@fusion/engine";
|
||||
import { request as performRequest } from "../../test-request.js";
|
||||
import {
|
||||
createCliAgentHooksRouterForTest,
|
||||
HOOK_PAYLOAD_LIMIT_BYTES,
|
||||
type CliAgentHookHub,
|
||||
} from "../cli-agent-hooks.js";
|
||||
|
||||
const PATH = "/api/cli-agent/hooks";
|
||||
const TOKEN_HEADER = "x-fusion-cli-session-token";
|
||||
const SESSION_HEADER = "x-fusion-cli-session-id";
|
||||
|
||||
/** Mount the hook route on a bare express app with a JSON error handler. */
|
||||
function mount(resolver: (projectId: string | undefined, sessionId: string) => CliAgentHookHub | undefined) {
|
||||
const router = createCliAgentHooksRouterForTest(resolver);
|
||||
const app = express();
|
||||
app.use("/api", router);
|
||||
// express.json's PayloadTooLargeError surfaces here as 413.
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(err?.statusCode ?? err?.status ?? 500).json({ error: err?.message ?? String(err) });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
function post(
|
||||
app: express.Express,
|
||||
body: string,
|
||||
headers: Record<string, string> = {},
|
||||
path = PATH,
|
||||
) {
|
||||
return performRequest(app, "POST", path, body, {
|
||||
"content-type": "application/json",
|
||||
host: "127.0.0.1",
|
||||
...headers,
|
||||
});
|
||||
}
|
||||
|
||||
describe("cli-agent-hooks route (stub hub)", () => {
|
||||
function stubHub(overrides: Partial<CliAgentHookHub> = {}): CliAgentHookHub & { ingested: Array<{ sessionId: string; event: unknown }> } {
|
||||
const ingested: Array<{ sessionId: string; event: unknown }> = [];
|
||||
return {
|
||||
ingested,
|
||||
validateToken: (sessionId, token) => token === "good-token" && sessionId === "sess-1",
|
||||
ingest: (sessionId, event) => {
|
||||
ingested.push({ sessionId, event });
|
||||
return event;
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("forwards a valid token + session to the hub", async () => {
|
||||
const hub = stubHub();
|
||||
const app = mount(() => hub);
|
||||
const res = await post(app, JSON.stringify({ session_id: "native-1", hello: "world" }) , {
|
||||
[TOKEN_HEADER]: "good-token",
|
||||
[SESSION_HEADER]: "sess-1",
|
||||
}, `${PATH}?event=Stop`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
expect(hub.ingested).toHaveLength(1);
|
||||
expect(hub.ingested[0].sessionId).toBe("sess-1");
|
||||
expect(hub.ingested[0].event).toMatchObject({
|
||||
kind: "done",
|
||||
payload: { nativeSessionId: "native-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a missing token with 401", async () => {
|
||||
const hub = stubHub();
|
||||
const app = mount(() => hub);
|
||||
const res = await post(app, "{}", { [SESSION_HEADER]: "sess-1" });
|
||||
expect(res.status).toBe(401);
|
||||
expect(hub.ingested).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a wrong token with 401", async () => {
|
||||
const hub = stubHub();
|
||||
const app = mount(() => hub);
|
||||
const res = await post(app, "{}", {
|
||||
[TOKEN_HEADER]: "wrong-token",
|
||||
[SESSION_HEADER]: "sess-1",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(hub.ingested).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a valid-format token issued for the WRONG session", async () => {
|
||||
const hub = stubHub();
|
||||
const app = mount(() => hub);
|
||||
// good-token only validates for sess-1; present it for sess-2.
|
||||
const res = await post(app, "{}", {
|
||||
[TOKEN_HEADER]: "good-token",
|
||||
[SESSION_HEADER]: "sess-2",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(hub.ingested).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a request carrying a browser Origin header (CSRF)", async () => {
|
||||
const hub = stubHub();
|
||||
const app = mount(() => hub);
|
||||
const res = await post(app, "{}", {
|
||||
[TOKEN_HEADER]: "good-token",
|
||||
[SESSION_HEADER]: "sess-1",
|
||||
origin: "http://evil.example.com",
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(hub.ingested).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a cross-site Host header", async () => {
|
||||
const hub = stubHub();
|
||||
const app = mount(() => hub);
|
||||
const res = await post(app, "{}", {
|
||||
[TOKEN_HEADER]: "good-token",
|
||||
[SESSION_HEADER]: "sess-1",
|
||||
host: "evil.example.com",
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(hub.ingested).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts loopback Host with a port", async () => {
|
||||
const hub = stubHub();
|
||||
const app = mount(() => hub);
|
||||
const res = await post(app, "{}", {
|
||||
[TOKEN_HEADER]: "good-token",
|
||||
[SESSION_HEADER]: "sess-1",
|
||||
host: "127.0.0.1:4040",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects an oversized payload", async () => {
|
||||
const hub = stubHub();
|
||||
const app = mount(() => hub);
|
||||
const big = JSON.stringify({ blob: "x".repeat(HOOK_PAYLOAD_LIMIT_BYTES + 1024) });
|
||||
const res = await post(app, big, {
|
||||
[TOKEN_HEADER]: "good-token",
|
||||
[SESSION_HEADER]: "sess-1",
|
||||
});
|
||||
expect(res.status).toBe(413);
|
||||
expect(hub.ingested).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("treats an unknown session as a no-op (200), never a crash, when the hub accepts it", async () => {
|
||||
// A hub that validates any token but whose ingest is a no-op for unknown
|
||||
// sessions (the real hub's contract). The route returns 200 and never throws.
|
||||
const hub: CliAgentHookHub = {
|
||||
validateToken: () => true,
|
||||
ingest: () => undefined, // unknown session → no-op (returns undefined)
|
||||
};
|
||||
const app = mount(() => hub);
|
||||
const res = await post(app, "{}", {
|
||||
[TOKEN_HEADER]: "any",
|
||||
[SESSION_HEADER]: "ghost",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 401 when no hub is resolvable for the session", async () => {
|
||||
const app = mount(() => undefined);
|
||||
const res = await post(app, "{}", {
|
||||
[TOKEN_HEADER]: "good-token",
|
||||
[SESSION_HEADER]: "sess-1",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 200 even when hub.ingest throws (best-effort telemetry)", async () => {
|
||||
const hub: CliAgentHookHub = {
|
||||
validateToken: () => true,
|
||||
ingest: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
const app = mount(() => hub);
|
||||
const res = await post(app, "{}", {
|
||||
[TOKEN_HEADER]: "good-token",
|
||||
[SESSION_HEADER]: "sess-1",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cli-agent-hooks route (real TelemetryHub lifecycle)", () => {
|
||||
let tmpDir: string;
|
||||
let db: Database;
|
||||
let store: CliSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "fusion-hook-route-"));
|
||||
const fusionDir = join(tmpDir, ".fusion");
|
||||
db = new Database(fusionDir, { inMemory: true });
|
||||
db.init();
|
||||
store = new CliSessionStore(fusionDir, db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function seed(agentState = "busy"): string {
|
||||
return store.createSession({
|
||||
purpose: "execute",
|
||||
projectId: "proj",
|
||||
adapterId: "claude-code",
|
||||
agentState: agentState as never,
|
||||
}).id;
|
||||
}
|
||||
|
||||
it("end-to-end: valid token forwards and advances state; lifecycle revokes it", async () => {
|
||||
const sessionId = seed("busy");
|
||||
const hub = new TelemetryHub({ store });
|
||||
const token = hub.issueToken(sessionId);
|
||||
|
||||
const app = mount((_proj, sid) => (hub.hasSession(sid) ? (hub as unknown as CliAgentHookHub) : undefined));
|
||||
|
||||
// Valid POST → 200, state machine advances to done.
|
||||
const ok = await post(app, "{}", {
|
||||
[TOKEN_HEADER]: token,
|
||||
[SESSION_HEADER]: sessionId,
|
||||
}, `${PATH}?event=Stop`);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(hub.getStateMachine(sessionId)?.getState()).toBe("done");
|
||||
|
||||
// Lifecycle: session end invalidates the token.
|
||||
hub.invalidate(sessionId);
|
||||
|
||||
// Replayed POST with the old token → 401 (no hub / not validating).
|
||||
const replay = await post(app, "{}", {
|
||||
[TOKEN_HEADER]: token,
|
||||
[SESSION_HEADER]: sessionId,
|
||||
}, `${PATH}?event=Stop`);
|
||||
expect(replay.status).toBe(401);
|
||||
});
|
||||
|
||||
it("after registry rebuild from non-live sessions, old tokens are rejected", async () => {
|
||||
const sessionId = seed("busy");
|
||||
const hub1 = new TelemetryHub({ store });
|
||||
const oldToken = hub1.issueToken(sessionId);
|
||||
|
||||
// Simulate engine death mid-session: the session is no longer live.
|
||||
store.updateSession(sessionId, { agentState: "dead" as never });
|
||||
|
||||
// New hub rebuilt from the store mints NO token for the non-live session.
|
||||
const hub2 = new TelemetryHub({ store });
|
||||
expect(hub2.hasSession(sessionId)).toBe(false);
|
||||
|
||||
const app = mount((_proj, sid) => (hub2.hasSession(sid) ? (hub2 as unknown as CliAgentHookHub) : undefined));
|
||||
const res = await post(app, "{}", {
|
||||
[TOKEN_HEADER]: oldToken,
|
||||
[SESSION_HEADER]: sessionId,
|
||||
}, `${PATH}?event=Stop`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import express from "express";
|
||||
import { Router } from "express";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { request as performRequest } from "../../test-request.js";
|
||||
import { rethrowAsApiError } from "../../api-error.js";
|
||||
import { registerCliAgentSettingsRoutes } from "../cli-agent-settings.js";
|
||||
import type { ApiRoutesContext } from "../types.js";
|
||||
|
||||
/**
|
||||
* Minimal fake TaskStore covering the methods the route touches. Global settings
|
||||
* (`cliAgents`) and project autonomy approvals live in-memory; `getSettings`
|
||||
* returns the merged view (global ∪ project) the route reads from.
|
||||
*/
|
||||
function makeFakeStore() {
|
||||
const state = {
|
||||
cliAgents: {} as Record<string, unknown>,
|
||||
approvedCliAutonomyAdapters: [] as string[],
|
||||
};
|
||||
return {
|
||||
state,
|
||||
async getSettings() {
|
||||
return {
|
||||
cliAgents: state.cliAgents,
|
||||
approvedCliAutonomyAdapters: [...state.approvedCliAutonomyAdapters],
|
||||
};
|
||||
},
|
||||
async updateGlobalSettings(patch: { cliAgents?: Record<string, unknown> }) {
|
||||
if (patch.cliAgents) state.cliAgents = patch.cliAgents;
|
||||
return state;
|
||||
},
|
||||
async isCliAutonomyApproved(adapterId: string) {
|
||||
return state.approvedCliAutonomyAdapters.includes(adapterId);
|
||||
},
|
||||
async approveCliAutonomy(adapterId: string) {
|
||||
if (!state.approvedCliAutonomyAdapters.includes(adapterId)) {
|
||||
state.approvedCliAutonomyAdapters.push(adapterId);
|
||||
}
|
||||
},
|
||||
async revokeCliAutonomy(adapterId: string) {
|
||||
state.approvedCliAutonomyAdapters = state.approvedCliAutonomyAdapters.filter(
|
||||
(a) => a !== adapterId,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mount(store: ReturnType<typeof makeFakeStore>) {
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
const ctx = {
|
||||
router,
|
||||
rethrowAsApiError,
|
||||
getScopedStore: async () => store as never,
|
||||
getProjectContext: async () => ({ store: store as never, engine: undefined, projectId: "p1" }),
|
||||
} as unknown as ApiRoutesContext;
|
||||
registerCliAgentSettingsRoutes(ctx);
|
||||
|
||||
const app = express();
|
||||
app.use("/api", router);
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(err?.statusCode ?? err?.status ?? 500).json({ error: err?.message ?? String(err) });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
const JSON_HEADERS = { "content-type": "application/json", host: "127.0.0.1" };
|
||||
|
||||
describe("cli-agent-settings routes (U15)", () => {
|
||||
let store: ReturnType<typeof makeFakeStore>;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
store = makeFakeStore();
|
||||
app = mount(store);
|
||||
});
|
||||
|
||||
afterEach(() => {});
|
||||
|
||||
it("GET /api/cli-agents lists adapter descriptors with tier labels", async () => {
|
||||
const res = await performRequest(app, "GET", "/api/cli-agents", undefined, JSON_HEADERS);
|
||||
expect(res.status).toBe(200);
|
||||
const ids = res.body.adapters.map((a: { id: string }) => a.id);
|
||||
expect(ids).toContain("claude-code");
|
||||
expect(ids).toContain("generic");
|
||||
const claude = res.body.adapters.find((a: { id: string }) => a.id === "claude-code");
|
||||
expect(claude.tier).toBe("native");
|
||||
const generic = res.body.adapters.find((a: { id: string }) => a.id === "generic");
|
||||
expect(generic.tier).toBe("generic");
|
||||
});
|
||||
|
||||
it("PUT /api/cli-agents/settings persists a sanitized adapter config", async () => {
|
||||
const res = await performRequest(
|
||||
app,
|
||||
"PUT",
|
||||
"/api/cli-agents/settings",
|
||||
JSON.stringify({
|
||||
adapterId: "codex",
|
||||
config: { extraArgs: ["--model=gpt"], autonomyMode: "garbage", bogus: 1 },
|
||||
}),
|
||||
JSON_HEADERS,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// autonomyMode "garbage" + bogus field dropped at the core write boundary.
|
||||
expect(res.body.cliAgents).toEqual({ codex: { extraArgs: ["--model=gpt"] } });
|
||||
expect(store.state.cliAgents).toEqual({ codex: { extraArgs: ["--model=gpt"] } });
|
||||
});
|
||||
|
||||
it("PUT rejects an unknown adapter id", async () => {
|
||||
const res = await performRequest(
|
||||
app,
|
||||
"PUT",
|
||||
"/api/cli-agents/settings",
|
||||
JSON.stringify({ adapterId: "evil", config: {} }),
|
||||
JSON_HEADERS,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("autonomy approval round-trip (approve requires confirm)", async () => {
|
||||
// Initially unapproved.
|
||||
let res = await performRequest(app, "GET", "/api/cli-agents/claude-code/autonomy", undefined, JSON_HEADERS);
|
||||
expect(res.body).toEqual({ adapterId: "claude-code", approved: false });
|
||||
|
||||
// Approve without confirm → rejected.
|
||||
res = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/cli-agents/claude-code/approve-autonomy",
|
||||
JSON.stringify({}),
|
||||
JSON_HEADERS,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
// Approve with confirm → granted.
|
||||
res = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/cli-agents/claude-code/approve-autonomy",
|
||||
JSON.stringify({ confirm: true }),
|
||||
JSON_HEADERS,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.state.approvedCliAutonomyAdapters).toContain("claude-code");
|
||||
|
||||
// Now reads as approved.
|
||||
res = await performRequest(app, "GET", "/api/cli-agents/claude-code/autonomy", undefined, JSON_HEADERS);
|
||||
expect(res.body.approved).toBe(true);
|
||||
|
||||
// Revoke.
|
||||
res = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/cli-agents/claude-code/revoke-autonomy",
|
||||
JSON.stringify({}),
|
||||
JSON_HEADERS,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.state.approvedCliAutonomyAdapters).not.toContain("claude-code");
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ vi.mock("@fusion/core", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: vi.fn(async () => ({ session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() } })),
|
||||
createResolvedAgentSession: vi.fn(async () => ({
|
||||
session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() },
|
||||
|
||||
@@ -34,7 +34,7 @@ vi.mock("@fusion/core", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => []),
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: vi.fn(async () => ({ session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() } })),
|
||||
createResolvedAgentSession: vi.fn(async () => ({
|
||||
session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() },
|
||||
|
||||
231
packages/dashboard/src/routes/cli-agent-hooks.ts
Normal file
231
packages/dashboard/src/routes/cli-agent-hooks.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* CLI-agent hook ingestion route (CLI Agent Executor, U17).
|
||||
*
|
||||
* A localhost POST endpoint that authenticates per-session hook POSTs from a
|
||||
* spawned CLI agent (Claude Code, Codex, Droid, …) and forwards the validated,
|
||||
* parsed payload IN-PROCESS to the engine-held telemetry hub. The engine has no
|
||||
* HTTP server — only the dashboard serves HTTP (the Orca pattern, adapted).
|
||||
*
|
||||
* Security posture (KTD — hook-endpoint security; localhost is NOT a trust
|
||||
* boundary: any local process or browser page can reach 127.0.0.1):
|
||||
*
|
||||
* 1. Per-session token, constant-time. The request must carry the high-entropy
|
||||
* per-session hook token AND the session id; the route validates that the
|
||||
* token was issued for exactly that session against the engine-held registry
|
||||
* (`hub.validateToken`). A session id alone is NEVER sufficient, and a valid
|
||||
* token for session B never validates for session A. Comparison is
|
||||
* constant-time inside the hub registry lookup; the header presence check here
|
||||
* avoids leaking timing on the cheap path only.
|
||||
*
|
||||
* 2. Origin / Host CSRF defense. A browser page on any origin can POST to
|
||||
* 127.0.0.1, so a forged `Stop`/completion could otherwise advance incomplete
|
||||
* work or suppress the stall detector. We REJECT any request carrying a
|
||||
* browser `Origin` header, and any request whose `Host` is not a loopback
|
||||
* host. Hook scripts are plain `curl` (no Origin); browsers always attach one
|
||||
* on cross-origin fetch — so this cleanly separates the two.
|
||||
*
|
||||
* 3. Payload cap. Oversized bodies are rejected (413) — both at parse time (a
|
||||
* route-scoped `express.json` limit) and defensively via `Content-Length`.
|
||||
*
|
||||
* 4. No daemon bearer token. Hook scripts only hold the per-session token, so
|
||||
* this path is EXEMPT from the daemon-token middleware (see auth-middleware
|
||||
* `EXEMPT_PATHS`). It is not unauthenticated — it authenticates with the
|
||||
* per-session token instead.
|
||||
*
|
||||
* 5. Never crash. An unknown / non-live session key is a 200 no-op (the hub's
|
||||
* `ingest` is itself a no-op for unknown sessions); malformed JSON is a 400;
|
||||
* nothing here throws into the agent's hook chain.
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import express from "express";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
/** Max accepted hook payload size. Hook payloads are small JSON envelopes. */
|
||||
export const HOOK_PAYLOAD_LIMIT_BYTES = 256 * 1024;
|
||||
|
||||
/** Header carrying the per-session hook token (matches the engine hook scripts). */
|
||||
const TOKEN_HEADER = "x-fusion-cli-session-token";
|
||||
/** Header carrying the session id the token must validate for. */
|
||||
const SESSION_HEADER = "x-fusion-cli-session-id";
|
||||
|
||||
/** The minimal hub surface the route depends on (validate + ingest). */
|
||||
export interface CliAgentHookHub {
|
||||
validateToken(sessionId: string, token: string | null | undefined): boolean;
|
||||
ingest(sessionId: string, event: unknown): unknown;
|
||||
}
|
||||
|
||||
/** Loopback hosts the route accepts. Anything else is treated as cross-site. */
|
||||
function isLoopbackHost(host: string | undefined): boolean {
|
||||
if (!host) return false;
|
||||
// Strip a :port suffix (but keep IPv6 brackets intact for the comparison).
|
||||
const bare = host.replace(/:\d+$/, "").toLowerCase();
|
||||
return (
|
||||
bare === "127.0.0.1" ||
|
||||
bare === "localhost" ||
|
||||
bare === "[::1]" ||
|
||||
bare === "::1" ||
|
||||
bare === "0.0.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
/** First value of a (possibly array) header, trimmed. */
|
||||
function headerValue(req: Request, name: string): string | undefined {
|
||||
const raw = req.headers[name];
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a host CLI hook event name (from the `?event=` query param the scripts add)
|
||||
* onto a normalized telemetry event kind. Unknown / absent events fall back to a
|
||||
* generic activity signal, so an unrecognized hook never advances state on its own
|
||||
* (positive completion gating lives in the state machine, not here).
|
||||
*/
|
||||
function normalizeHookEvent(eventName: string | undefined, body: Record<string, unknown>) {
|
||||
const name = (eventName ?? "").toLowerCase();
|
||||
// Carry the native session id whenever the payload reports one (Claude:
|
||||
// `session_id` in every payload) so the hub can persist it.
|
||||
const nativeSessionId =
|
||||
typeof body.session_id === "string"
|
||||
? body.session_id
|
||||
: typeof body.sessionId === "string"
|
||||
? body.sessionId
|
||||
: undefined;
|
||||
|
||||
const basePayload: Record<string, unknown> = {};
|
||||
if (nativeSessionId) basePayload.nativeSessionId = nativeSessionId;
|
||||
|
||||
switch (name) {
|
||||
case "sessionstart":
|
||||
return { kind: "sessionStart" as const, payload: basePayload };
|
||||
case "stop":
|
||||
case "subagentstop":
|
||||
return { kind: "done" as const, payload: basePayload };
|
||||
case "notification":
|
||||
case "permissionrequest":
|
||||
case "notify":
|
||||
return {
|
||||
kind: "waitingOnInput" as const,
|
||||
payload: { ...basePayload, notification: body },
|
||||
};
|
||||
case "pretooluse":
|
||||
case "posttooluse":
|
||||
return { kind: "toolActivity" as const, payload: basePayload };
|
||||
case "userpromptsubmit":
|
||||
return { kind: "busy" as const, payload: basePayload };
|
||||
default:
|
||||
// Unknown / absent event → activity only (re-arms watchdog, never advances).
|
||||
return { kind: "outputProgress" as const, payload: basePayload };
|
||||
}
|
||||
}
|
||||
|
||||
export const registerCliAgentHooksRoute: ApiRouteRegistrar = (ctx) => {
|
||||
const { router } = ctx;
|
||||
const logger = ctx.runtimeLogger.child("cli-agent-hooks");
|
||||
|
||||
// Route-scoped JSON parser with a hard size cap. An oversized body is rejected
|
||||
// at parse time (express throws a 413 PayloadTooLargeError, surfaced by the
|
||||
// error handler) before any handler logic runs.
|
||||
const parseHookBody = express.json({ limit: HOOK_PAYLOAD_LIMIT_BYTES });
|
||||
|
||||
const handler = (req: Request, res: Response): void => {
|
||||
// ── 1. CSRF defense: reject browser-context requests ──────────────────────
|
||||
// Any request carrying an Origin header came from a browser fetch — a hook
|
||||
// script never sets one. Reject outright (localhost is not a trust boundary).
|
||||
if (headerValue(req, "origin") !== undefined) {
|
||||
res.status(403).json({ error: "Origin not allowed" });
|
||||
return;
|
||||
}
|
||||
// Host must be a loopback host. A cross-site Host (DNS-rebinding style) is
|
||||
// rejected even absent an Origin header.
|
||||
if (!isLoopbackHost(headerValue(req, "host"))) {
|
||||
res.status(403).json({ error: "Host not allowed" });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 2. Defensive payload cap on Content-Length ────────────────────────────
|
||||
const contentLength = Number(req.headers["content-length"] ?? 0);
|
||||
if (Number.isFinite(contentLength) && contentLength > HOOK_PAYLOAD_LIMIT_BYTES) {
|
||||
res.status(413).json({ error: "Payload too large" });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 3. Identify session + token ───────────────────────────────────────────
|
||||
const sessionId = headerValue(req, SESSION_HEADER);
|
||||
const token = headerValue(req, TOKEN_HEADER);
|
||||
if (!sessionId || !token) {
|
||||
// Missing credentials — never a no-op (a no-op is reserved for a *valid*
|
||||
// request against an unknown session). No token == not authenticated.
|
||||
res.status(401).json({ error: "Missing session token" });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 4. Resolve the engine-held hub for this session ───────────────────────
|
||||
const projectId = ctx.getProjectIdFromRequest(req);
|
||||
const resolver = ctx.options?.cliAgentHubResolver;
|
||||
const hub = resolver?.(projectId, sessionId) as CliAgentHookHub | undefined;
|
||||
|
||||
// No hub at all (e.g. engine not wired / no live sessions). A forged token
|
||||
// cannot validate; treat as unauthorized rather than no-op so a wrong token
|
||||
// is never silently accepted.
|
||||
if (!hub) {
|
||||
res.status(401).json({ error: "Invalid session token" });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 5. Validate the per-session token (token-belongs-to-session) ──────────
|
||||
// The hub validates that this exact token was issued for THIS session —
|
||||
// session id alone is never sufficient, and a valid token for another session
|
||||
// is rejected. Missing/wrong/expired/invalidated tokens all fail here.
|
||||
if (!hub.validateToken(sessionId, token)) {
|
||||
res.status(401).json({ error: "Invalid session token" });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 6. Forward the validated payload in-process to the hub ────────────────
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const eventName = typeof req.query.event === "string" ? req.query.event : undefined;
|
||||
const event = normalizeHookEvent(eventName, body);
|
||||
|
||||
try {
|
||||
// ingest is itself a no-op for unknown/non-live sessions — never crashes.
|
||||
hub.ingest(sessionId, event);
|
||||
} catch (error) {
|
||||
// Telemetry ingestion is best-effort. Log and still return 200 so the
|
||||
// agent's hook chain is never disturbed by an engine-side hiccup.
|
||||
logger.warn("hook ingest failed", {
|
||||
sessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({ ok: true });
|
||||
};
|
||||
|
||||
// POST only. The route does its own auth (per-session token) and is exempt
|
||||
// from the daemon bearer-token middleware (see auth-middleware EXEMPT_PATHS).
|
||||
router.post("/cli-agent/hooks", parseHookBody, handler);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a standalone Express router carrying just the hook route — used by the
|
||||
* route test to mount the handler without the full server. Mirrors the
|
||||
* production registration (`registerCliAgentHooksRoute`).
|
||||
*/
|
||||
export function createCliAgentHooksRouterForTest(
|
||||
resolver: (projectId: string | undefined, sessionId: string) => CliAgentHookHub | undefined,
|
||||
logger: { warn: (msg: string, ctx?: unknown) => void } = { warn: () => {} },
|
||||
): Router {
|
||||
const router = Router();
|
||||
registerCliAgentHooksRoute({
|
||||
router,
|
||||
options: { cliAgentHubResolver: resolver as never },
|
||||
getProjectIdFromRequest: (req: Request) =>
|
||||
typeof req.query.projectId === "string" ? req.query.projectId : undefined,
|
||||
runtimeLogger: { child: () => logger } as never,
|
||||
} as never);
|
||||
return router;
|
||||
}
|
||||
131
packages/dashboard/src/routes/cli-agent-settings.ts
Normal file
131
packages/dashboard/src/routes/cli-agent-settings.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* CLI-agent adapter settings + autonomy-approval routes (CLI Agent Executor, U15).
|
||||
*
|
||||
* All routes are daemon-token authed (the standard `/api` middleware — no new
|
||||
* auth surface; the approving principal in v1 is the daemon-token holder, the
|
||||
* single workspace owner). Routes:
|
||||
*
|
||||
* GET /api/cli-agents — adapter descriptors (tier +
|
||||
* capability flags) for the
|
||||
* settings UI + node editor.
|
||||
* GET /api/cli-agents/settings — per-adapter launch config
|
||||
* (GlobalSettings.cliAgents).
|
||||
* PUT /api/cli-agents/settings — replace one adapter's launch
|
||||
* config (validated at the core
|
||||
* write boundary).
|
||||
* GET /api/cli-agents/:adapterId/autonomy — approval state for the project.
|
||||
* POST /api/cli-agents/:adapterId/approve-autonomy — approve elevated autonomy
|
||||
* for the adapter in this
|
||||
* project (idempotent).
|
||||
* POST /api/cli-agents/:adapterId/revoke-autonomy — revoke approval.
|
||||
*
|
||||
* The approval is per-PROJECT + per-adapter, stored in project settings
|
||||
* (`approvedCliAutonomyAdapters`) and mirrors the raw workflow-CLI-command
|
||||
* approval precedent (`approveWorkflowCliCommand`).
|
||||
*/
|
||||
|
||||
import { listCliAdapterDescriptors } from "@fusion/engine";
|
||||
import { sanitizeCliAgentSettings, CLI_AGENT_ADAPTER_IDS } from "@fusion/core";
|
||||
import { ApiError, badRequest } from "../api-error.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
/** Static adapter descriptor list (tier + capability flags). Stable per build. */
|
||||
const ADAPTER_DESCRIPTORS = listCliAdapterDescriptors();
|
||||
const KNOWN_ADAPTER_IDS = new Set<string>(CLI_AGENT_ADAPTER_IDS);
|
||||
|
||||
export function registerCliAgentSettingsRoutes(ctx: ApiRoutesContext): void {
|
||||
const { router, rethrowAsApiError } = ctx;
|
||||
|
||||
// GET /api/cli-agents — adapter catalog (tier labels + capability flags).
|
||||
router.get("/cli-agents", async (_req, res) => {
|
||||
res.json({ adapters: ADAPTER_DESCRIPTORS });
|
||||
});
|
||||
|
||||
// GET /api/cli-agents/settings — the per-adapter launch config map.
|
||||
router.get("/cli-agents/settings", async (req, res) => {
|
||||
try {
|
||||
const store = await ctx.getScopedStore(req);
|
||||
const settings = await store.getSettings();
|
||||
res.json({ cliAgents: (settings as { cliAgents?: unknown }).cliAgents ?? {} });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/cli-agents/settings — replace ONE adapter's launch config. The body
|
||||
// is `{ adapterId, config }`; an empty/invalid config clears the entry. The
|
||||
// core write boundary sanitizes (`sanitizeCliAgentsSettings`) so invalid fields
|
||||
// are dropped regardless — this route just scopes the merge to one adapter.
|
||||
router.put("/cli-agents/settings", async (req, res) => {
|
||||
try {
|
||||
const store = await ctx.getScopedStore(req);
|
||||
const adapterId = String((req.body as { adapterId?: unknown })?.adapterId ?? "").trim();
|
||||
if (!adapterId || !KNOWN_ADAPTER_IDS.has(adapterId)) {
|
||||
throw badRequest("Unknown or missing adapterId");
|
||||
}
|
||||
const rawConfig = (req.body as { config?: unknown })?.config;
|
||||
const sanitized = sanitizeCliAgentSettings(rawConfig);
|
||||
|
||||
const settings = await store.getSettings();
|
||||
const prior = { ...(((settings as { cliAgents?: Record<string, unknown> }).cliAgents) ?? {}) };
|
||||
if (sanitized) {
|
||||
prior[adapterId] = sanitized;
|
||||
} else {
|
||||
delete prior[adapterId];
|
||||
}
|
||||
await store.updateGlobalSettings({ cliAgents: prior } as never);
|
||||
res.json({ cliAgents: prior });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/cli-agents/:adapterId/autonomy — approval state for this project.
|
||||
router.get("/cli-agents/:adapterId/autonomy", async (req, res) => {
|
||||
try {
|
||||
const { store } = await ctx.getProjectContext(req);
|
||||
const adapterId = req.params.adapterId;
|
||||
if (!KNOWN_ADAPTER_IDS.has(adapterId)) throw badRequest("Unknown adapterId");
|
||||
const approved = await store.isCliAutonomyApproved(adapterId);
|
||||
res.json({ adapterId, approved });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cli-agents/:adapterId/approve-autonomy — grant elevated autonomy
|
||||
// for the adapter in this project. Idempotent. Requires an explicit confirm
|
||||
// flag in the body so a stray POST cannot grant elevation by accident.
|
||||
router.post("/cli-agents/:adapterId/approve-autonomy", async (req, res) => {
|
||||
try {
|
||||
const { store } = await ctx.getProjectContext(req);
|
||||
const adapterId = req.params.adapterId;
|
||||
if (!KNOWN_ADAPTER_IDS.has(adapterId)) throw badRequest("Unknown adapterId");
|
||||
if ((req.body as { confirm?: unknown })?.confirm !== true) {
|
||||
throw badRequest("Elevated autonomy approval requires explicit confirmation (confirm: true)");
|
||||
}
|
||||
await store.approveCliAutonomy(adapterId);
|
||||
res.json({ adapterId, approved: true });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cli-agents/:adapterId/revoke-autonomy — revoke approval. Idempotent.
|
||||
router.post("/cli-agents/:adapterId/revoke-autonomy", async (req, res) => {
|
||||
try {
|
||||
const { store } = await ctx.getProjectContext(req);
|
||||
const adapterId = req.params.adapterId;
|
||||
if (!KNOWN_ADAPTER_IDS.has(adapterId)) throw badRequest("Unknown adapterId");
|
||||
await store.revokeCliAutonomy(adapterId);
|
||||
res.json({ adapterId, approved: false });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
162
packages/dashboard/src/routes/cli-sessions.ts
Normal file
162
packages/dashboard/src/routes/cli-sessions.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* cli-sessions routes — authenticated REST surface for CLI agent sessions
|
||||
* (CLI Agent Executor, U10).
|
||||
*
|
||||
* Daemon-token gated like every other /api route (the caller mounts this router
|
||||
* behind the auth middleware). Routes:
|
||||
* - GET /api/cli-sessions list (by project/task/chat)
|
||||
* - GET /api/cli-sessions/:id one session record
|
||||
* - POST /api/cli-sessions/:id/attach-ticket mint a short-lived single-use
|
||||
* session-scoped attach ticket
|
||||
* - POST /api/cli-sessions/:id/inject inject text onto the session FIFO
|
||||
* - POST /api/cli-sessions/:id/confirm-advance generic-tier R20 affordance
|
||||
*
|
||||
* Attach tickets (KTD — attach auth): the long-lived daemon token never
|
||||
* authorizes PTY write access by itself. A surface mints a ticket here (gated by
|
||||
* the daemon token), then presents it on the WS upgrade (which re-checks the
|
||||
* token AND an Origin allowlist). Tickets are single-use, ~60s TTL, and bound to
|
||||
* their session id.
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { badRequest, notFound, ApiError, catchHandler } from "../api-error.js";
|
||||
import {
|
||||
type AttachTicketStore,
|
||||
type CliInputAttributionLog,
|
||||
type CliConfirmAdvanceRegistry,
|
||||
type CliSessionTransportDeps,
|
||||
isReadOnlySession,
|
||||
} from "../cli-session-transport.js";
|
||||
|
||||
export interface CliSessionRoutesOptions extends CliSessionTransportDeps {
|
||||
ticketStore: AttachTicketStore;
|
||||
attributionLog: CliInputAttributionLog;
|
||||
confirmAdvance: CliConfirmAdvanceRegistry;
|
||||
/** Max inject body length (chars). Bounds a hostile body. */
|
||||
maxInjectChars?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_INJECT_CHARS = 64 * 1024;
|
||||
|
||||
/** Coerce a route param (may be string | string[]) to a single string. */
|
||||
function paramId(value: unknown): string {
|
||||
return Array.isArray(value) ? String(value[0]) : String(value);
|
||||
}
|
||||
|
||||
/** Optional project scoping: when a `projectId` is provided it must match. */
|
||||
function assertProjectScope(sessionProjectId: string, requested: unknown): void {
|
||||
if (requested === undefined || requested === null || requested === "") return;
|
||||
if (typeof requested !== "string" || requested !== sessionProjectId) {
|
||||
// Cross-project access is a hard rejection (the session id is not enough).
|
||||
throw new ApiError(403, "Session does not belong to this project");
|
||||
}
|
||||
}
|
||||
|
||||
export function createCliSessionsRouter(options: CliSessionRoutesOptions): Router {
|
||||
const { manager, store, ticketStore, attributionLog, confirmAdvance } = options;
|
||||
const maxInjectChars = options.maxInjectChars ?? DEFAULT_MAX_INJECT_CHARS;
|
||||
const router = Router();
|
||||
|
||||
// ── List ────────────────────────────────────────────────────────────────
|
||||
router.get(
|
||||
"/",
|
||||
catchHandler(async (req: Request, res: Response) => {
|
||||
const { projectId, taskId, chatSessionId } = req.query;
|
||||
const sessions = store.listSessions({
|
||||
projectId: typeof projectId === "string" ? projectId : undefined,
|
||||
taskId: typeof taskId === "string" ? taskId : undefined,
|
||||
chatSessionId: typeof chatSessionId === "string" ? chatSessionId : undefined,
|
||||
});
|
||||
res.json({ sessions });
|
||||
}),
|
||||
);
|
||||
|
||||
// ── One ─────────────────────────────────────────────────────────────────
|
||||
router.get(
|
||||
"/:id",
|
||||
catchHandler(async (req: Request, res: Response) => {
|
||||
const session = store.getSession(paramId(req.params.id));
|
||||
if (!session) throw notFound("Session not found");
|
||||
assertProjectScope(session.projectId, req.query.projectId);
|
||||
res.json({ session });
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Attach ticket ─────────────────────────────────────────────────────────
|
||||
router.post(
|
||||
"/:id/attach-ticket",
|
||||
catchHandler(async (req: Request, res: Response) => {
|
||||
const session = store.getSession(paramId(req.params.id));
|
||||
if (!session) throw notFound("Session not found");
|
||||
assertProjectScope(session.projectId, req.body?.projectId ?? req.query.projectId);
|
||||
const readOnly = isReadOnlySession(session);
|
||||
const { ticket, expiresAt } = ticketStore.mint({
|
||||
sessionId: session.id,
|
||||
projectId: session.projectId,
|
||||
readOnly,
|
||||
});
|
||||
res.json({
|
||||
ticket,
|
||||
expiresAt: new Date(expiresAt).toISOString(),
|
||||
readOnly,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Inject ────────────────────────────────────────────────────────────────
|
||||
router.post(
|
||||
"/:id/inject",
|
||||
catchHandler(async (req: Request, res: Response) => {
|
||||
const session = store.getSession(paramId(req.params.id));
|
||||
if (!session) throw notFound("Session not found");
|
||||
assertProjectScope(session.projectId, req.body?.projectId ?? req.query.projectId);
|
||||
|
||||
const text = req.body?.text;
|
||||
if (typeof text !== "string" || text.length === 0) {
|
||||
throw badRequest("Missing or empty `text`");
|
||||
}
|
||||
if (text.length > maxInjectChars) {
|
||||
throw badRequest(`\`text\` exceeds max length (${maxInjectChars})`);
|
||||
}
|
||||
// Server-side read-only enforcement (not just client).
|
||||
if (isReadOnlySession(session)) {
|
||||
throw new ApiError(403, "Session is read-only — input is not permitted");
|
||||
}
|
||||
if (!manager.isLive(session.id)) {
|
||||
throw new ApiError(409, "Session is not live");
|
||||
}
|
||||
|
||||
await manager.inject(session.id, text);
|
||||
|
||||
// Attribution: record the input source on the session's input log. The
|
||||
// REST inject path has no attach ticket; attribute it to the daemon token.
|
||||
attributionLog.record(session.id, {
|
||||
ticketId: "rest:inject",
|
||||
source: "inject",
|
||||
byteLength: Buffer.byteLength(text, "utf8"),
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Confirm-advance (generic-tier R20 affordance) ──────────────────────────
|
||||
router.post(
|
||||
"/:id/confirm-advance",
|
||||
catchHandler(async (req: Request, res: Response) => {
|
||||
const session = store.getSession(paramId(req.params.id));
|
||||
if (!session) throw notFound("Session not found");
|
||||
assertProjectScope(session.projectId, req.body?.projectId ?? req.query.projectId);
|
||||
|
||||
const decisionRaw = req.body?.decision ?? "advance";
|
||||
if (decisionRaw !== "advance" && decisionRaw !== "not-yet") {
|
||||
throw badRequest("`decision` must be 'advance' or 'not-yet'");
|
||||
}
|
||||
confirmAdvance.record(session.id, session.projectId, decisionRaw);
|
||||
res.json({ ok: true, decision: decisionRaw });
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -56,9 +56,12 @@ import {
|
||||
rehydrateFromStore as rehydrateMilestoneSliceSessions,
|
||||
} from "./milestone-slice-interview.js";
|
||||
import { ChatManager } from "./chat.js";
|
||||
import { CliChatSessionRunner } from "./cli-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 {
|
||||
@@ -194,6 +197,18 @@ export interface ServerOptions {
|
||||
engineManager?: import("@fusion/engine").ProjectEngineManager;
|
||||
/** Optional HybridExecutor orchestration context for multi-project runtime plumbing. */
|
||||
hybridExecutor?: import("@fusion/engine").HybridExecutor;
|
||||
/**
|
||||
* Resolver for the engine-held CLI-agent telemetry hub (U17 hook route).
|
||||
* Given a request's projectId (if any) and the target session id, returns the
|
||||
* in-process TelemetryHub that owns that session's token registry, or undefined
|
||||
* when no hub / session is live. The hook route validates the per-session token
|
||||
* against this hub and forwards validated payloads to `hub.ingest`. Injected
|
||||
* here (rather than reached through the engine) so the engine↔dashboard wiring
|
||||
* can be supplied by later units and stubbed in tests. */
|
||||
cliAgentHubResolver?: (
|
||||
projectId: string | undefined,
|
||||
sessionId: string,
|
||||
) => import("@fusion/engine").TelemetryHub | undefined;
|
||||
/** Shared CentralCore instance used by the engine manager.
|
||||
* Routes that mutate central runtime state should use this instance so
|
||||
* in-process listeners (for example global concurrency changes) are notified. */
|
||||
@@ -223,6 +238,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;
|
||||
@@ -1110,6 +1138,73 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
store,
|
||||
);
|
||||
|
||||
// CLI Agent Executor — chat surface wiring. When the cli-session transport is
|
||||
// supplied (the runtime is live), broker cli-backed chat sends to the PTY and
|
||||
// route the project hub's sanitized telemetry into the runner's transcript
|
||||
// handler. The listener is keyed per-session inside one closure so it composes
|
||||
// safely even if other taps exist.
|
||||
if (options?.cliSessionTransport && options.cliAgentHubResolver) {
|
||||
try {
|
||||
const cliTransportStore = options.cliSessionTransport.store;
|
||||
// The transport's `manager` is typed for the attach/inject transport slice;
|
||||
// the chat runner additionally needs `spawn`. The concrete engine
|
||||
// CliSessionManager provides both — widen via a structural cast to the
|
||||
// spawn/inject slice the runner consumes.
|
||||
const spawnInject = options.cliSessionTransport.manager as unknown as {
|
||||
spawn: (opts: {
|
||||
adapterId: string;
|
||||
projectId: string;
|
||||
purpose: "chat";
|
||||
chatSessionId: string;
|
||||
worktreePath?: string | null;
|
||||
resume?: { sessionId: string; nativeSessionId: string };
|
||||
}) => Promise<{ id: string; nativeSessionId: string | null; agentState: string }>;
|
||||
inject: (sessionId: string, text: string) => Promise<void>;
|
||||
};
|
||||
// The runner needs spawn/inject (manager) + a fresh session record getter
|
||||
// (store). Compose the slice the runner expects so flush decisions read
|
||||
// authoritative records.
|
||||
const cliChatRunner = new CliChatSessionRunner({
|
||||
store: chatStore,
|
||||
manager: {
|
||||
spawn: (opts) => spawnInject.spawn(opts),
|
||||
inject: (sessionId, text) => spawnInject.inject(sessionId, text),
|
||||
getSession: (sessionId) => {
|
||||
const r = cliTransportStore.getSession(sessionId);
|
||||
return r
|
||||
? { id: r.id, nativeSessionId: r.nativeSessionId, agentState: r.agentState }
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
chatManager.setCliChatRunner(cliChatRunner, options.engine?.getProjectId?.());
|
||||
const hub = options.cliAgentHubResolver(undefined, "");
|
||||
if (hub) {
|
||||
hub.setEventListener((cliSessionId, event) => {
|
||||
// Per-session routing inside one listener: map the CLI session id to its
|
||||
// owning chat session (only chat-purpose sessions carry chatSessionId);
|
||||
// non-chat sessions (task/validator) are ignored here.
|
||||
const record = cliTransportStore.getSession(cliSessionId);
|
||||
const chatSessionId = record?.chatSessionId;
|
||||
if (!chatSessionId) return;
|
||||
void cliChatRunner
|
||||
.handleTelemetry(chatSessionId, {
|
||||
kind: event.kind,
|
||||
text: event.text,
|
||||
nativeSessionId: event.nativeSessionId,
|
||||
})
|
||||
.catch(() => {
|
||||
// best-effort: a transcript-handler throw must never break ingest.
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
runtimeLogger.warn?.("CLI-agent chat runner wiring failed", {
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const runAiSessionCleanup = (maxAgeMs: number, source: "initial" | "scheduled") => {
|
||||
const result = aiSessionStore.cleanupStaleSessions(maxAgeMs);
|
||||
runtimeLogger.info("AI session cleanup summary", {
|
||||
@@ -1436,6 +1531,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");
|
||||
@@ -1549,6 +1659,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;
|
||||
|
||||
@@ -281,6 +281,72 @@ export function emitPluginCustomSseEvent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI agent session state transitions (CLI Agent Executor, U10). The engine
|
||||
* state machine's `onStateChange` (throttled ~500ms in the engine) is bridged
|
||||
* into this seam by `setupCliSessionTransport`; every open SSE stream forwards
|
||||
* a matching (project-scoped) `cli:session:state` event so cards / banners
|
||||
* update without touching the byte stream.
|
||||
*
|
||||
* Events are also appended to a small module-level ring buffer with monotonic
|
||||
* ids so a client reconnecting with `Last-Event-ID` replays the transitions it
|
||||
* missed (the byte stream is on a separate WS channel; this is state only).
|
||||
*/
|
||||
export interface CliSessionStateSsePayload {
|
||||
sessionId: string;
|
||||
taskId: string | null;
|
||||
chatSessionId: string | null;
|
||||
/** Machine state (may be the transient "resuming"). */
|
||||
state: string;
|
||||
terminationReason?: string | null;
|
||||
/** Bounded (~200 chars), ANSI-stripped, redacted preview of recent output. */
|
||||
lastOutputPreview?: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
type CliSessionStateSseListener = (
|
||||
id: number,
|
||||
payload: CliSessionStateSsePayload,
|
||||
projectId?: string,
|
||||
) => void;
|
||||
|
||||
const cliSessionStateSseListeners = new Set<CliSessionStateSseListener>();
|
||||
|
||||
/** Module-level ring buffer of cli-session-state events for lastEventId replay. */
|
||||
const cliSessionStateBuffer: { id: number; payload: CliSessionStateSsePayload; projectId?: string }[] =
|
||||
[];
|
||||
let cliSessionStateNextId = 1;
|
||||
const CLI_SESSION_STATE_BUFFER_CAP = 200;
|
||||
|
||||
export function emitCliSessionStateSseEvent(
|
||||
payload: CliSessionStateSsePayload,
|
||||
projectId?: string,
|
||||
): number {
|
||||
const id = cliSessionStateNextId++;
|
||||
cliSessionStateBuffer.push({ id, payload, projectId });
|
||||
if (cliSessionStateBuffer.length > CLI_SESSION_STATE_BUFFER_CAP) {
|
||||
cliSessionStateBuffer.splice(0, cliSessionStateBuffer.length - CLI_SESSION_STATE_BUFFER_CAP);
|
||||
}
|
||||
for (const listener of cliSessionStateSseListeners) {
|
||||
listener(id, payload, projectId);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Buffered cli-session-state events with id > lastEventId (for reconnect replay). */
|
||||
export function getCliSessionStateEventsSince(
|
||||
lastEventId: number,
|
||||
): { id: number; payload: CliSessionStateSsePayload; projectId?: string }[] {
|
||||
if (!Number.isFinite(lastEventId)) return [...cliSessionStateBuffer];
|
||||
return cliSessionStateBuffer.filter((entry) => entry.id > lastEventId);
|
||||
}
|
||||
|
||||
/** Test seam: reset the cli-session-state buffer between tests. */
|
||||
export function resetCliSessionStateBufferForTests(): void {
|
||||
cliSessionStateBuffer.length = 0;
|
||||
cliSessionStateNextId = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized plugin lifecycle payload emitted via SSE.
|
||||
* This is the stable contract the UI can reconcile.
|
||||
@@ -650,6 +716,11 @@ export function createSSE(
|
||||
send(`event: plugin:custom\ndata: ${JSON.stringify({ pluginId, event, payload })}\n\n`);
|
||||
};
|
||||
|
||||
const onCliSessionStateEvent: CliSessionStateSseListener = (id, payload, eventProjectId) => {
|
||||
if (projectId && eventProjectId && eventProjectId !== projectId) return;
|
||||
send(`id: ${id}\nevent: cli:session:state\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
|
||||
// --- Chat store event handlers ---
|
||||
const onChatSessionCreated = (session: unknown) => {
|
||||
send(`event: chat:session:created\ndata: ${JSON.stringify(session)}\n\n`);
|
||||
@@ -801,6 +872,7 @@ export function createSSE(
|
||||
approvalSseListeners.delete(onApprovalEvent);
|
||||
workflowSseListeners.delete(onWorkflowEvent);
|
||||
pluginCustomSseListeners.delete(onPluginCustomEvent);
|
||||
cliSessionStateSseListeners.delete(onCliSessionStateEvent);
|
||||
if (chatStore) {
|
||||
chatStore.off("chat:session:created", onChatSessionCreated);
|
||||
chatStore.off("chat:session:updated", onChatSessionUpdated);
|
||||
@@ -949,6 +1021,30 @@ export function createSSE(
|
||||
approvalSseListeners.add(onApprovalEvent);
|
||||
workflowSseListeners.add(onWorkflowEvent);
|
||||
pluginCustomSseListeners.add(onPluginCustomEvent);
|
||||
cliSessionStateSseListeners.add(onCliSessionStateEvent);
|
||||
|
||||
// Replay any cli-session-state transitions missed since the client's
|
||||
// Last-Event-ID (reconnect recovery — the byte stream is on a separate WS
|
||||
// channel, so only state events are replayed here).
|
||||
{
|
||||
const headerVal = _req.headers?.["last-event-id"];
|
||||
const lastEventIdRaw =
|
||||
(typeof headerVal === "string"
|
||||
? headerVal
|
||||
: Array.isArray(headerVal)
|
||||
? headerVal[0]
|
||||
: undefined) ??
|
||||
(typeof _req.query?.lastEventId === "string" ? _req.query.lastEventId : undefined);
|
||||
const lastEventId = lastEventIdRaw !== undefined ? Number(lastEventIdRaw) : NaN;
|
||||
if (Number.isFinite(lastEventId)) {
|
||||
for (const entry of getCliSessionStateEventsSince(lastEventId)) {
|
||||
if (projectId && entry.projectId && entry.projectId !== projectId) continue;
|
||||
send(
|
||||
`id: ${entry.id}\nevent: cli:session:state\ndata: ${JSON.stringify(entry.payload)}\n\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerManagedConnection({
|
||||
id: connectionId,
|
||||
|
||||
@@ -11,176 +11,10 @@ import { EventEmitter } from "events";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import * as fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
// Detect if we're running as a Bun-compiled binary
|
||||
// @ts-expect-error - Bun global is only available in Bun runtime
|
||||
const isBunBinary = typeof Bun !== "undefined" && !!Bun.embeddedFiles;
|
||||
|
||||
// Lazy-loaded node-pty module (only loaded when terminal is actually used)
|
||||
let ptyModule: typeof import("node-pty") | null = null;
|
||||
let ptyLoadError: Error | null = null;
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Find the staged native assets directory for Bun-compiled binaries.
|
||||
* Looks for runtime/<platform-arch>/ next to the binary.
|
||||
*
|
||||
* NOTE: The fs.existsSync() calls in this function run during service initialization
|
||||
* (when terminal is first used). This is acceptable as it only executes once per
|
||||
* service lifetime, not per-request.
|
||||
*/
|
||||
function getNativePrebuildName(): string {
|
||||
const platform = process.platform === "darwin" ? "darwin" :
|
||||
process.platform === "linux" ? "linux" :
|
||||
process.platform === "win32" ? "win32" : "unknown";
|
||||
const arch = process.arch === "arm64" ? "arm64" :
|
||||
process.arch === "x64" ? "x64" : "unknown";
|
||||
return `${platform}-${arch}`;
|
||||
}
|
||||
|
||||
function findInstalledNodePtyNativeDir(): string | null {
|
||||
try {
|
||||
const packageJsonPath = require.resolve("node-pty/package.json");
|
||||
const pkgRoot = dirname(packageJsonPath);
|
||||
|
||||
// @homebridge/node-pty-prebuilt-multiarch (aliased as node-pty) places the binary
|
||||
// in build/Release/pty.node after prebuild-install runs at install time.
|
||||
// Prefer this location as it is the fork's standard output path.
|
||||
const releaseDir = join(pkgRoot, "build", "Release");
|
||||
if (fs.existsSync(join(releaseDir, "pty.node"))) {
|
||||
return releaseDir;
|
||||
}
|
||||
|
||||
// Fallback: check the old prebuilds/<plat-arch>/ layout (upstream node-pty style).
|
||||
const prebuildDir = join(pkgRoot, "prebuilds", getNativePrebuildName());
|
||||
if (fs.existsSync(join(prebuildDir, "pty.node"))) {
|
||||
return prebuildDir;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNodePtyNativePermissions(): void {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidateDirs = new Set<string>();
|
||||
const envNativeDir = process.env.NODE_PTY_SPAWN_HELPER_DIR || process.env.FUSION_NATIVE_ASSETS_PATH;
|
||||
if (envNativeDir) {
|
||||
candidateDirs.add(envNativeDir);
|
||||
}
|
||||
|
||||
const stagedNativeDir = findStagedNativeDir();
|
||||
if (stagedNativeDir) {
|
||||
candidateDirs.add(stagedNativeDir);
|
||||
}
|
||||
|
||||
const installedNativeDir = findInstalledNodePtyNativeDir();
|
||||
if (installedNativeDir) {
|
||||
candidateDirs.add(installedNativeDir);
|
||||
}
|
||||
|
||||
for (const nativeDir of candidateDirs) {
|
||||
const helperPath = join(nativeDir, "spawn-helper");
|
||||
const nativeModulePath = join(nativeDir, "pty.node");
|
||||
|
||||
try {
|
||||
fs.chmodSync(helperPath, 0o755);
|
||||
} catch {
|
||||
// Best-effort permission repair; helper may not exist in some layouts.
|
||||
}
|
||||
|
||||
try {
|
||||
fs.chmodSync(nativeModulePath, 0o755);
|
||||
} catch (err) {
|
||||
// Keep diagnostics for the native module path since missing/invalid perms
|
||||
// here are more likely to prevent PTY startup.
|
||||
console.warn("[terminal] Failed to repair node-pty native permissions:", {
|
||||
nativeDir,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findStagedNativeDir(): string | null {
|
||||
const prebuildName = getNativePrebuildName();
|
||||
|
||||
// Check FUSION_RUNTIME_DIR env var first
|
||||
if (process.env.FUSION_RUNTIME_DIR) {
|
||||
const envPath = join(process.env.FUSION_RUNTIME_DIR, prebuildName);
|
||||
if (fs.existsSync(join(envPath, "pty.node"))) {
|
||||
return envPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Look next to the executable
|
||||
const execDir = dirname(process.execPath);
|
||||
const nextToBinary = join(execDir, "runtime", prebuildName);
|
||||
if (fs.existsSync(join(nextToBinary, "pty.node"))) {
|
||||
return nextToBinary;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadPtyModule(): Promise<typeof import("node-pty")> {
|
||||
ensureNodePtyNativePermissions();
|
||||
|
||||
if (ptyModule) {
|
||||
return ptyModule;
|
||||
}
|
||||
|
||||
if (ptyLoadError) {
|
||||
throw ptyLoadError;
|
||||
}
|
||||
|
||||
// For Bun-compiled binary, set up native paths before loading
|
||||
if (isBunBinary) {
|
||||
const nativeDir = findStagedNativeDir();
|
||||
if (nativeDir) {
|
||||
// Set spawn-helper directory
|
||||
if (process.platform !== "win32") {
|
||||
process.env.NODE_PTY_SPAWN_HELPER_DIR = nativeDir;
|
||||
}
|
||||
// Store reference for debugging
|
||||
process.env.FUSION_NATIVE_ASSETS_PATH = nativeDir;
|
||||
|
||||
// Try to pre-load the native module using process.dlopen
|
||||
// This can help when the normal require() path fails
|
||||
const nativePath = join(nativeDir, "pty.node");
|
||||
if (fs.existsSync(nativePath)) {
|
||||
try {
|
||||
const nativeModule: { exports?: unknown } = { exports: {} };
|
||||
// process.dlopen is a Node internal API
|
||||
process.dlopen(nativeModule, nativePath);
|
||||
console.log("[terminal] Pre-loaded native module via dlopen");
|
||||
} catch (dlopenErr) {
|
||||
// dlopen failed - log but continue, normal import might still work
|
||||
console.log("[terminal] dlopen pre-load failed (continuing):", dlopenErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Standard import path - the native-patch setup should have created
|
||||
// the necessary symlink structure for node-pty to find the module
|
||||
const mod = await import("node-pty");
|
||||
ptyModule = mod;
|
||||
return ptyModule as typeof import("node-pty");
|
||||
} catch (err) {
|
||||
ptyLoadError = err instanceof Error ? err : new Error(String(err));
|
||||
throw ptyLoadError;
|
||||
}
|
||||
}
|
||||
// The node-pty native-asset loader (lazy-load, prebuild resolution, dlopen
|
||||
// fallback, and permission repair) lives in @fusion/engine so PTY owners share
|
||||
// one implementation. See packages/engine/src/pty-native.ts.
|
||||
import { loadPtyModule } from "@fusion/engine";
|
||||
|
||||
// Maximum scrollback buffer size (characters)
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@earendil-works/pi-coding-agent": "^0.78.0",
|
||||
"cron-parser": "^5.5.0",
|
||||
"esbuild": "^0.25.12",
|
||||
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"typebox": "^1.0.0"
|
||||
},
|
||||
|
||||
396
packages/engine/src/__tests__/cli-agent-executor.test.ts
Normal file
396
packages/engine/src/__tests__/cli-agent-executor.test.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import "./executor-test-helpers.js";
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
// node:fs is mocked by executor-test-helpers; use node:fs/promises (unmocked) for
|
||||
// real temp-dir + hook-script I/O.
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { Database, CliSessionStore } from "@fusion/core";
|
||||
import type { IPty } from "node-pty";
|
||||
import { TaskExecutor, type CliAgentRuntime } from "../executor.js";
|
||||
import { resetExecutorMocks } from "./executor-test-helpers.js";
|
||||
import { CliSessionManager } from "../cli-agent/session-manager.js";
|
||||
import { TelemetryHub } from "../cli-agent/telemetry-hub.js";
|
||||
import { CliAdapterRegistry, type CliAgentAdapter } from "../cli-agent/adapter.js";
|
||||
|
||||
type Listener = (...args: any[]) => void;
|
||||
|
||||
// ── Mock PTY ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MockPty extends IPty {
|
||||
written: string[];
|
||||
killed: boolean;
|
||||
killSignal: string | undefined;
|
||||
emitData(data: string): void;
|
||||
emitExit(exitCode: number, signal?: number): void;
|
||||
}
|
||||
interface MockState {
|
||||
ptys: MockPty[];
|
||||
}
|
||||
function makeMockPtyModule(state: MockState): typeof import("node-pty") {
|
||||
return {
|
||||
spawn() {
|
||||
let dataCb: ((d: string) => void) | undefined;
|
||||
let exitCb: ((e: { exitCode: number; signal?: number }) => void) | undefined;
|
||||
const mock: MockPty = {
|
||||
pid: 3000 + state.ptys.length,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
process: "mock",
|
||||
handleFlowControl: false,
|
||||
written: [],
|
||||
killed: false,
|
||||
killSignal: undefined,
|
||||
onData: (cb: (d: string) => void) => {
|
||||
dataCb = cb;
|
||||
return { dispose() {} };
|
||||
},
|
||||
onExit: (cb: (e: { exitCode: number; signal?: number }) => void) => {
|
||||
exitCb = cb;
|
||||
return { dispose() {} };
|
||||
},
|
||||
on() {},
|
||||
write(data: string) {
|
||||
mock.written.push(data);
|
||||
},
|
||||
resize() {},
|
||||
clear() {},
|
||||
kill(signal?: string) {
|
||||
mock.killed = true;
|
||||
mock.killSignal = signal;
|
||||
exitCb?.({ exitCode: 0, signal: signal === "SIGKILL" ? 9 : undefined });
|
||||
},
|
||||
pause() {},
|
||||
resume() {},
|
||||
emitData(d: string) {
|
||||
dataCb?.(d);
|
||||
},
|
||||
emitExit(exitCode: number, signal?: number) {
|
||||
exitCb?.({ exitCode, signal });
|
||||
},
|
||||
} as any;
|
||||
state.ptys.push(mock);
|
||||
return mock as unknown as IPty;
|
||||
},
|
||||
} as unknown as typeof import("node-pty");
|
||||
}
|
||||
|
||||
function scriptedAdapter(): CliAgentAdapter {
|
||||
return {
|
||||
id: "scripted",
|
||||
name: "Scripted",
|
||||
capabilities: { nativeDone: true, nativeWaiting: true, transcriptSource: "hooks", supportsResume: true },
|
||||
buildLaunch: () => ({ command: "scripted", args: [] }),
|
||||
buildEnvAllowlist: () => ["PATH"],
|
||||
createReadinessDetector: () => {
|
||||
let ready = false;
|
||||
return {
|
||||
observe(chunk: string) {
|
||||
if (chunk.includes("READY")) ready = true;
|
||||
return ready;
|
||||
},
|
||||
};
|
||||
},
|
||||
formatInjection: (text) => ({ payload: text.endsWith("\r") ? text : `${text}\r` }),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Store stub satisfying the runGraphCustomNode/cli-agent code paths ──────────
|
||||
|
||||
function createStore(task: any) {
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
const logs: string[] = [];
|
||||
return {
|
||||
logs,
|
||||
store: {
|
||||
on: vi.fn((event: string, listener: Listener) => {
|
||||
const set = listeners.get(event) ?? new Set<Listener>();
|
||||
set.add(listener);
|
||||
listeners.set(event, set);
|
||||
}),
|
||||
off: vi.fn(),
|
||||
getTask: vi.fn().mockImplementation(async () => task),
|
||||
logEntry: vi.fn().mockImplementation(async (_id: string, msg: string) => {
|
||||
logs.push(msg);
|
||||
}),
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
describe("cli-agent executor seam (U7)", () => {
|
||||
let tmpDir: string;
|
||||
let fusionDir: string;
|
||||
let db: Database;
|
||||
let cliStore: CliSessionStore;
|
||||
let registry: CliAdapterRegistry;
|
||||
let manager: CliSessionManager;
|
||||
let hub: TelemetryHub;
|
||||
let state: MockState;
|
||||
let worktree: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetExecutorMocks();
|
||||
vi.clearAllMocks();
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "kb-cli-exec-"));
|
||||
fusionDir = join(tmpDir, ".fusion");
|
||||
worktree = join(tmpDir, "wt");
|
||||
db = new Database(fusionDir, { inMemory: true });
|
||||
db.init();
|
||||
cliStore = new CliSessionStore(fusionDir, db);
|
||||
registry = new CliAdapterRegistry();
|
||||
registry.register(scriptedAdapter());
|
||||
state = { ptys: [] };
|
||||
manager = new CliSessionManager({ registry, store: cliStore, loadPty: async () => makeMockPtyModule(state) });
|
||||
hub = new TelemetryHub({ store: cliStore });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
manager.dispose();
|
||||
db.close();
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function runtime(): CliAgentRuntime {
|
||||
return {
|
||||
manager,
|
||||
hub,
|
||||
registry,
|
||||
store: cliStore,
|
||||
projectId: "proj",
|
||||
hookEndpointUrl: "http://127.0.0.1:4040/api/cli-agent/hooks",
|
||||
hookDirRoot: tmpDir,
|
||||
};
|
||||
}
|
||||
|
||||
function makeExecutor(task: any) {
|
||||
const { store, logs } = createStore(task);
|
||||
const executor = new TaskExecutor(store, tmpDir, { cliAgentRuntime: runtime() });
|
||||
return { executor, store, logs };
|
||||
}
|
||||
|
||||
const cliNode = {
|
||||
id: "execute",
|
||||
kind: "prompt" as const,
|
||||
config: { executor: "cli-agent", cliAdapterId: "scripted", prompt: "implement the feature" },
|
||||
};
|
||||
|
||||
const taskDetail = () => ({
|
||||
id: "FN-100",
|
||||
column: "in-progress",
|
||||
worktree,
|
||||
prompt: "implement the feature",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
});
|
||||
|
||||
function lastPty() {
|
||||
return state.ptys[state.ptys.length - 1];
|
||||
}
|
||||
|
||||
// ── AE1 / F1 ────────────────────────────────────────────────────────────────
|
||||
|
||||
it("AE1: cli-agent node spawns in worktree, injects prompt after readiness, native done advances, PTY reaped", async () => {
|
||||
const { executor } = makeExecutor(taskDetail());
|
||||
const resultP = (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
|
||||
|
||||
// Wait for spawn.
|
||||
await vi.waitFor(() => expect(state.ptys).toHaveLength(1));
|
||||
|
||||
// Readiness → injection.
|
||||
lastPty().emitData("READY\r\n");
|
||||
await vi.waitFor(() => expect(lastPty().written.some((w) => w.includes("implement the feature"))).toBe(true));
|
||||
|
||||
// Resolve the live session via the hub (the registered session id).
|
||||
const sessions = cliStore.listByTask("FN-100");
|
||||
expect(sessions).toHaveLength(1);
|
||||
const sid = sessions[0].id;
|
||||
// Injection drives ready→busy on the machine asynchronously; wait for busy.
|
||||
await vi.waitFor(() => expect(hub.getStateMachine(sid)?.getState()).toBe("busy"));
|
||||
hub.ingest(sid, { kind: "done" });
|
||||
|
||||
const result = await resultP;
|
||||
expect(result.outcome).toBe("success");
|
||||
expect(result.value).toBe("cli-agent-done");
|
||||
// Reaped at handoff.
|
||||
expect(lastPty().killed).toBe(true);
|
||||
expect(manager.isLive(sid)).toBe(false);
|
||||
expect(cliStore.getSession(sid)?.terminationReason).toBe("completed");
|
||||
});
|
||||
|
||||
// ── AE5 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
it("AE5: user input mid-busy doesn't break tracking; subsequent done still advances", async () => {
|
||||
const { executor } = makeExecutor(taskDetail());
|
||||
const resultP = (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
|
||||
await vi.waitFor(() => expect(state.ptys).toHaveLength(1));
|
||||
lastPty().emitData("READY\r\n");
|
||||
|
||||
const sid = await vi.waitFor(() => {
|
||||
const s = cliStore.listByTask("FN-100");
|
||||
expect(s).toHaveLength(1);
|
||||
return s[0].id;
|
||||
});
|
||||
// The injection drives the ready→busy machine transition asynchronously;
|
||||
// wait until the machine has reached busy before exercising mid-busy input.
|
||||
await vi.waitFor(() => expect(hub.getStateMachine(sid)?.getState()).toBe("busy"));
|
||||
hub.ingest(sid, { kind: "sessionStart" });
|
||||
hub.ingest(sid, { kind: "busy" });
|
||||
// Mid-busy user keystrokes via the manager (deliberate control input).
|
||||
manager.write(sid, "hint\r");
|
||||
hub.ingest(sid, { kind: "toolActivity" });
|
||||
expect(hub.getStateMachine(sid)?.getState()).toBe("busy");
|
||||
|
||||
hub.ingest(sid, { kind: "done" });
|
||||
const result = await resultP;
|
||||
expect(result.outcome).toBe("success");
|
||||
});
|
||||
|
||||
// ── Hard cancel via the abort path ────────────────────────────────────────────
|
||||
|
||||
it("hard cancel: abort path SIGKILLs the cli session, marks killed (not resume-eligible), releases slot", async () => {
|
||||
const { executor } = makeExecutor(taskDetail());
|
||||
const resultP = (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
|
||||
await vi.waitFor(() => expect(state.ptys).toHaveLength(1));
|
||||
lastPty().emitData("READY\r\n");
|
||||
const sid = await vi.waitFor(() => {
|
||||
const s = cliStore.listByTask("FN-100");
|
||||
expect(s).toHaveLength(1);
|
||||
return s[0].id;
|
||||
});
|
||||
hub.ingest(sid, { kind: "sessionStart" });
|
||||
hub.ingest(sid, { kind: "busy" });
|
||||
|
||||
// The cli session is registered as an active surface.
|
||||
expect((executor as any).activeCliTaskSessions.has("FN-100")).toBe(true);
|
||||
expect(manager.activeCount()).toBe(1);
|
||||
|
||||
// moveTask(in-progress→todo) hard cancel routes here.
|
||||
await executor.awaitAbortInFlightTaskWork("FN-100", "parent moved from in-progress to todo", {
|
||||
userCanceled: true,
|
||||
});
|
||||
|
||||
const result = await resultP;
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(result.value).toBe("cli-agent-killed");
|
||||
expect(lastPty().killed).toBe(true);
|
||||
expect(lastPty().killSignal).toBe("SIGKILL");
|
||||
expect(manager.activeCount()).toBe(0);
|
||||
expect((executor as any).activeCliTaskSessions.has("FN-100")).toBe(false);
|
||||
expect(cliStore.getSession(sid)?.terminationReason).toBe("killed");
|
||||
});
|
||||
|
||||
// ── Re-entry launches fresh (prior live session killed) ──────────────────────
|
||||
|
||||
it("re-entry: a fresh run kills the prior live session and spawns a new PTY", async () => {
|
||||
const { executor } = makeExecutor(taskDetail());
|
||||
// First run, left live (no done).
|
||||
void (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
|
||||
await vi.waitFor(() => expect(state.ptys).toHaveLength(1));
|
||||
lastPty().emitData("READY\r\n");
|
||||
const firstId = await vi.waitFor(() => {
|
||||
const s = cliStore.listByTask("FN-100");
|
||||
expect(s.length).toBeGreaterThanOrEqual(1);
|
||||
return s[0].id;
|
||||
});
|
||||
expect(manager.isLive(firstId)).toBe(true);
|
||||
// Let the first run's async injection settle (it drives the machine to busy
|
||||
// and would otherwise overwrite the killed reason mid-race).
|
||||
await vi.waitFor(() => expect(hub.getStateMachine(firstId)?.getState()).toBe("busy"));
|
||||
// Drop the first run's active handle to simulate a graph re-entry without abort.
|
||||
(executor as any).activeCliTaskSessions.delete("FN-100");
|
||||
|
||||
// Second run (RETHINK re-entry) — kills the prior live session, spawns fresh.
|
||||
const secondP = (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
|
||||
await vi.waitFor(() => expect(state.ptys).toHaveLength(2));
|
||||
expect(manager.isLive(firstId)).toBe(false);
|
||||
expect(cliStore.getSession(firstId)?.terminationReason).toBe("killed");
|
||||
|
||||
lastPty().emitData("READY\r\n");
|
||||
const second = cliStore.listByTask("FN-100").find((s) => s.id !== firstId)!;
|
||||
await vi.waitFor(() => expect(hub.getStateMachine(second.id)?.getState()).toBe("busy"));
|
||||
hub.ingest(second.id, { kind: "done" });
|
||||
const result = await secondP;
|
||||
expect(result.outcome).toBe("success");
|
||||
});
|
||||
|
||||
// ── Ceiling produces a typed surfaced value, not a hang ──────────────────────
|
||||
|
||||
it("ceiling: spawn at the PTY pool ceiling produces a surfaced cli-agent-at-capacity value", async () => {
|
||||
const limited = new CliSessionManager({
|
||||
registry,
|
||||
store: cliStore,
|
||||
concurrencyCeiling: 1,
|
||||
loadPty: async () => makeMockPtyModule(state),
|
||||
});
|
||||
try {
|
||||
// Consume the only slot with a directly-spawned session.
|
||||
await limited.spawn({ adapterId: "scripted", projectId: "proj", purpose: "execute", worktreePath: worktree });
|
||||
const { store, logs } = createStore(taskDetail());
|
||||
const executor = new TaskExecutor(store, tmpDir, {
|
||||
cliAgentRuntime: { ...runtime(), manager: limited },
|
||||
});
|
||||
const result = await (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(result.value).toBe("cli-agent-at-capacity");
|
||||
expect(logs.some((l) => l.includes("ceiling"))).toBe(true);
|
||||
} finally {
|
||||
limited.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Missing config / runtime surface as clear errors ─────────────────────────
|
||||
|
||||
it("missing cliAdapterId surfaces a clear config error (not a stall)", async () => {
|
||||
const { executor } = makeExecutor(taskDetail());
|
||||
const node = { id: "x", kind: "prompt" as const, config: { executor: "cli-agent", prompt: "go" } };
|
||||
const result = await (executor as any).runGraphCustomNode(node, taskDetail(), {});
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(result.value).toBe("cli-agent-adapter-missing");
|
||||
});
|
||||
|
||||
it("absent runtime surfaces cli-agent-runtime-unavailable", async () => {
|
||||
const { store } = createStore(taskDetail());
|
||||
const executor = new TaskExecutor(store, tmpDir, {}); // no cliAgentRuntime
|
||||
const result = await (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(result.value).toBe("cli-agent-runtime-unavailable");
|
||||
});
|
||||
|
||||
it("no worktree surfaces no-worktree-for-write-node", async () => {
|
||||
const noWt = { ...taskDetail(), worktree: undefined };
|
||||
const { store } = createStore(noWt);
|
||||
const executor = new TaskExecutor(store, tmpDir, { cliAgentRuntime: runtime() });
|
||||
const result = await (executor as any).runGraphCustomNode(cliNode, noWt, {});
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(result.value).toBe("no-worktree-for-write-node");
|
||||
});
|
||||
|
||||
// ── Node-config edit mid-run keeps the launch-time snapshot ───────────────────
|
||||
|
||||
it("node-config edit mid-run does not re-spawn or change the live session", async () => {
|
||||
const { executor } = makeExecutor(taskDetail());
|
||||
const node = {
|
||||
id: "execute",
|
||||
kind: "prompt" as const,
|
||||
config: { executor: "cli-agent", cliAdapterId: "scripted", prompt: "v1 prompt" },
|
||||
};
|
||||
const resultP = (executor as any).runGraphCustomNode(node, taskDetail(), {});
|
||||
await vi.waitFor(() => expect(state.ptys).toHaveLength(1));
|
||||
lastPty().emitData("READY\r\n");
|
||||
await vi.waitFor(() => expect(lastPty().written.some((w) => w.includes("v1 prompt"))).toBe(true));
|
||||
|
||||
// Edit the node config object mid-run.
|
||||
node.config.prompt = "v2 prompt";
|
||||
const sid = cliStore.listByTask("FN-100")[0].id;
|
||||
await vi.waitFor(() => expect(hub.getStateMachine(sid)?.getState()).toBe("busy"));
|
||||
hub.ingest(sid, { kind: "done" });
|
||||
await resultP;
|
||||
|
||||
// Exactly one PTY, and it only ever saw the launch-time prompt (no re-spawn).
|
||||
expect(state.ptys).toHaveLength(1);
|
||||
expect(lastPty().written.some((w) => w.includes("v2 prompt"))).toBe(false);
|
||||
});
|
||||
});
|
||||
153
packages/engine/src/__tests__/cli-agent-validator.test.ts
Normal file
153
packages/engine/src/__tests__/cli-agent-validator.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
mapParsedToVerdict,
|
||||
oneShotResultToVerdict,
|
||||
normalizeVerdictToken,
|
||||
inferVerdictFromProse,
|
||||
runCliAgentValidation,
|
||||
} from "../cli-agent-validator.js";
|
||||
import type {
|
||||
OneShotResult,
|
||||
RunOneShotOptions,
|
||||
} from "../cli-agent/one-shot-session.js";
|
||||
|
||||
function success(parsed: Record<string, unknown>, text = ""): OneShotResult {
|
||||
return { ok: true, sessionId: "s1", parsed, text, rawOutput: JSON.stringify(parsed) };
|
||||
}
|
||||
|
||||
describe("verdict token normalization", () => {
|
||||
it("maps synonyms to the contract set", () => {
|
||||
expect(normalizeVerdictToken("APPROVE")).toBe("pass");
|
||||
expect(normalizeVerdictToken("passed")).toBe("pass");
|
||||
expect(normalizeVerdictToken("REVISE")).toBe("fail");
|
||||
expect(normalizeVerdictToken("blocked")).toBe("blocked");
|
||||
expect(normalizeVerdictToken("nonsense")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapParsedToVerdict — per-adapter shapes → verdicts", () => {
|
||||
it("claude-shaped pass (is_error:false + verdict)", () => {
|
||||
const v = mapParsedToVerdict({ type: "result", verdict: "pass", is_error: false }, "");
|
||||
expect(v.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("claude-shaped error flag is authoritative", () => {
|
||||
const v = mapParsedToVerdict({ is_error: true, result: "crashed" }, "");
|
||||
expect(v.status).toBe("error");
|
||||
});
|
||||
|
||||
it("boolean passed:false → fail", () => {
|
||||
const v = mapParsedToVerdict({ passed: false, summary: "missing X" }, "");
|
||||
expect(v.status).toBe("fail");
|
||||
expect(v.summary).toBe("missing X");
|
||||
});
|
||||
|
||||
it("explicit blocked flag → blocked with reason", () => {
|
||||
const v = mapParsedToVerdict({ blocked: true, reason: "needs creds" }, "");
|
||||
expect(v.status).toBe("blocked");
|
||||
expect(v.blockedReason).toBe("needs creds");
|
||||
});
|
||||
|
||||
it("status token + assertions array", () => {
|
||||
const v = mapParsedToVerdict(
|
||||
{
|
||||
status: "fail",
|
||||
assertions: [
|
||||
{ assertionId: "a1", passed: true },
|
||||
{ id: "a2", passed: false, message: "nope" },
|
||||
],
|
||||
},
|
||||
"",
|
||||
);
|
||||
expect(v.status).toBe("fail");
|
||||
expect(v.assertions).toHaveLength(2);
|
||||
expect(v.assertions[1]).toEqual({ assertionId: "a2", passed: false, message: "nope" });
|
||||
});
|
||||
|
||||
it("prose-only pass inference", () => {
|
||||
expect(inferVerdictFromProse("All assertions pass.")).toBe("pass");
|
||||
const v = mapParsedToVerdict({}, "All assertions pass.");
|
||||
expect(v.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("MALFORMED / undecidable → error, NEVER pass", () => {
|
||||
const v = mapParsedToVerdict({ irrelevant: 1 }, "the agent rambled without a verdict");
|
||||
expect(v.status).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("oneShotResultToVerdict — failures map to error", () => {
|
||||
it("nonzero exit → error with stderr in summary", () => {
|
||||
const v = oneShotResultToVerdict({
|
||||
ok: false,
|
||||
reason: "nonzero-exit",
|
||||
sessionId: "s1",
|
||||
exitCode: 1,
|
||||
stderr: "segfault",
|
||||
message: "exited with code 1",
|
||||
});
|
||||
expect(v.status).toBe("error");
|
||||
expect(v.summary).toContain("segfault");
|
||||
});
|
||||
|
||||
it("unparseable → error (never silent pass)", () => {
|
||||
const v = oneShotResultToVerdict({
|
||||
ok: false,
|
||||
reason: "unparseable",
|
||||
sessionId: "s1",
|
||||
exitCode: 0,
|
||||
stderr: "garbage",
|
||||
message: "no decodable result",
|
||||
});
|
||||
expect(v.status).toBe("error");
|
||||
});
|
||||
|
||||
it("success with pass verdict → pass", () => {
|
||||
expect(oneShotResultToVerdict(success({ verdict: "pass" })).status).toBe("pass");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCliAgentValidation — seam threads purpose:validator and maps verdict", () => {
|
||||
it("invokes runner with validator purpose and returns the verdict", async () => {
|
||||
let seenPurpose: string | undefined;
|
||||
const fakeRun = async (opts: RunOneShotOptions): Promise<OneShotResult> => {
|
||||
seenPurpose = opts.purpose;
|
||||
return success({ verdict: "pass", summary: "looks good" }, "looks good");
|
||||
};
|
||||
const verdict = await runCliAgentValidation(
|
||||
{
|
||||
manager: {} as RunOneShotOptions["manager"],
|
||||
adapterId: "claude-code",
|
||||
projectId: "p",
|
||||
prompt: "validate",
|
||||
cwd: "/tmp",
|
||||
},
|
||||
fakeRun as never,
|
||||
);
|
||||
expect(seenPurpose).toBe("validator");
|
||||
expect(verdict.status).toBe("pass");
|
||||
expect(verdict.summary).toBe("looks good");
|
||||
});
|
||||
|
||||
it("runner failure surfaces as error verdict", async () => {
|
||||
const fakeRun = async (): Promise<OneShotResult> => ({
|
||||
ok: false,
|
||||
reason: "spawn-failed",
|
||||
sessionId: null,
|
||||
exitCode: null,
|
||||
stderr: "",
|
||||
message: "ENOENT claude",
|
||||
});
|
||||
const verdict = await runCliAgentValidation(
|
||||
{
|
||||
manager: {} as RunOneShotOptions["manager"],
|
||||
adapterId: "claude-code",
|
||||
projectId: "p",
|
||||
prompt: "validate",
|
||||
cwd: "/tmp",
|
||||
},
|
||||
fakeRun as never,
|
||||
);
|
||||
expect(verdict.status).toBe("error");
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,61 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { PlanningQuestion, PlanningResponse } from "@fusion/core";
|
||||
import {
|
||||
createInteractiveAiSessionWith,
|
||||
runCliAgentPlanning,
|
||||
type InteractiveAgentResult,
|
||||
type InteractiveAgentSession,
|
||||
} from "../interactive-ai-session.js";
|
||||
import type {
|
||||
OneShotResult,
|
||||
RunOneShotOptions,
|
||||
} from "../cli-agent/one-shot-session.js";
|
||||
|
||||
describe("runCliAgentPlanning (U9 one-shot planning seam)", () => {
|
||||
const baseOpts = {
|
||||
manager: {} as RunOneShotOptions["manager"],
|
||||
adapterId: "claude-code",
|
||||
projectId: "p",
|
||||
prompt: "plan it",
|
||||
cwd: "/tmp",
|
||||
};
|
||||
|
||||
it("maps one-shot output to the SAME PlanningResponse shape a model run produces", async () => {
|
||||
let seenPurpose: string | undefined;
|
||||
const fakeRun = async (opts: RunOneShotOptions): Promise<OneShotResult> => {
|
||||
seenPurpose = opts.purpose;
|
||||
const summary = {
|
||||
title: "Do X",
|
||||
description: "Plan to do X",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["X"],
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
sessionId: "s1",
|
||||
parsed: {},
|
||||
text: JSON.stringify({ type: "complete", data: summary }),
|
||||
rawOutput: "",
|
||||
};
|
||||
};
|
||||
const resp: PlanningResponse = await runCliAgentPlanning(baseOpts, fakeRun as never);
|
||||
expect(seenPurpose).toBe("planning");
|
||||
expect(resp.type).toBe("complete");
|
||||
if (resp.type === "complete") expect(resp.data.title).toBe("Do X");
|
||||
});
|
||||
|
||||
it("throws on a failed one-shot (never returns a fabricated plan)", async () => {
|
||||
const fakeRun = async (): Promise<OneShotResult> => ({
|
||||
ok: false,
|
||||
reason: "unparseable",
|
||||
sessionId: "s1",
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
message: "no result",
|
||||
});
|
||||
await expect(runCliAgentPlanning(baseOpts, fakeRun as never)).rejects.toThrow(/planning/i);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A scripted fake agent: each `prompt()` advances through a queue of canned
|
||||
|
||||
92
packages/engine/src/__tests__/pty-native.test.ts
Normal file
92
packages/engine/src/__tests__/pty-native.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
getNativePrebuildName,
|
||||
findStagedNativeDir,
|
||||
ensureNodePtyNativePermissions,
|
||||
} from "../pty-native.js";
|
||||
|
||||
const SAVED_ENV = {
|
||||
FUSION_RUNTIME_DIR: process.env.FUSION_RUNTIME_DIR,
|
||||
NODE_PTY_SPAWN_HELPER_DIR: process.env.NODE_PTY_SPAWN_HELPER_DIR,
|
||||
FUSION_NATIVE_ASSETS_PATH: process.env.FUSION_NATIVE_ASSETS_PATH,
|
||||
};
|
||||
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(join(os.tmpdir(), "pty-native-"));
|
||||
delete process.env.FUSION_RUNTIME_DIR;
|
||||
delete process.env.NODE_PTY_SPAWN_HELPER_DIR;
|
||||
delete process.env.FUSION_NATIVE_ASSETS_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
for (const [k, v] of Object.entries(SAVED_ENV)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
/** Create a fixture `<root>/<prebuildName>/pty.node` (+ spawn-helper) directory. */
|
||||
function makeStagedDir(root: string, opts: { broken?: boolean } = {}): string {
|
||||
const dir = join(root, getNativePrebuildName());
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const nativePath = join(dir, "pty.node");
|
||||
const helperPath = join(dir, "spawn-helper");
|
||||
fs.writeFileSync(nativePath, "fake-native");
|
||||
fs.writeFileSync(helperPath, "fake-helper");
|
||||
if (opts.broken) {
|
||||
// Strip executable + write/read bits to simulate a broken-mode install.
|
||||
fs.chmodSync(nativePath, 0o400);
|
||||
fs.chmodSync(helperPath, 0o400);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("getNativePrebuildName", () => {
|
||||
it("returns a <platform>-<arch> token", () => {
|
||||
const name = getNativePrebuildName();
|
||||
expect(name).toMatch(/^(darwin|linux|win32|unknown)-(arm64|x64|unknown)$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findStagedNativeDir (packaged-binary mode)", () => {
|
||||
it("resolves the staged dir via FUSION_RUNTIME_DIR fixture", () => {
|
||||
const staged = makeStagedDir(tmpRoot);
|
||||
process.env.FUSION_RUNTIME_DIR = tmpRoot;
|
||||
expect(findStagedNativeDir()).toBe(staged);
|
||||
});
|
||||
|
||||
it("returns null when no staged pty.node is present", () => {
|
||||
process.env.FUSION_RUNTIME_DIR = tmpRoot; // empty, no pty.node
|
||||
expect(findStagedNativeDir()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureNodePtyNativePermissions (permission repair)", () => {
|
||||
// chmod semantics don't apply on win32; skip there.
|
||||
const maybe = process.platform === "win32" ? it.skip : it;
|
||||
|
||||
maybe("repairs broken modes on a fixture native dir to 0o755", () => {
|
||||
const dir = makeStagedDir(tmpRoot, { broken: true });
|
||||
process.env.FUSION_RUNTIME_DIR = tmpRoot;
|
||||
|
||||
const nativePath = join(dir, "pty.node");
|
||||
const helperPath = join(dir, "spawn-helper");
|
||||
// Precondition: not executable.
|
||||
expect(fs.statSync(nativePath).mode & 0o111).toBe(0);
|
||||
|
||||
ensureNodePtyNativePermissions();
|
||||
|
||||
expect(fs.statSync(nativePath).mode & 0o777).toBe(0o755);
|
||||
expect(fs.statSync(helperPath).mode & 0o777).toBe(0o755);
|
||||
});
|
||||
|
||||
maybe("is a no-op (does not throw) when no candidate dirs exist", () => {
|
||||
expect(() => ensureNodePtyNativePermissions()).not.toThrow();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user