Files
fusion/packages/dashboard/src/cli-session-transport.ts
2026-06-04 23:44:39 -07:00

344 lines
13 KiB
TypeScript

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