From 689a5a34740e1e605fac7e815f3d56d7ba801250 Mon Sep 17 00:00:00 2001
From: Foad Kesheh
Date: Wed, 15 Jul 2026 13:00:05 -0300
Subject: [PATCH 1/8] fix(bridge): stagger cursor spawns (keychain write race);
persist errorReason
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Investigation proved concurrent cursor-agent startups race on a macOS
Keychain WRITE during token refresh (threshold 2; both incident
signatures reproduced deterministically; 250ms stagger → 0/27 failures
at N=9). Harness registry gains spawnStaggerMs (cursor: 300); create and
relaunch serialize per-harness via lock-free slot reservation. Startup
crashes now persist errorReason (exit message + de-ANSI'd terminal tail,
capped) instead of status:'error' with no explanation.
---
bridge/src/create-ftown-session.ts | 24 +++++++++-
bridge/src/harness-registry.ts | 10 ++++
bridge/src/spawn-stagger.test.ts | 77 ++++++++++++++++++++++++++++++
bridge/src/spawn-stagger.ts | 44 +++++++++++++++++
bridge/src/terminal-pump.test.ts | 60 ++++++++++++++++++++++-
bridge/src/terminal-pump.ts | 54 ++++++++++++++++++++-
6 files changed, 266 insertions(+), 3 deletions(-)
create mode 100644 bridge/src/spawn-stagger.test.ts
create mode 100644 bridge/src/spawn-stagger.ts
diff --git a/bridge/src/create-ftown-session.ts b/bridge/src/create-ftown-session.ts
index aa6a4fb..080be99 100644
--- a/bridge/src/create-ftown-session.ts
+++ b/bridge/src/create-ftown-session.ts
@@ -4,7 +4,8 @@ import { basename, resolve } from 'node:path';
import { buildSessionCommand } from './agent-commands.js';
import { ensureCodexWorkdirTrust } from './codex-installer.js';
-import { harnessAcceptsPromptAsCliArg } from './harness-registry.js';
+import { HARNESSES, harnessAcceptsPromptAsCliArg, type HarnessSpec } from './harness-registry.js';
+import { staggerSpawn } from './spawn-stagger.js';
import { PROVIDER_AUTH_ENV, PROVIDER_RUNTIME_ENV, loadProviderEnv } from './provider-env-store.js';
import { registerSessionWorkspace } from './session-registry.js';
@@ -231,6 +232,23 @@ export function assertProviderAuthAvailable(
if (missing) throw missing;
}
+/**
+ * Per-harness spawn stagger, applied immediately before every runner.run
+ * (create AND relaunch). Harnesses with spawnStaggerMs set (currently cursor,
+ * whose startup token refresh races a macOS Keychain write under concurrent
+ * spawns) get their spawns serialized at that gap. NOTE: this delays the
+ * create/relaunch response by up to queueDepth × spawnStaggerMs — intended.
+ */
+async function staggerHarnessSpawn(shellType: ShellType | undefined): Promise {
+ if (!shellType) return;
+ // Widen: the `as const` registry narrows away optional fields it doesn't set.
+ const spec: HarnessSpec | undefined = HARNESSES[shellType];
+ const staggerMs = spec?.spawnStaggerMs;
+ if (staggerMs) {
+ await staggerSpawn(`spawn:${shellType}`, staggerMs);
+ }
+}
+
/** The stored-session fields relaunch derivation needs — satisfied by both live records and tombstones. */
export type RelaunchCommandSource = Pick<
Session,
@@ -325,6 +343,8 @@ export async function relaunchFtownSession(
await deps.store.saveSession(session);
await deps.centrifugo.publishSessionUpdate(deps.userId, session);
+ await staggerHarnessSpawn(session.shellType);
+
deps.runner.run(session.id, command, {
workingDir: session.workingDir,
env: session.env,
@@ -506,6 +526,8 @@ export async function createFtownSession(
ensureCodexWorkdirTrust(workingDir ?? process.cwd());
}
+ await staggerHarnessSpawn(effectiveInput.shellType);
+
deps.runner.run(sessionId, launchCommand, {
workingDir,
env: sessionEnv,
diff --git a/bridge/src/harness-registry.ts b/bridge/src/harness-registry.ts
index b0eb1a3..cad5910 100644
--- a/bridge/src/harness-registry.ts
+++ b/bridge/src/harness-registry.ts
@@ -47,6 +47,11 @@ export interface HarnessSpec {
validForLoop: boolean;
/** Accepted as a workflow child shell (WorkflowShell / ftown-workflows --shell). */
validForWorkflow: boolean;
+ /**
+ * Minimum gap (ms) enforced between concurrent spawns of this harness
+ * (see spawn-stagger.ts). Unset means spawns are not staggered.
+ */
+ spawnStaggerMs?: number;
}
export function buildCursorAgentCommand(options: {
@@ -176,6 +181,11 @@ export const HARNESSES = {
resumeField: 'cursorSessionId',
validForLoop: true,
validForWorkflow: true,
+ // Concurrent cursor-agent startups race on a macOS Keychain WRITE during
+ // token refresh (last-writer-wins; corrupts auth from 2 concurrent spawns).
+ // A 250ms spawn stagger fully eliminated it in testing (0/27 at N=9);
+ // 300ms adds margin.
+ spawnStaggerMs: 300,
},
codex: {
// Workdir comes from the runner cwd — codex needs no -C flag.
diff --git a/bridge/src/spawn-stagger.test.ts b/bridge/src/spawn-stagger.test.ts
new file mode 100644
index 0000000..981cd3c
--- /dev/null
+++ b/bridge/src/spawn-stagger.test.ts
@@ -0,0 +1,77 @@
+import { beforeEach, describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+import { resetSpawnStaggerForTest, staggerSpawn } from './spawn-stagger.js';
+
+interface FakeClock {
+ now: () => number;
+ advanceTo: (t: number) => void;
+ /** sleep(ms) records the caller's absolute wake time (now + ms). */
+ sleep: (ms: number) => Promise;
+ wakeTimes: number[];
+}
+
+function makeFakeClock(start = 0): FakeClock {
+ let current = start;
+ const wakeTimes: number[] = [];
+ return {
+ now: () => current,
+ advanceTo: (t: number) => { current = t; },
+ sleep: async (ms: number) => { wakeTimes.push(current + ms); },
+ wakeTimes,
+ };
+}
+
+describe('staggerSpawn', () => {
+ beforeEach(() => resetSpawnStaggerForTest());
+
+ it('serializes 9 concurrent callers at exactly 0, 300, 600, ... intervals', async () => {
+ const clock = makeFakeClock(0);
+ const slots: number[] = [];
+
+ await Promise.all(
+ Array.from({ length: 9 }, (_, i) => {
+ let slept = false;
+ return staggerSpawn('spawn:cursor', 300, {
+ now: clock.now,
+ // now() is frozen at 0, so the sleep duration IS the absolute slot.
+ sleep: async (ms) => { slept = true; slots[i] = ms; },
+ }).then(() => {
+ if (!slept) slots[i] = clock.now(); // zero-wait caller: slot = now
+ });
+ }),
+ );
+
+ assert.deepEqual(slots, [0, 300, 600, 900, 1200, 1500, 1800, 2100, 2400]);
+ });
+
+ it('a caller arriving after the queue drained gets slot=now (no residual delay)', async () => {
+ const clock = makeFakeClock(0);
+ await Promise.all(
+ Array.from({ length: 3 }, () =>
+ staggerSpawn('spawn:cursor', 300, { now: clock.now, sleep: clock.sleep }),
+ ),
+ );
+ assert.deepEqual(clock.wakeTimes, [300, 600]);
+
+ // Queue drained at t=600; arrive well after lastSlot + gap.
+ clock.advanceTo(10_000);
+ await staggerSpawn('spawn:cursor', 300, { now: clock.now, sleep: clock.sleep });
+ // No new sleep — resolved immediately at now.
+ assert.deepEqual(clock.wakeTimes, [300, 600]);
+
+ // And the next concurrent caller staggers off the NEW slot, not the old one.
+ await staggerSpawn('spawn:cursor', 300, { now: clock.now, sleep: clock.sleep });
+ assert.deepEqual(clock.wakeTimes, [300, 600, 10_300]);
+ });
+
+ it('different keys do not serialize each other', async () => {
+ const clock = makeFakeClock(0);
+ await Promise.all([
+ staggerSpawn('spawn:cursor', 300, { now: clock.now, sleep: clock.sleep }),
+ staggerSpawn('spawn:other', 300, { now: clock.now, sleep: clock.sleep }),
+ ]);
+ // Each key's first caller waits zero — no sleeps at all.
+ assert.deepEqual(clock.wakeTimes, []);
+ });
+});
diff --git a/bridge/src/spawn-stagger.ts b/bridge/src/spawn-stagger.ts
new file mode 100644
index 0000000..f008645
--- /dev/null
+++ b/bridge/src/spawn-stagger.ts
@@ -0,0 +1,44 @@
+/**
+ * Per-key spawn stagger: serializes concurrent callers of the same key so
+ * their spawns land at least `minGapMs` apart.
+ *
+ * Why: some harness CLIs (cursor-agent) refresh an auth token on startup with
+ * a read-modify-write against the macOS Keychain. Two or more concurrent
+ * startups race that write (pure last-writer-wins) and corrupt the stored
+ * credential. Spacing spawns by a small gap fully eliminates the race.
+ *
+ * Algorithm: a monotonic slot reservation per key. Each caller SYNCHRONOUSLY
+ * (before any await — this is what makes it lock-free under the single-threaded
+ * event loop) reserves `slot = max(now, lastSlot + minGapMs)`, records it as
+ * the new lastSlot, then sleeps until its slot. N concurrent callers therefore
+ * serialize at exactly `minGapMs` intervals; a caller arriving after the queue
+ * has drained (now >= lastSlot + minGapMs) gets slot = now and no delay.
+ */
+
+const lastSlotByKey = new Map();
+
+const defaultSleep = (ms: number): Promise =>
+ new Promise((resolve) => setTimeout(resolve, ms));
+
+export function staggerSpawn(
+ key: string,
+ minGapMs: number,
+ opts?: { now?: () => number; sleep?: (ms: number) => Promise },
+): Promise {
+ const now = opts?.now ?? Date.now;
+ const sleep = opts?.sleep ?? defaultSleep;
+
+ // Reserve the slot synchronously — no await may happen before this point.
+ const arrival = now();
+ const lastSlot = lastSlotByKey.get(key);
+ const slot = lastSlot === undefined ? arrival : Math.max(arrival, lastSlot + minGapMs);
+ lastSlotByKey.set(key, slot);
+
+ const waitMs = slot - arrival;
+ return waitMs > 0 ? sleep(waitMs) : Promise.resolve();
+}
+
+/** Clears all reserved slots. Test seam only. */
+export function resetSpawnStaggerForTest(): void {
+ lastSlotByKey.clear();
+}
diff --git a/bridge/src/terminal-pump.test.ts b/bridge/src/terminal-pump.test.ts
index 91bea49..a7104f8 100644
--- a/bridge/src/terminal-pump.test.ts
+++ b/bridge/src/terminal-pump.test.ts
@@ -23,6 +23,7 @@ interface Harness {
published: Array<{ sessionId: string; data: string }>;
hookEvents: Array<{ sessionId: string; event: Record }>;
savedStatuses: string[];
+ savedErrorReasons: Array;
publishedSessions: Session[];
unregistered: string[];
destroyed: string[];
@@ -37,6 +38,7 @@ function makeHarness(deps: Partial = {}): Harness {
published: [],
hookEvents: [],
savedStatuses: [],
+ savedErrorReasons: [],
publishedSessions: [],
unregistered: [],
destroyed: [],
@@ -47,7 +49,13 @@ function makeHarness(deps: Partial = {}): Harness {
store: {
appendTerminalData: async (sessionId, data) => { h.appended.push({ sessionId, data }); },
loadSession: async () => h.session,
- saveSession: async (session) => { h.savedStatuses.push(session.status); },
+ saveSession: async (session) => {
+ h.savedStatuses.push(session.status);
+ h.savedErrorReasons.push(session.errorReason);
+ },
+ // The "flushed log" is everything appended so far for the session.
+ loadTerminalLog: async (sessionId) =>
+ h.appended.filter((a) => a.sessionId === sessionId).map((a) => a.data).join(''),
},
terminalManager: {
write: (_sid: string, data: string) => { h.written.push(data); },
@@ -135,6 +143,56 @@ describe('TerminalPump lifecycle handlers', () => {
assert.deepEqual(h.destroyed, ['s1']);
});
+ it('on error: persists errorReason with the message and recent terminal tail, ANSI stripped', async () => {
+ const h = makeHarness();
+ const runner = makeRunnerStub();
+ h.pump.attach(runner as unknown as Pick);
+
+ // Colored, multi-line startup output still buffered when the crash lands.
+ h.pump.handleData('s1', '\u001b[31mkeychain\u001b[0m write\nfailed: token refresh\u001b[2K\r');
+
+ runner.emit('error', 's1', new Error('Process exited with code 1'));
+ await settle();
+
+ assert.deepEqual(h.savedStatuses, ['error']);
+ const reason = h.savedErrorReasons[0];
+ assert.ok(reason, 'errorReason must be persisted');
+ assert.ok(reason.startsWith('Process exited with code 1 — '), reason);
+ assert.ok(reason.includes('keychain write failed: token refresh'), reason);
+ assert.ok(!/\u001b/.test(reason), 'ANSI escapes must be stripped');
+ assert.equal(h.publishedSessions[0]?.errorReason, reason);
+ });
+
+ it('on error: caps errorReason at ~400 chars with a ~300-char tail', async () => {
+ const h = makeHarness();
+ const runner = makeRunnerStub();
+ h.pump.attach(runner as unknown as Pick);
+
+ h.pump.handleData('s1', 'x'.repeat(2000) + ' TAIL_MARKER_END');
+ runner.emit('error', 's1', new Error('boom'));
+ await settle();
+
+ const reason = h.savedErrorReasons[0];
+ assert.ok(reason);
+ assert.ok(reason.length <= 400, `length ${reason.length} exceeds cap`);
+ assert.ok(reason.startsWith('boom — '));
+ // The tail keeps the END of the output (the most recent chars).
+ assert.ok(reason.endsWith('TAIL_MARKER_END'), reason.slice(-40));
+ // Tail portion is capped at ~300 chars.
+ assert.ok(reason.length - 'boom — '.length <= 300);
+ });
+
+ it('on error with no terminal output: errorReason is just the message', async () => {
+ const h = makeHarness();
+ const runner = makeRunnerStub();
+ h.pump.attach(runner as unknown as Pick);
+
+ runner.emit('error', 's1', new Error('spawn ENOENT'));
+ await settle();
+
+ assert.equal(h.savedErrorReasons[0], 'spawn ENOENT');
+ });
+
it('withSessionWrite serializes tasks per session', async () => {
const h = makeHarness();
const order: string[] = [];
diff --git a/bridge/src/terminal-pump.ts b/bridge/src/terminal-pump.ts
index 11816f1..ef7922f 100644
--- a/bridge/src/terminal-pump.ts
+++ b/bridge/src/terminal-pump.ts
@@ -6,7 +6,7 @@ import type { Session } from './types.js';
export type SyntheticStopReason = 'complete' | 'error' | 'stopped';
export interface TerminalPumpDeps {
- store: Pick;
+ store: Pick;
terminalManager: Pick;
publishTerminalData: (sessionId: string, data: string) => void;
publishSessionUpdate: (session: Session) => Promise;
@@ -20,6 +20,27 @@ export interface TerminalPumpDeps {
const FLUSH_INTERVAL_MS = 16;
const MAX_BUFFER_BYTES = 32_000;
+/** Raw terminal chars considered for the errorReason tail (pre-sanitize). */
+const ERROR_TAIL_RAW_CHARS = 1200;
+/** Max sanitized tail chars kept in errorReason. */
+const ERROR_TAIL_MAX_CHARS = 300;
+/** Hard cap on the whole errorReason string. */
+const ERROR_REASON_MAX_CHARS = 400;
+
+// CSI (\x1b[...X), OSC (\x1b]...BEL/ST), and any leftover lone ESC sequences.
+const ANSI_ESCAPE_RE = new RegExp(
+ '\\x1b\\[[0-9;?]*[ -/]*[@-~]|\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)?|\\x1b[@-_]?',
+ 'g',
+);
+
+/** Strip ANSI escapes and collapse all whitespace/control runs to single spaces. */
+function sanitizeTerminalTail(raw: string): string {
+ return raw
+ .replace(ANSI_ESCAPE_RE, '')
+ .replace(/[\s\u0000-\u001f\u007f]+/g, ' ')
+ .trim();
+}
+
/**
* Terminal output pump: buffers runner PTY output per session (coalescing into
* ≤16ms / ≤32KB flushes to the store + transports), and owns the runner
@@ -120,13 +141,44 @@ export class TerminalPump {
});
}
+ /**
+ * ` — ` (ANSI-stripped, whitespace
+ * collapsed, capped at ~400 chars) so a startup crash's cause is readable
+ * straight off the session record. The tail comes from the flushed terminal
+ * log; flush() fires its append without awaiting, so the chunk buffered at
+ * error time is re-appended if it hasn't landed on disk yet.
+ */
+ private async buildErrorReason(
+ sessionId: string,
+ error: Error,
+ pendingAtError: string,
+ ): Promise {
+ let log = '';
+ try {
+ log = await this.deps.store.loadTerminalLog(sessionId);
+ } catch {
+ // Tail is best-effort — fall back to whatever was buffered at error time.
+ }
+ const source = pendingAtError && !log.endsWith(pendingAtError)
+ ? log + pendingAtError
+ : log;
+ const tail = sanitizeTerminalTail(source.slice(-ERROR_TAIL_RAW_CHARS))
+ .slice(-ERROR_TAIL_MAX_CHARS);
+ const reason = tail ? `${error.message} — ${tail}` : error.message;
+ return reason.slice(0, ERROR_REASON_MAX_CHARS);
+ }
+
private handleError(sessionId: string, error: Error): void {
+ // Capture what's still buffered BEFORE flush drains it — the errorReason
+ // tail needs it if the (unawaited) flush append hasn't hit the store yet.
+ const pendingAtError = this.outputBuffers.get(sessionId) ?? '';
this.flush(sessionId);
this.publishSyntheticStop(sessionId, 'error');
this.withSessionWrite(sessionId, async () => {
const session = await this.deps.store.loadSession(sessionId);
if (session) {
session.status = 'error';
+ session.errorReason = await this.buildErrorReason(sessionId, error, pendingAtError);
session.updatedAt = new Date().toISOString();
await this.deps.store.saveSession(session);
await this.deps.publishSessionUpdate(session);
From 9a396fcf4dfe5ade8aa0144e3ee7723e16f3f30e Mon Sep 17 00:00:00 2001
From: Foad Kesheh
Date: Wed, 15 Jul 2026 13:15:30 -0300
Subject: [PATCH 2/8] feat: per-session token/cost usage from harness-native
transcripts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
usage-collector extracts SessionUsage (4 token classes, models, cost)
from claude transcripts (streamed, deduped by message id — multi-line
assistant messages double-count naively) and codex rollouts (last
total_token_usage; cached decomposed out of input). Hand-maintained
model-pricing table; unknown models report tokens, omit cost. Collected
automatically on session complete/error (terminal-pump) and on demand
(controller). Surfaces: ftown-sessions usage , GET
/api/sessions/:id/usage, get_session_usage RPC, Terminal header +
session-row display with detail tooltip. cursor/grok have no structured
source and return null; opencode TODO.
---
bridge/src/command-rpc.ts | 15 ++
bridge/src/ftown-sessions-cli.ts | 77 +++++++
bridge/src/index.ts | 3 +
bridge/src/local-api-server.ts | 19 ++
bridge/src/model-pricing.ts | 61 ++++++
bridge/src/session-controller.test.ts | 87 +++++++-
bridge/src/session-controller.ts | 36 ++-
bridge/src/terminal-pump.test.ts | 88 +++++++-
bridge/src/terminal-pump.ts | 38 +++-
bridge/src/types.ts | 23 +-
bridge/src/usage-collector.test.ts | 204 +++++++++++++++++
bridge/src/usage-collector.ts | 301 ++++++++++++++++++++++++++
bridge/src/wire-types.ts | 17 ++
ui/src/components/Dashboard.tsx | 1 +
ui/src/components/SessionList.tsx | 17 ++
ui/src/components/Terminal.tsx | 33 ++-
ui/src/lib/format-usage.ts | 29 +++
ui/src/types.ts | 17 +-
18 files changed, 1047 insertions(+), 19 deletions(-)
create mode 100644 bridge/src/model-pricing.ts
create mode 100644 bridge/src/usage-collector.test.ts
create mode 100644 bridge/src/usage-collector.ts
create mode 100644 ui/src/lib/format-usage.ts
diff --git a/bridge/src/command-rpc.ts b/bridge/src/command-rpc.ts
index 27f9e8e..166cfa2 100644
--- a/bridge/src/command-rpc.ts
+++ b/bridge/src/command-rpc.ts
@@ -16,6 +16,7 @@ import type {
DeleteLoopPayload,
GetHistoryPayload,
GetLoopRunsPayload,
+ GetSessionUsagePayload,
RemoveSessionPayload,
RenameSessionPayload,
RunLoopNowPayload,
@@ -204,6 +205,20 @@ export function createCommandHandler(deps: CommandRpcDeps): (command: Command) =
break;
}
+ case 'get_session_usage': {
+ const payload = command.payload as GetSessionUsagePayload;
+ if (!payload.sessionId) {
+ response = { requestId: command.requestId, success: false, error: 'Missing sessionId' };
+ break;
+ }
+
+ const result = await sessionController.usage(payload.sessionId);
+ response = result.ok
+ ? { requestId: command.requestId, success: true, data: { usage: result.usage } }
+ : { requestId: command.requestId, success: false, error: result.message };
+ break;
+ }
+
case 'create_loop': {
const payload = command.payload as CreateLoopPayload;
// bridgeId is forced to THIS bridge inside the controller (the routing
diff --git a/bridge/src/ftown-sessions-cli.ts b/bridge/src/ftown-sessions-cli.ts
index 40419e0..36c2996 100644
--- a/bridge/src/ftown-sessions-cli.ts
+++ b/bridge/src/ftown-sessions-cli.ts
@@ -16,6 +16,7 @@ import {
type LoopSchedule,
type MailMessage,
type Session,
+ type SessionUsage,
} from './wire-types.js';
function loadBridge(): BridgePointer {
@@ -309,6 +310,69 @@ function positionals(args: string[], valueFlags: string[]): string[] {
return out;
}
+
+interface UsageRow {
+ id: string;
+ name: string;
+ usage: SessionUsage | null;
+}
+
+async function fetchUsageRow(id: string): Promise {
+ const [{ data: usageData }, sessionData] = await Promise.all([
+ api('GET', `/api/sessions/${id}/usage`),
+ api('GET', `/api/sessions/${id}`).then(
+ (r) => r.data,
+ () => null,
+ ),
+ ]);
+ const session = (sessionData as { session?: Session } | null)?.session;
+ return {
+ id,
+ name: session?.name ?? '',
+ usage: (usageData as { usage?: SessionUsage | null }).usage ?? null,
+ };
+}
+
+function formatUsageTable(rows: UsageRow[]): string {
+ const header = ['id', 'name', 'models', 'in', 'out', 'cacheR', 'cacheW', 'total', '$cost'];
+ const cost = (u: SessionUsage | null): string =>
+ u?.costUsd === undefined ? '-' : `$${u.costUsd.toFixed(4)}`;
+ const body = rows.map((r) => [
+ r.id.slice(0, 8),
+ r.name,
+ r.usage ? r.usage.models.join(',') : '-',
+ r.usage ? String(r.usage.inputTokens) : '-',
+ r.usage ? String(r.usage.outputTokens) : '-',
+ r.usage ? String(r.usage.cacheReadTokens) : '-',
+ r.usage ? String(r.usage.cacheWriteTokens) : '-',
+ r.usage ? String(r.usage.totalTokens) : '-',
+ cost(r.usage),
+ ]);
+ const collected = rows.map((r) => r.usage).filter((u): u is SessionUsage => u !== null);
+ const sum = (pick: (u: SessionUsage) => number): number =>
+ collected.reduce((acc, u) => acc + pick(u), 0);
+ const costs = collected.map((u) => u.costUsd).filter((c): c is number => c !== undefined);
+ const totalCost =
+ costs.length > 0 ? `$${costs.reduce((a, b) => a + b, 0).toFixed(4)}` : '-';
+ body.push([
+ 'TOTAL',
+ '',
+ '',
+ String(sum((u) => u.inputTokens)),
+ String(sum((u) => u.outputTokens)),
+ String(sum((u) => u.cacheReadTokens)),
+ String(sum((u) => u.cacheWriteTokens)),
+ String(sum((u) => u.totalTokens)),
+ totalCost,
+ ]);
+ const widths = header.map((h, i) =>
+ Math.max(h.length, ...body.map((row) => row[i].length)),
+ );
+ const render = (row: string[]): string =>
+ row.map((cell, i) => cell.padEnd(widths[i])).join(' ').trimEnd();
+ return [render(header), ...body.map(render)].join('\n');
+}
+
function usage(): void {
console.error(`Usage: ftown-sessions [options]
@@ -322,6 +386,7 @@ Commands:
tell Send mail to another session's inbox
inbox | mail Read own inbox (requires FTOWN_SESSION_ID)
running Check if session PTY is running
+ usage Token/cost usage per session (--json for raw)
remove Stop and remove a session (archived as a tombstone)
archive List archived (removed) sessions
revive Recreate a removed session from its tombstone
@@ -596,6 +661,18 @@ async function main(): Promise {
break;
}
+ case 'usage': {
+ const ids = rest.filter((a) => !a.startsWith('--'));
+ if (ids.length === 0) throw new Error('Missing session-id');
+ const rows = await Promise.all(ids.map(fetchUsageRow));
+ if (hasFlag(rest, '--json')) {
+ console.log(JSON.stringify({ sessions: rows }, null, 2));
+ } else {
+ console.log(formatUsageTable(rows));
+ }
+ break;
+ }
+
case 'remove': {
const id = rest.find((a) => !a.startsWith('--'));
if (!id) throw new Error('Missing session-id');
diff --git a/bridge/src/index.ts b/bridge/src/index.ts
index ef2862c..e78c640 100644
--- a/bridge/src/index.ts
+++ b/bridge/src/index.ts
@@ -38,6 +38,7 @@ import { createCommandHandler } from './command-rpc.js';
import { removeFtownSession } from './remove-ftown-session.js';
import { SessionResurrection } from './session-resurrection.js';
import { TerminalPump } from './terminal-pump.js';
+import { collectSessionUsage } from './usage-collector.js';
import { AgentSessionIdPersister } from './session-ids.js';
import { fetchBridgeToken, refreshBridgeToken, type BridgeAuthResponse } from './bridge-auth.js';
import { listLoops } from './loop-store.js';
@@ -333,6 +334,7 @@ program
publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
publishHookEvent: (sid, event) => centrifugo.publishHookEvent(userId, sid, event),
unregisterSession: (sid) => unregisterSession(sid),
+ collectUsage: (session) => collectSessionUsage(session),
});
pump.attach(runner);
@@ -370,6 +372,7 @@ program
publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
removeSession: (id, options) => removeFtownSession({ store, runner, centrifugo, userId }, id, options),
sessionFactory: sessionFactoryDeps,
+ collectUsage: (session) => collectSessionUsage(session),
publishSyntheticStop: (sid, reason) => pump.publishSyntheticStop(sid, reason),
withSessionWrite: (sid, task) => pump.withSessionWrite(sid, task),
unregisterSession: (sid) => unregisterSession(sid),
diff --git a/bridge/src/local-api-server.ts b/bridge/src/local-api-server.ts
index aca38fe..3829ae3 100644
--- a/bridge/src/local-api-server.ts
+++ b/bridge/src/local-api-server.ts
@@ -27,6 +27,7 @@ import {
type CreateFtownSessionDeps,
} from './create-ftown-session.js';
import { removeFtownSession } from './remove-ftown-session.js';
+import { collectSessionUsage } from './usage-collector.js';
import { toWireSession } from './session-wire.js';
export interface HookPayload {
@@ -235,6 +236,7 @@ export class LocalApiServer extends EventEmitter {
publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
removeSession: (id, options) =>
removeFtownSession({ store, runner, centrifugo, userId }, id, options),
+ collectUsage: (session) => collectSessionUsage(session),
...(this.sessionDeps ? { sessionFactory: this.sessionDeps } : {}),
});
return this.sessionController;
@@ -528,6 +530,7 @@ export class LocalApiServer extends EventEmitter {
const sessionInboxMatch = path.match(/^\/api\/sessions\/([^/]+)\/inbox$/);
const sessionResizeMatch = path.match(/^\/api\/sessions\/([^/]+)\/resize$/);
const sessionRunningMatch = path.match(/^\/api\/sessions\/([^/]+)\/running$/);
+ const sessionUsageMatch = path.match(/^\/api\/sessions\/([^/]+)\/usage$/);
// GET /api/sessions/:id
if (sessionMatch && req.method === 'GET') {
@@ -914,6 +917,22 @@ export class LocalApiServer extends EventEmitter {
return;
}
+ // GET /api/sessions/:id/usage — per-session token/cost usage
+ if (sessionUsageMatch && req.method === 'GET') {
+ const controller = this.getSessionController();
+ if (!controller) {
+ jsonResponse(res, 503, { error: 'Server not ready' });
+ return;
+ }
+ const result = await controller.usage(sessionUsageMatch[1]);
+ if (!result.ok) {
+ jsonResponse(res, 404, { error: result.message });
+ return;
+ }
+ jsonResponse(res, 200, { usage: result.usage });
+ return;
+ }
+
// GET /api/sessions/:id/running
if (sessionRunningMatch && req.method === 'GET') {
const sessionId = sessionRunningMatch[1];
diff --git a/bridge/src/model-pricing.ts b/bridge/src/model-pricing.ts
new file mode 100644
index 0000000..4e0944c
--- /dev/null
+++ b/bridge/src/model-pricing.ts
@@ -0,0 +1,61 @@
+// Per-model token pricing used to estimate session cost in usage-collector.ts.
+//
+// ⚠️ These rates are ESTIMATES maintained by hand from public price sheets
+// (USD per 1M tokens). They are not fetched from any API and can drift from
+// the real billed rates — treat costUsd as an approximation. Models with no
+// entry here (unknown/new/synthetic ids) yield costUsd === undefined rather
+// than a guessed number.
+
+export interface ModelPrice {
+ /** USD per 1M uncached input tokens. */
+ inPerM: number;
+ /** USD per 1M output tokens. */
+ outPerM: number;
+ /** USD per 1M cache-read input tokens (~0.1x input on Anthropic). */
+ cacheReadPerM: number;
+ /** USD per 1M cache-write input tokens (~1.25x input on Anthropic; 0 on OpenAI). */
+ cacheWritePerM: number;
+}
+
+/**
+ * Keyed by model-id prefix — see priceFor() for the matching rule. Anthropic
+ * ids sometimes carry date suffixes (claude-haiku-4-5-20251001), OpenAI codex
+ * ids carry variant suffixes (gpt-5.6-luna); prefix matching absorbs both.
+ */
+export const PRICES: Record = {
+ // Anthropic — cache read = 0.1x input, cache write (5m) = 1.25x input.
+ 'claude-fable-5': { inPerM: 10, outPerM: 50, cacheReadPerM: 1, cacheWritePerM: 12.5 },
+ 'claude-opus-4-8': { inPerM: 5, outPerM: 25, cacheReadPerM: 0.5, cacheWritePerM: 6.25 },
+ 'claude-opus-4-7': { inPerM: 5, outPerM: 25, cacheReadPerM: 0.5, cacheWritePerM: 6.25 },
+ 'claude-opus-4-6': { inPerM: 5, outPerM: 25, cacheReadPerM: 0.5, cacheWritePerM: 6.25 },
+ 'claude-opus-4-5': { inPerM: 5, outPerM: 25, cacheReadPerM: 0.5, cacheWritePerM: 6.25 },
+ 'claude-sonnet-5': { inPerM: 3, outPerM: 15, cacheReadPerM: 0.3, cacheWritePerM: 3.75 },
+ 'claude-sonnet-4-5': { inPerM: 3, outPerM: 15, cacheReadPerM: 0.3, cacheWritePerM: 3.75 },
+ 'claude-sonnet-4-6': { inPerM: 3, outPerM: 15, cacheReadPerM: 0.3, cacheWritePerM: 3.75 },
+ 'claude-haiku-4-5': { inPerM: 1, outPerM: 5, cacheReadPerM: 0.1, cacheWritePerM: 1.25 },
+
+ // OpenAI codex rollouts (model ids observed in ~/.codex/sessions rollout
+ // files: gpt-5.4, gpt-5.4-mini, gpt-5.5, gpt-5.6-luna/sol/terra). Rates
+ // estimated from the gpt-5 family price sheet; cached input = 0.1x input,
+ // cache writes are free on OpenAI.
+ 'gpt-5.4-mini': { inPerM: 0.25, outPerM: 2, cacheReadPerM: 0.025, cacheWritePerM: 0 },
+ 'gpt-5.4': { inPerM: 1.25, outPerM: 10, cacheReadPerM: 0.125, cacheWritePerM: 0 },
+ 'gpt-5.5': { inPerM: 1.25, outPerM: 10, cacheReadPerM: 0.125, cacheWritePerM: 0 },
+ 'gpt-5.6': { inPerM: 1.25, outPerM: 10, cacheReadPerM: 0.125, cacheWritePerM: 0 },
+};
+
+/**
+ * Longest-prefix match so date/variant suffixes resolve to their base entry
+ * (claude-opus-4-8-20260115 → claude-opus-4-8; gpt-5.6-luna → gpt-5.6) while
+ * more specific entries win over shorter ones (gpt-5.4-mini over gpt-5.4).
+ * Returns undefined for unknown models — callers must then omit costUsd.
+ */
+export function priceFor(model: string): ModelPrice | undefined {
+ let best: string | undefined;
+ for (const key of Object.keys(PRICES)) {
+ if (model === key || model.startsWith(key)) {
+ if (best === undefined || key.length > best.length) best = key;
+ }
+ }
+ return best === undefined ? undefined : PRICES[best];
+}
diff --git a/bridge/src/session-controller.test.ts b/bridge/src/session-controller.test.ts
index 74e4a4d..6d978ea 100644
--- a/bridge/src/session-controller.test.ts
+++ b/bridge/src/session-controller.test.ts
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { SessionController } from './session-controller.js';
import type { SessionControllerDeps, SessionStoreLike } from './session-controller.js';
-import type { Session } from './types.js';
+import type { Session, SessionUsage } from './types.js';
function makeSession(overrides: Partial = {}): Session {
return {
@@ -209,3 +209,88 @@ describe('SessionController.retry', () => {
});
});
});
+
+describe('SessionController.usage', () => {
+ function makeUsage(overrides: Partial = {}): SessionUsage {
+ return {
+ inputTokens: 1,
+ outputTokens: 2,
+ cacheReadTokens: 3,
+ cacheWriteTokens: 4,
+ totalTokens: 10,
+ models: ['claude-sonnet-5'],
+ costUsd: 0.0001,
+ harness: 'claude',
+ collectedAt: '2026-01-01T00:00:00.000Z',
+ ...overrides,
+ };
+ }
+
+ it('returns not_found for an unknown session', async () => {
+ const { controller } = setup([]);
+ const result = await controller.usage('nope');
+ assert.deepEqual(result, { ok: false, code: 'not_found', message: 'Session not found' });
+ });
+
+ it('passes through persisted usage without invoking the collector', async () => {
+ const persisted = makeUsage({ totalTokens: 42 });
+ let collectorCalls = 0;
+ const { controller } = setup(
+ [makeSession({ usage: persisted })],
+ { collectUsage: async () => { collectorCalls += 1; return makeUsage(); } },
+ );
+
+ const result = await controller.usage('sess-1');
+ assert.ok(result.ok);
+ assert.deepEqual(result.usage, persisted);
+ assert.equal(collectorCalls, 0);
+ });
+
+ it('collects on demand for a terminal session and persists + publishes the result', async () => {
+ const collectedUsage = makeUsage();
+ const { controller, store, published } = setup(
+ [makeSession({ status: 'completed' })],
+ { collectUsage: async () => collectedUsage },
+ );
+
+ const result = await controller.usage('sess-1');
+ assert.ok(result.ok);
+ assert.deepEqual(result.usage, collectedUsage);
+ assert.equal(store.saved.length, 1);
+ assert.deepEqual(store.saved[0].usage, collectedUsage);
+ assert.deepEqual(published[0]?.usage, collectedUsage);
+ });
+
+ it('collects on demand for a RUNNING session without persisting (numbers still moving)', async () => {
+ const collectedUsage = makeUsage();
+ const { controller, store, published } = setup(
+ [makeSession({ status: 'running' })],
+ { collectUsage: async () => collectedUsage },
+ );
+
+ const result = await controller.usage('sess-1');
+ assert.ok(result.ok);
+ assert.deepEqual(result.usage, collectedUsage);
+ assert.equal(store.saved.length, 0);
+ assert.equal(published.length, 0);
+ });
+
+ it('returns null usage when the collector yields null, without persisting', async () => {
+ const { controller, store } = setup(
+ [makeSession({ status: 'completed' })],
+ { collectUsage: async () => null },
+ );
+
+ const result = await controller.usage('sess-1');
+ assert.ok(result.ok);
+ assert.equal(result.usage, null);
+ assert.equal(store.saved.length, 0);
+ });
+
+ it('returns null usage when no collector is wired', async () => {
+ const { controller } = setup([makeSession({ status: 'completed' })]);
+ const result = await controller.usage('sess-1');
+ assert.ok(result.ok);
+ assert.equal(result.usage, null);
+ });
+});
diff --git a/bridge/src/session-controller.ts b/bridge/src/session-controller.ts
index 4a1c91d..c8687c8 100644
--- a/bridge/src/session-controller.ts
+++ b/bridge/src/session-controller.ts
@@ -6,7 +6,7 @@ import {
} from './create-ftown-session.js';
import type { RemoveFtownSessionOptions } from './remove-ftown-session.js';
-import type { Session } from './types.js';
+import type { Session, SessionUsage } from './types.js';
/**
* Transport-agnostic session operations, defined ONCE and shared by the two
@@ -61,6 +61,9 @@ export interface SessionControllerDeps {
): Promise;
/** Full factory bundle, required by create/retry only. */
sessionFactory?: CreateFtownSessionDeps;
+ /** Optional: extract token/cost usage from harness-native session files.
+ * Required only by usage(); omitted in adapters that never call it. */
+ collectUsage?(session: Session): Promise;
// ---- Runtime closures still owned by index.ts. They are injected rather
// than restructured out of index.ts (a later task splits that file); only
// stop/clearTerminal need them, so the HTTP adapter may omit them.
@@ -118,6 +121,37 @@ export class SessionController {
return this.deps.store.loadSession(sessionId);
}
+ /**
+ * Per-session token/cost usage. Returns the persisted snapshot when
+ * present; otherwise collects on demand (covers live sessions and sessions
+ * that finished before usage collection existed). An on-demand result is
+ * persisted + published only when the session is terminal — a live
+ * session's numbers are still moving, so caching them would go stale.
+ */
+ async usage(
+ sessionId: string,
+ ): Promise> {
+ const session = await this.deps.store.loadSession(sessionId);
+ if (!session) {
+ return { ok: false, code: 'not_found', message: 'Session not found' };
+ }
+ if (session.usage) {
+ return { ok: true, usage: session.usage };
+ }
+ const collect = this.deps.collectUsage;
+ if (!collect) {
+ return { ok: true, usage: null };
+ }
+ const usage = await collect(session);
+ if (usage && (session.status === 'completed' || session.status === 'error')) {
+ session.usage = usage;
+ session.updatedAt = new Date().toISOString();
+ await this.deps.store.saveSession(session);
+ await this.deps.publishSessionUpdate(session);
+ }
+ return { ok: true, usage };
+ }
+
/** Re-run a finished/dead session's stored command verbatim. */
async retry(sessionId: string): Promise> {
const factory = this.require(this.deps.sessionFactory, 'sessionFactory');
diff --git a/bridge/src/terminal-pump.test.ts b/bridge/src/terminal-pump.test.ts
index a7104f8..8c12e0c 100644
--- a/bridge/src/terminal-pump.test.ts
+++ b/bridge/src/terminal-pump.test.ts
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { TerminalPump, type TerminalPumpDeps } from './terminal-pump.js';
import type { ProcessRunner } from './claude-runner.js';
-import type { Session } from './types.js';
+import type { Session, SessionUsage } from './types.js';
function makeSession(overrides: Partial = {}): Session {
return {
@@ -213,6 +213,92 @@ describe('TerminalPump lifecycle handlers', () => {
});
});
+describe('TerminalPump usage collection', () => {
+ function makeUsage(overrides: Partial = {}): SessionUsage {
+ return {
+ inputTokens: 10,
+ outputTokens: 20,
+ cacheReadTokens: 30,
+ cacheWriteTokens: 40,
+ totalTokens: 100,
+ models: ['claude-sonnet-5'],
+ costUsd: 0.001,
+ harness: 'claude',
+ collectedAt: new Date().toISOString(),
+ ...overrides,
+ };
+ }
+
+ async function waitFor(cond: () => boolean): Promise {
+ for (let i = 0; i < 100 && !cond(); i++) {
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+ }
+
+ it('on complete: runs the injected collector and persists+publishes non-null usage', async () => {
+ const usage = makeUsage();
+ const collected: string[] = [];
+ const h = makeHarness({
+ collectUsage: async (session) => {
+ collected.push(session.id);
+ return usage;
+ },
+ });
+ const runner = makeRunnerStub();
+ h.pump.attach(runner as unknown as Pick);
+
+ runner.emit('complete', 's1');
+ await waitFor(() => h.session.usage !== undefined);
+
+ assert.deepEqual(collected, ['s1']);
+ assert.deepEqual(h.session.usage, usage);
+ const last = h.publishedSessions[h.publishedSessions.length - 1];
+ assert.deepEqual(last?.usage, usage);
+ // status persist first, usage persist second
+ assert.deepEqual(h.savedStatuses, ['completed', 'completed']);
+ });
+
+ it('on error: also collects usage', async () => {
+ const usage = makeUsage({ harness: 'codex' });
+ const h = makeHarness({ collectUsage: async () => usage });
+ const runner = makeRunnerStub();
+ h.pump.attach(runner as unknown as Pick);
+
+ runner.emit('error', 's1', new Error('boom'));
+ await waitFor(() => h.session.usage !== undefined);
+
+ assert.deepEqual(h.session.usage, usage);
+ });
+
+ it('a null collector result never clobbers existing usage or triggers an extra save', async () => {
+ const existing = makeUsage({ totalTokens: 999 });
+ const h = makeHarness({ collectUsage: async () => null });
+ h.session.usage = existing;
+ const runner = makeRunnerStub();
+ h.pump.attach(runner as unknown as Pick);
+
+ runner.emit('complete', 's1');
+ await settle();
+ await settle();
+
+ assert.deepEqual(h.session.usage, existing);
+ // Only the status persist — no second save from the usage path.
+ assert.deepEqual(h.savedStatuses, ['completed']);
+ });
+
+ it('without a collector dep, completion behaves exactly as before', async () => {
+ const h = makeHarness();
+ const runner = makeRunnerStub();
+ h.pump.attach(runner as unknown as Pick);
+
+ runner.emit('complete', 's1');
+ await settle();
+
+ assert.equal(h.session.usage, undefined);
+ assert.deepEqual(h.savedStatuses, ['completed']);
+ });
+});
+
type RunnerListener = (...args: [string] | [string, string] | [string, Error]) => void;
function makeRunnerStub(): { on: (event: string, listener: RunnerListener) => unknown; emit: (event: string, ...args: unknown[]) => void } {
diff --git a/bridge/src/terminal-pump.ts b/bridge/src/terminal-pump.ts
index ef7922f..63476ea 100644
--- a/bridge/src/terminal-pump.ts
+++ b/bridge/src/terminal-pump.ts
@@ -1,7 +1,7 @@
import type { ProcessRunner } from './claude-runner.js';
import type { SessionStore } from './session-store.js';
import type { TerminalManager } from './terminal-manager.js';
-import type { Session } from './types.js';
+import type { Session, SessionUsage } from './types.js';
export type SyntheticStopReason = 'complete' | 'error' | 'stopped';
@@ -12,6 +12,13 @@ export interface TerminalPumpDeps {
publishSessionUpdate: (session: Session) => Promise;
publishHookEvent: (sessionId: string, event: Record) => Promise;
unregisterSession: (sessionId: string) => void;
+ /**
+ * Optional (injectable for tests): extract token/cost usage from the
+ * session's harness-native transcript. Fired-and-forgotten after the
+ * terminal status persist on complete/error; a null result never clobbers
+ * previously persisted usage.
+ */
+ collectUsage?: (session: Session) => Promise;
/** Test seams; production uses the defaults. */
flushIntervalMs?: number;
maxBufferBytes?: number;
@@ -122,6 +129,33 @@ export class TerminalPump {
});
}
+ /**
+ * Fire-and-forget usage collection after a terminal status persist. Runs
+ * OUTSIDE the caller's write (the collect can take seconds on huge
+ * transcripts); the persist itself is serialized via withSessionWrite.
+ * Guard: a null collection result never clobbers previously saved usage.
+ */
+ private collectAndPersistUsage(sessionId: string): void {
+ const collect = this.deps.collectUsage;
+ if (!collect) return;
+ void (async () => {
+ const session = await this.deps.store.loadSession(sessionId);
+ if (!session) return;
+ const usage = await collect(session);
+ if (!usage) return;
+ await this.withSessionWrite(sessionId, async () => {
+ const fresh = await this.deps.store.loadSession(sessionId);
+ if (!fresh) return;
+ fresh.usage = usage;
+ fresh.updatedAt = new Date().toISOString();
+ await this.deps.store.saveSession(fresh);
+ await this.deps.publishSessionUpdate(fresh);
+ });
+ })().catch((err) => {
+ console.error(`[Bridge] Failed to collect usage for session ${sessionId}:`, err);
+ });
+ }
+
private handleComplete(sessionId: string): void {
this.flush(sessionId);
this.publishSyntheticStop(sessionId, 'complete');
@@ -137,6 +171,7 @@ export class TerminalPump {
}).catch((err) => {
console.error(`[Bridge] Failed to handle completion for session ${sessionId}:`, err);
}).finally(() => {
+ this.collectAndPersistUsage(sessionId);
this.deps.unregisterSession(sessionId);
});
}
@@ -187,6 +222,7 @@ export class TerminalPump {
}).catch((err) => {
console.error(`[Bridge] Failed to handle error for session ${sessionId}:`, err);
}).finally(() => {
+ this.collectAndPersistUsage(sessionId);
this.deps.unregisterSession(sessionId);
this.deps.terminalManager.destroy(sessionId);
});
diff --git a/bridge/src/types.ts b/bridge/src/types.ts
index 84012a6..cf32b09 100644
--- a/bridge/src/types.ts
+++ b/bridge/src/types.ts
@@ -6,6 +6,19 @@ import type { LoopHarness, ShellType } from './harness-registry.js';
export type SessionRuntime = 'tmux' | 'direct';
+/** Per-session token/cost usage, extracted from harness-native session files. */
+export interface SessionUsage {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ totalTokens: number; // sum of the four
+ models: string[]; // distinct, order of first appearance
+ costUsd?: number; // omitted when any model lacks pricing
+ harness: string; // which extractor produced it
+ collectedAt: string; // ISO
+}
+
export interface Session {
id: string;
name: string;
@@ -26,6 +39,7 @@ export interface Session {
runtime?: SessionRuntime;
errorReason?: string;
loopId?: string; // set on loop-run sessions; groups the run under its Loop in the UI
+ usage?: SessionUsage;
}
export type SessionStatus = 'pending' | 'running' | 'completed' | 'error';
@@ -153,7 +167,7 @@ export interface Command {
requestId: string;
}
-export type CommandType = 'create_session' | 'stop_session' | 'list_sessions' | 'get_history' | 'retry_session' | 'send_message' | 'rename_session' | 'remove_session' | 'bridge_exec' | 'clear_terminal' | 'update_session_parent' | 'create_loop' | 'list_loops' | 'update_loop' | 'delete_loop' | 'run_loop_now' | 'get_loop_runs';
+export type CommandType = 'create_session' | 'stop_session' | 'list_sessions' | 'get_history' | 'retry_session' | 'send_message' | 'rename_session' | 'remove_session' | 'bridge_exec' | 'clear_terminal' | 'update_session_parent' | 'get_session_usage' | 'create_loop' | 'list_loops' | 'update_loop' | 'delete_loop' | 'run_loop_now' | 'get_loop_runs';
export interface CreateSessionPayload {
command: string;
@@ -211,6 +225,11 @@ export interface ClearTerminalPayload {
sessionId: string;
}
+export interface GetSessionUsagePayload {
+ sessionId: string;
+ bridgeId?: string;
+}
+
export interface CreateLoopPayload extends LoopDraft { bridgeId: string }
export interface ListLoopsPayload { bridgeId?: string }
export interface UpdateLoopPayload { bridgeId: string; loopId: string; patch: Partial }
@@ -218,7 +237,7 @@ export interface DeleteLoopPayload { bridgeId: string; loopId: string }
export interface RunLoopNowPayload { bridgeId: string; loopId: string }
export interface GetLoopRunsPayload { bridgeId?: string; loopId: string }
-export type CommandPayload = CreateSessionPayload | StopSessionPayload | GetHistoryPayload | RenameSessionPayload | RemoveSessionPayload | BridgeExecPayload | ClearTerminalPayload | UpdateSessionParentPayload | CreateLoopPayload | ListLoopsPayload | UpdateLoopPayload | DeleteLoopPayload | RunLoopNowPayload | GetLoopRunsPayload | Record;
+export type CommandPayload = CreateSessionPayload | StopSessionPayload | GetHistoryPayload | RenameSessionPayload | RemoveSessionPayload | BridgeExecPayload | ClearTerminalPayload | UpdateSessionParentPayload | GetSessionUsagePayload | CreateLoopPayload | ListLoopsPayload | UpdateLoopPayload | DeleteLoopPayload | RunLoopNowPayload | GetLoopRunsPayload | Record;
export interface CommandResponse {
requestId: string;
diff --git a/bridge/src/usage-collector.test.ts b/bridge/src/usage-collector.test.ts
new file mode 100644
index 0000000..2648401
--- /dev/null
+++ b/bridge/src/usage-collector.test.ts
@@ -0,0 +1,204 @@
+import { describe, it, before, after } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { claudeProjectSlug, collectSessionUsage } from './usage-collector.js';
+
+let root: string;
+
+before(async () => {
+ root = await mkdtemp(join(tmpdir(), 'usage-collector-test-'));
+});
+
+after(async () => {
+ await rm(root, { recursive: true, force: true });
+});
+
+describe('claudeProjectSlug', () => {
+ it('maps every non-alphanumeric char to "-" (verified against real ~/.claude/projects dirs)', () => {
+ assert.equal(
+ claudeProjectSlug('/Users/x/projects/ftown/.claude/worktrees/fix-1'),
+ '-Users-x-projects-ftown--claude-worktrees-fix-1',
+ );
+ assert.equal(claudeProjectSlug('/tmp/a_b.c'), '-tmp-a-b-c');
+ });
+});
+
+function claudeLine(
+ id: string,
+ model: string,
+ usage: Record,
+ type = 'assistant',
+): string {
+ return JSON.stringify({ type, message: { id, model, usage } });
+}
+
+describe('collectSessionUsage — claude extractor', () => {
+ const workingDir = '/tmp/proj.x';
+ const sessionId = 'aaaa-bbbb';
+
+ it('sums per-message usage, dedupes repeated message ids, skips synthetic rows, prices per model', async () => {
+ const claudeProjectsDir = join(root, 'claude-sums');
+ const dir = join(claudeProjectsDir, claudeProjectSlug(workingDir));
+ await mkdir(dir, { recursive: true });
+ const lines = [
+ JSON.stringify({ type: 'user', message: { role: 'user' } }),
+ // msg1: sonnet-5, spans two jsonl lines with identical usage — count once
+ claudeLine('msg1', 'claude-sonnet-5', {
+ input_tokens: 100, output_tokens: 200,
+ cache_read_input_tokens: 1000, cache_creation_input_tokens: 500,
+ }),
+ claudeLine('msg1', 'claude-sonnet-5', {
+ input_tokens: 100, output_tokens: 200,
+ cache_read_input_tokens: 1000, cache_creation_input_tokens: 500,
+ }),
+ // synthetic placeholder — ignored
+ claudeLine('msg-syn', '', { input_tokens: 0, output_tokens: 0 }),
+ // msg2: sonnet-5 again
+ claudeLine('msg2', 'claude-sonnet-5', { input_tokens: 10, output_tokens: 20 }),
+ 'this is not json',
+ // msg3: haiku (second model, date-suffixed id exercising prefix pricing)
+ claudeLine('msg3', 'claude-haiku-4-5-20251001', {
+ input_tokens: 1000, output_tokens: 100,
+ cache_read_input_tokens: 200, cache_creation_input_tokens: 400,
+ }),
+ ];
+ await writeFile(join(dir, `${sessionId}.jsonl`), lines.join('\n') + '\n');
+
+ const usage = await collectSessionUsage(
+ { shellType: 'claude', claudeSessionId: sessionId, workingDir },
+ { claudeProjectsDir },
+ );
+
+ assert.ok(usage);
+ assert.equal(usage.harness, 'claude');
+ assert.equal(usage.inputTokens, 1110);
+ assert.equal(usage.outputTokens, 320);
+ assert.equal(usage.cacheReadTokens, 1200);
+ assert.equal(usage.cacheWriteTokens, 900);
+ assert.equal(usage.totalTokens, 1110 + 320 + 1200 + 900);
+ assert.deepEqual(usage.models, ['claude-sonnet-5', 'claude-haiku-4-5-20251001']);
+ // sonnet-5: (100*3 + 200*15 + 1000*0.3 + 500*3.75)/1e6 = 0.005475
+ // + (10*3 + 20*15)/1e6 = 0.000330
+ // haiku: (1000*1 + 100*5 + 200*0.1 + 400*1.25)/1e6 = 0.002020
+ assert.ok(usage.costUsd !== undefined);
+ assert.ok(Math.abs(usage.costUsd - 0.007825) < 1e-9, String(usage.costUsd));
+ assert.ok(usage.collectedAt);
+ });
+
+ it('omits costUsd when any model lacks pricing', async () => {
+ const claudeProjectsDir = join(root, 'claude-unknown');
+ const dir = join(claudeProjectsDir, claudeProjectSlug(workingDir));
+ await mkdir(dir, { recursive: true });
+ await writeFile(
+ join(dir, `${sessionId}.jsonl`),
+ [
+ claudeLine('m1', 'claude-sonnet-5', { input_tokens: 10, output_tokens: 10 }),
+ claudeLine('m2', 'totally-unknown-model', { input_tokens: 5, output_tokens: 5 }),
+ ].join('\n'),
+ );
+
+ const usage = await collectSessionUsage(
+ { shellType: 'claude', claudeSessionId: sessionId, workingDir },
+ { claudeProjectsDir },
+ );
+
+ assert.ok(usage);
+ assert.equal(usage.costUsd, undefined);
+ assert.equal(usage.totalTokens, 30);
+ assert.deepEqual(usage.models, ['claude-sonnet-5', 'totally-unknown-model']);
+ });
+
+ it('returns null when the transcript file is missing', async () => {
+ const usage = await collectSessionUsage(
+ { shellType: 'claude', claudeSessionId: 'no-such-session', workingDir },
+ { claudeProjectsDir: join(root, 'claude-missing') },
+ );
+ assert.equal(usage, null);
+ });
+});
+
+describe('collectSessionUsage — codex extractor', () => {
+ const codexSessionId = '019d2b5c-d671-7863-9688-d9be287e46a6';
+
+ it('uses the LAST token_count totals, splits cached from input, cost from single model', async () => {
+ const codexSessionsDir = join(root, 'codex-last-wins');
+ const dir = join(codexSessionsDir, '2026', '07', '01');
+ await mkdir(dir, { recursive: true });
+ const lines = [
+ JSON.stringify({ type: 'event_msg', payload: { type: 'token_count', info: null } }),
+ JSON.stringify({ type: 'turn_context', payload: { model: 'gpt-5.4-mini' } }),
+ JSON.stringify({
+ type: 'event_msg',
+ payload: {
+ type: 'token_count',
+ info: { total_token_usage: { input_tokens: 100, cached_input_tokens: 50, output_tokens: 5, total_tokens: 105 } },
+ },
+ }),
+ JSON.stringify({ type: 'turn_context', payload: { model: 'gpt-5.4-mini' } }),
+ JSON.stringify({
+ type: 'event_msg',
+ payload: {
+ type: 'token_count',
+ info: { total_token_usage: { input_tokens: 11276, cached_input_tokens: 9088, output_tokens: 29, total_tokens: 11305 } },
+ },
+ }),
+ ];
+ await writeFile(
+ join(dir, `rollout-2026-07-01T10-00-00-${codexSessionId}.jsonl`),
+ lines.join('\n'),
+ );
+
+ const usage = await collectSessionUsage(
+ { shellType: 'codex', codexSessionId },
+ { codexSessionsDir },
+ );
+
+ assert.ok(usage);
+ assert.equal(usage.harness, 'codex');
+ // last token_count wins; input excludes cached so the four sum to codex's total_tokens
+ assert.equal(usage.inputTokens, 11276 - 9088);
+ assert.equal(usage.cacheReadTokens, 9088);
+ assert.equal(usage.outputTokens, 29);
+ assert.equal(usage.cacheWriteTokens, 0);
+ assert.equal(usage.totalTokens, 11305);
+ assert.deepEqual(usage.models, ['gpt-5.4-mini']);
+ // (2188*0.25 + 29*2 + 9088*0.025)/1e6
+ assert.ok(usage.costUsd !== undefined);
+ assert.ok(Math.abs(usage.costUsd - 0.0008322) < 1e-9, String(usage.costUsd));
+ });
+
+ it('returns null when no rollout file matches the session id', async () => {
+ const usage = await collectSessionUsage(
+ { shellType: 'codex', codexSessionId: 'ffffffff-0000-0000-0000-000000000000' },
+ { codexSessionsDir: join(root, 'codex-missing') },
+ );
+ assert.equal(usage, null);
+ });
+});
+
+describe('collectSessionUsage — extractor routing', () => {
+ it('prefers the claude extractor whenever claudeSessionId is present (provider flavors)', async () => {
+ const claudeProjectsDir = join(root, 'routing');
+ const workingDir = '/tmp/routing';
+ const dir = join(claudeProjectsDir, claudeProjectSlug(workingDir));
+ await mkdir(dir, { recursive: true });
+ await writeFile(
+ join(dir, 'sess-1.jsonl'),
+ claudeLine('m1', 'claude-haiku-4-5', { input_tokens: 1, output_tokens: 2 }),
+ );
+
+ const usage = await collectSessionUsage(
+ // shellType is a provider flavor, but the native claude id wins
+ { shellType: 'zai' as never, claudeSessionId: 'sess-1', codexSessionId: 'also-set', workingDir },
+ { claudeProjectsDir },
+ );
+ assert.equal(usage?.harness, 'claude');
+ });
+
+ it('returns null for sessions with no structured source (shell/cursor)', async () => {
+ assert.equal(await collectSessionUsage({ shellType: 'shell' }), null);
+ });
+});
diff --git a/bridge/src/usage-collector.ts b/bridge/src/usage-collector.ts
new file mode 100644
index 0000000..edd67fa
--- /dev/null
+++ b/bridge/src/usage-collector.ts
@@ -0,0 +1,301 @@
+import { createReadStream } from 'node:fs';
+import { readdir } from 'node:fs/promises';
+import { createInterface } from 'node:readline';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+import { priceFor } from './model-pricing.js';
+import type { Session, SessionUsage } from './types.js';
+
+/**
+ * Per-session token/cost usage extraction from harness-native session files.
+ *
+ * The extractor is keyed by which NATIVE session id is present on the Session,
+ * not by shellType: provider flavors (zai/kimi/deepseek/fireworks) run the
+ * claude CLI under the hood and get a claudeSessionId persisted by the hook
+ * pipeline, so any session with a claudeSessionId uses the claude extractor
+ * regardless of shellType. Sessions with neither a claude nor a codex id
+ * (cursor, grok, plain shell) have no structured usage source and yield null.
+ *
+ * TODO(opencode): add an opencode extractor once Session carries an
+ * opencodeSessionId (no such field exists yet).
+ *
+ * Robustness contract: missing files, unparseable lines, and I/O errors never
+ * throw — they degrade to null (or to a partial sum if the read-time cap fires
+ * mid-file).
+ */
+
+export type UsageSessionRef = Pick<
+ Session,
+ 'shellType' | 'claudeSessionId' | 'codexSessionId' | 'workingDir'
+>;
+
+export interface UsageCollectorOptions {
+ /** Override for tests. Default: ~/.claude/projects */
+ claudeProjectsDir?: string;
+ /** Override for tests. Default: ~/.codex/sessions */
+ codexSessionsDir?: string;
+ /** Cap on wall-clock read time per collection. Default 15s. */
+ timeoutMs?: number;
+}
+
+const DEFAULT_TIMEOUT_MS = 15_000;
+
+/**
+ * Claude Code project-directory slug: every non-alphanumeric character of the
+ * working directory becomes '-'. Verified against real transcript dirs on this
+ * machine — e.g. /Users/x/projects/ftown/.claude/worktrees/foo maps to
+ * -Users-x-projects-ftown--claude-worktrees-foo ('.' → '-', '/' → '-').
+ */
+export function claudeProjectSlug(workingDir: string): string {
+ return workingDir.replace(/[^a-zA-Z0-9]/g, '-');
+}
+
+export async function collectSessionUsage(
+ session: UsageSessionRef,
+ options: UsageCollectorOptions = {},
+): Promise {
+ try {
+ if (session.claudeSessionId && session.workingDir) {
+ return await collectClaudeUsage(session.claudeSessionId, session.workingDir, options);
+ }
+ if (session.codexSessionId) {
+ return await collectCodexUsage(session.codexSessionId, options);
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+/** Stream a JSONL file line by line (files can be tens of MB — never buffer whole). */
+async function* jsonlLines(filePath: string, deadline: number): AsyncGenerator {
+ const stream = createReadStream(filePath, { encoding: 'utf8' });
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
+ try {
+ for await (const line of rl) {
+ if (Date.now() > deadline) break;
+ if (!line) continue;
+ try {
+ yield JSON.parse(line) as unknown;
+ } catch {
+ // Skip unparseable lines (truncated tail writes, etc.).
+ }
+ }
+ } finally {
+ rl.close();
+ stream.destroy();
+ }
+}
+
+interface ClaudeUsageLine {
+ type?: string;
+ message?: {
+ id?: string;
+ model?: string;
+ usage?: {
+ input_tokens?: number;
+ output_tokens?: number;
+ cache_read_input_tokens?: number;
+ cache_creation_input_tokens?: number;
+ };
+ };
+}
+
+async function collectClaudeUsage(
+ claudeSessionId: string,
+ workingDir: string,
+ options: UsageCollectorOptions,
+): Promise {
+ const baseDir = options.claudeProjectsDir ?? join(homedir(), '.claude', 'projects');
+ const filePath = join(baseDir, claudeProjectSlug(workingDir), `${claudeSessionId}.jsonl`);
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
+
+ let inputTokens = 0;
+ let outputTokens = 0;
+ let cacheReadTokens = 0;
+ let cacheWriteTokens = 0;
+ let costUsd = 0;
+ let pricingComplete = true;
+ let counted = 0;
+ const models: string[] = [];
+ // An assistant message spans MULTIPLE jsonl lines (one per content block),
+ // each repeating the same message.id and identical usage — count each
+ // message id exactly once or sums double/triple.
+ const seenMessageIds = new Set();
+
+ for await (const raw of jsonlLines(filePath, deadline)) {
+ const entry = raw as ClaudeUsageLine;
+ if (entry?.type !== 'assistant') continue;
+ const message = entry.message;
+ const usage = message?.usage;
+ if (!message || !usage) continue;
+ const model = message.model ?? '';
+ // '' rows are harness-injected placeholders with zero usage.
+ if (!model || model === '') continue;
+ const id = message.id;
+ if (id) {
+ if (seenMessageIds.has(id)) continue;
+ seenMessageIds.add(id);
+ }
+
+ const input = usage.input_tokens ?? 0;
+ const output = usage.output_tokens ?? 0;
+ const cacheRead = usage.cache_read_input_tokens ?? 0;
+ const cacheWrite = usage.cache_creation_input_tokens ?? 0;
+ inputTokens += input;
+ outputTokens += output;
+ cacheReadTokens += cacheRead;
+ cacheWriteTokens += cacheWrite;
+ counted += 1;
+ if (!models.includes(model)) models.push(model);
+
+ // Models can vary mid-session — price per message, not per session.
+ const price = priceFor(model);
+ if (price) {
+ costUsd +=
+ (input * price.inPerM +
+ output * price.outPerM +
+ cacheRead * price.cacheReadPerM +
+ cacheWrite * price.cacheWritePerM) /
+ 1_000_000;
+ } else {
+ pricingComplete = false;
+ }
+ }
+
+ if (counted === 0) return null;
+
+ return {
+ inputTokens,
+ outputTokens,
+ cacheReadTokens,
+ cacheWriteTokens,
+ totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
+ models,
+ ...(pricingComplete ? { costUsd } : {}),
+ harness: 'claude',
+ collectedAt: new Date().toISOString(),
+ };
+}
+
+interface CodexRolloutLine {
+ type?: string;
+ payload?: {
+ type?: string;
+ model?: string;
+ info?: {
+ total_token_usage?: {
+ input_tokens?: number;
+ cached_input_tokens?: number;
+ output_tokens?: number;
+ };
+ } | null;
+ };
+}
+
+/** Resolve ~/.codex/sessions////rollout-*-.jsonl (date-partitioned). */
+async function findCodexRollout(baseDir: string, codexSessionId: string): Promise {
+ const suffix = `-${codexSessionId}.jsonl`;
+ const listDirs = async (dir: string): Promise => {
+ try {
+ const entries = await readdir(dir, { withFileTypes: true });
+ // Newest partitions first — sessions are usually recent.
+ return entries
+ .filter((e) => e.isDirectory())
+ .map((e) => e.name)
+ .sort()
+ .reverse();
+ } catch {
+ return [];
+ }
+ };
+ for (const year of await listDirs(baseDir)) {
+ for (const month of await listDirs(join(baseDir, year))) {
+ for (const day of await listDirs(join(baseDir, year, month))) {
+ const dir = join(baseDir, year, month, day);
+ let files: string[];
+ try {
+ files = await readdir(dir);
+ } catch {
+ continue;
+ }
+ const hit = files.find((f) => f.startsWith('rollout-') && f.endsWith(suffix));
+ if (hit) return join(dir, hit);
+ }
+ }
+ }
+ return null;
+}
+
+async function collectCodexUsage(
+ codexSessionId: string,
+ options: UsageCollectorOptions,
+): Promise {
+ const baseDir = options.codexSessionsDir ?? join(homedir(), '.codex', 'sessions');
+ const filePath = await findCodexRollout(baseDir, codexSessionId);
+ if (!filePath) return null;
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
+
+ const models: string[] = [];
+ let lastTotals: { input: number; cached: number; output: number } | null = null;
+
+ for await (const raw of jsonlLines(filePath, deadline)) {
+ const entry = raw as CodexRolloutLine;
+ const payload = entry?.payload;
+ if (!payload) continue;
+ if (entry.type === 'turn_context' && payload.model) {
+ if (!models.includes(payload.model)) models.push(payload.model);
+ continue;
+ }
+ // token_count events carry cumulative totals — the LAST one wins. Some
+ // token_count events have info: null (rate-limit-only updates); skip them.
+ if (payload.type === 'token_count') {
+ const totals = payload.info?.total_token_usage;
+ if (totals) {
+ lastTotals = {
+ input: totals.input_tokens ?? 0,
+ cached: totals.cached_input_tokens ?? 0,
+ output: totals.output_tokens ?? 0,
+ };
+ }
+ }
+ }
+
+ if (!lastTotals) return null;
+
+ // Codex counts cached tokens INSIDE input_tokens (input 11276 / cached 9088 /
+ // total 11305 = 11276 + 29 in real rollouts) — split them so totalTokens
+ // (sum of the four) matches codex's own total_tokens. output_tokens already
+ // includes reasoning tokens. Codex reports no cache writes.
+ const cacheReadTokens = Math.min(lastTotals.cached, lastTotals.input);
+ const inputTokens = lastTotals.input - cacheReadTokens;
+ const outputTokens = lastTotals.output;
+ const cacheWriteTokens = 0;
+
+ // With only cumulative totals there is no per-model attribution: cost is
+ // computable only when the whole session ran on a single priced model.
+ let costUsd: number | undefined;
+ if (models.length === 1) {
+ const price = priceFor(models[0]);
+ if (price) {
+ costUsd =
+ (inputTokens * price.inPerM +
+ outputTokens * price.outPerM +
+ cacheReadTokens * price.cacheReadPerM) /
+ 1_000_000;
+ }
+ }
+
+ return {
+ inputTokens,
+ outputTokens,
+ cacheReadTokens,
+ cacheWriteTokens,
+ totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
+ models,
+ ...(costUsd !== undefined ? { costUsd } : {}),
+ harness: 'codex',
+ collectedAt: new Date().toISOString(),
+ };
+}
diff --git a/bridge/src/wire-types.ts b/bridge/src/wire-types.ts
index adc503f..88a64b8 100644
--- a/bridge/src/wire-types.ts
+++ b/bridge/src/wire-types.ts
@@ -18,6 +18,22 @@ export interface BridgePointer {
harnessCli?: string;
}
+/**
+ * Per-session token/cost usage, as returned by GET /api/sessions/:id/usage.
+ * Mirror of SessionUsage in ./types.ts — keep the two shapes in sync.
+ */
+export interface SessionUsage {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ totalTokens: number; // sum of the four
+ models: string[]; // distinct, order of first appearance
+ costUsd?: number; // omitted when any model lacks pricing
+ harness: string; // which extractor produced it
+ collectedAt: string; // ISO
+}
+
/** Session shape as returned by GET /api/sessions and /api/sessions/:id. */
export interface Session {
id: string;
@@ -27,6 +43,7 @@ export interface Session {
shellType?: string;
model?: string;
parentSessionId?: string;
+ usage?: SessionUsage;
}
export type MailType = 'message' | 'task' | 'result' | 'escalation';
diff --git a/ui/src/components/Dashboard.tsx b/ui/src/components/Dashboard.tsx
index 888edd4..a7ae001 100644
--- a/ui/src/components/Dashboard.tsx
+++ b/ui/src/components/Dashboard.tsx
@@ -1194,6 +1194,7 @@ PY`;
isRunning={selectedSession?.status === "running"}
sessionName={selectedSession?.name ?? selectedSession?.prompt?.slice(0, 48) ?? null}
usage={selectedSessionId ? sessionActivity.get(selectedSessionId)?.usage : undefined}
+ persistedUsage={selectedSession?.usage}
onMobileTap={() => mobileControlRef.current?.focusInput()}
shellType={selectedSession?.shellType}
onInterrupt={handleTerminalInterrupt}
diff --git a/ui/src/components/SessionList.tsx b/ui/src/components/SessionList.tsx
index 4ea4063..197ee47 100644
--- a/ui/src/components/SessionList.tsx
+++ b/ui/src/components/SessionList.tsx
@@ -8,6 +8,7 @@ import { BridgeInfo } from "@/hooks/useBridges";
import { reorderByDrop } from "@/lib/bridge-order";
import { StatusDot, type StatusDotKind } from "@/lib/StatusDot";
import { usePersistentState, stringSetCodec } from "@/lib/use-persistent-state";
+import { formatUsage, formatUsageDetail } from "@/lib/format-usage";
interface SessionListProps {
sessions: Session[];
@@ -1041,6 +1042,22 @@ export function SessionList({
)}
+ {session.usage && (
+
+ {formatUsage(session.usage)}
+
+ )}
+
void;
shellType?: ShellType;
/** Fired when a lone ESC (interrupt) keystroke is sent, for optimistic idle. */
@@ -35,12 +38,6 @@ function isLoneInterrupt(data: string): boolean {
return data === "\x1b";
}
-function formatTokenCount(n: number): string {
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
- return String(n);
-}
-
interface TransportModeBadgeConfig {
label: string;
title: string;
@@ -125,7 +122,7 @@ function TransportModeBadge({
);
}
-export const Terminal = forwardRef(function Terminal({ transport, sessionId, bridgeId, isRunning, sessionName, usage, onMobileTap, shellType, onInterrupt }, ref) {
+export const Terminal = forwardRef(function Terminal({ transport, sessionId, bridgeId, isRunning, sessionName, usage, persistedUsage, onMobileTap, shellType, onInterrupt }, ref) {
const containerRef = useRef(null);
const xtermRef = useRef(null);
const fitAddonRef = useRef(null);
@@ -449,9 +446,9 @@ export const Terminal = forwardRef(function Termi
- {usage && (usage.inputTokens > 0 || usage.outputTokens > 0) && (
+ {usage && (usage.inputTokens > 0 || usage.outputTokens > 0) ? (
(function Termi
letterSpacing: "0.04em",
}}
>
- {formatTokenCount(usage.inputTokens)} in / {formatTokenCount(usage.outputTokens)} out
+ {formatTokens(usage.inputTokens)} in / {formatTokens(usage.outputTokens)} out
- )}
+ ) : persistedUsage ? (
+
+ {formatUsage(persistedUsage)}
+
+ ) : null}
{isRunning ? "Session running" : ""}
diff --git a/ui/src/lib/format-usage.ts b/ui/src/lib/format-usage.ts
new file mode 100644
index 0000000..5d27e75
--- /dev/null
+++ b/ui/src/lib/format-usage.ts
@@ -0,0 +1,29 @@
+import { SessionUsage } from "@/types";
+
+/** Compact token count, e.g. 1200 -> "1.2K", 3_400_000 -> "3.4M". */
+export function formatTokens(n: number): string {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
+ return String(n);
+}
+
+/** Short one-line summary, e.g. "1.2M in · 45K out · $0.42" (cost omitted when unknown). */
+export function formatUsage(u: SessionUsage): string {
+ const parts = [`${formatTokens(u.inputTokens)} in`, `${formatTokens(u.outputTokens)} out`];
+ if (u.costUsd !== undefined) parts.push(`$${u.costUsd.toFixed(2)}`);
+ return parts.join(" · ");
+}
+
+/** Long-form breakdown for a tooltip/title: all token classes plus the model list. */
+export function formatUsageDetail(u: SessionUsage): string {
+ const lines = [
+ `Input: ${formatTokens(u.inputTokens)}`,
+ `Output: ${formatTokens(u.outputTokens)}`,
+ `Cache read: ${formatTokens(u.cacheReadTokens)}`,
+ `Cache write: ${formatTokens(u.cacheWriteTokens)}`,
+ `Total: ${formatTokens(u.totalTokens)}`,
+ ];
+ if (u.costUsd !== undefined) lines.push(`Cost: $${u.costUsd.toFixed(2)}`);
+ if (u.models.length > 0) lines.push(`Models: ${u.models.join(", ")}`);
+ return lines.join("\n");
+}
diff --git a/ui/src/types.ts b/ui/src/types.ts
index f37024e..5efa2ec 100644
--- a/ui/src/types.ts
+++ b/ui/src/types.ts
@@ -19,6 +19,20 @@ export interface Session {
command?: string;
parentSessionId?: string;
loopId?: string; // set on loop-run sessions; groups the run under its Loop in the UI
+ usage?: SessionUsage;
+}
+
+/** Persisted per-session usage totals, recorded by the bridge on session completion/update. */
+export interface SessionUsage {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ totalTokens: number;
+ models: string[];
+ costUsd?: number;
+ harness: string;
+ collectedAt: string; // ISO
}
// ---------------------------------------------------------------------------
@@ -161,7 +175,8 @@ export type CommandType =
| 'retry_session' | 'rename_session' | 'remove_session' | 'bridge_exec'
| 'update_session_parent'
| 'create_loop' | 'list_loops' | 'update_loop'
- | 'delete_loop' | 'run_loop_now' | 'get_loop_runs';
+ | 'delete_loop' | 'run_loop_now' | 'get_loop_runs'
+ | 'get_session_usage';
export interface Command {
type: CommandType;
From 12c1fb46bedcab4432479047e552d1931475da4a Mon Sep 17 00:00:00 2001
From: Foad Kesheh
Date: Wed, 15 Jul 2026 15:32:03 -0300
Subject: [PATCH 3/8] refactor(usage): report model + token facts, not
hand-maintained cost
SessionUsage v2: costUsd and the model-pricing table are gone; perModel
gains a per-model breakdown of all four token classes (claude attributes
per message; codex keeps honest totals + model list, no fabricated
split). CLI table drops $ and prints indented per-model sub-rows;
UI shows tokens + model with full per-model detail in the tooltip.
Dollars stay derivable downstream from exactly this data.
---
bridge/src/ftown-sessions-cli.ts | 50 +++++++++++-------
bridge/src/model-pricing.ts | 61 ----------------------
bridge/src/session-controller.test.ts | 1 -
bridge/src/terminal-pump.test.ts | 1 -
bridge/src/types.ts | 15 ++++--
bridge/src/usage-collector.test.ts | 56 ++++++++------------
bridge/src/usage-collector.ts | 75 ++++++++++-----------------
bridge/src/wire-types.ts | 18 +++++--
ui/src/lib/format-usage.ts | 22 ++++++--
ui/src/types.ts | 11 +++-
10 files changed, 133 insertions(+), 177 deletions(-)
delete mode 100644 bridge/src/model-pricing.ts
diff --git a/bridge/src/ftown-sessions-cli.ts b/bridge/src/ftown-sessions-cli.ts
index 36c2996..e707538 100644
--- a/bridge/src/ftown-sessions-cli.ts
+++ b/bridge/src/ftown-sessions-cli.ts
@@ -334,26 +334,39 @@ async function fetchUsageRow(id: string): Promise {
}
function formatUsageTable(rows: UsageRow[]): string {
- const header = ['id', 'name', 'models', 'in', 'out', 'cacheR', 'cacheW', 'total', '$cost'];
- const cost = (u: SessionUsage | null): string =>
- u?.costUsd === undefined ? '-' : `$${u.costUsd.toFixed(4)}`;
- const body = rows.map((r) => [
- r.id.slice(0, 8),
- r.name,
- r.usage ? r.usage.models.join(',') : '-',
- r.usage ? String(r.usage.inputTokens) : '-',
- r.usage ? String(r.usage.outputTokens) : '-',
- r.usage ? String(r.usage.cacheReadTokens) : '-',
- r.usage ? String(r.usage.cacheWriteTokens) : '-',
- r.usage ? String(r.usage.totalTokens) : '-',
- cost(r.usage),
- ]);
+ const header = ['id', 'name', 'model(s)', 'in', 'out', 'cacheR', 'cacheW', 'total'];
+ const body: string[][] = [];
+ for (const r of rows) {
+ const u = r.usage;
+ body.push([
+ r.id.slice(0, 8),
+ r.name,
+ u ? u.models.join(',') : '-',
+ u ? String(u.inputTokens) : '-',
+ u ? String(u.outputTokens) : '-',
+ u ? String(u.cacheReadTokens) : '-',
+ u ? String(u.cacheWriteTokens) : '-',
+ u ? String(u.totalTokens) : '-',
+ ]);
+ // Multi-model sessions get an indented per-model sub-row each.
+ if (u?.perModel && u.perModel.length > 1) {
+ for (const m of u.perModel) {
+ body.push([
+ '',
+ '',
+ ` ${m.model}`,
+ String(m.inputTokens),
+ String(m.outputTokens),
+ String(m.cacheReadTokens),
+ String(m.cacheWriteTokens),
+ String(m.inputTokens + m.outputTokens + m.cacheReadTokens + m.cacheWriteTokens),
+ ]);
+ }
+ }
+ }
const collected = rows.map((r) => r.usage).filter((u): u is SessionUsage => u !== null);
const sum = (pick: (u: SessionUsage) => number): number =>
collected.reduce((acc, u) => acc + pick(u), 0);
- const costs = collected.map((u) => u.costUsd).filter((c): c is number => c !== undefined);
- const totalCost =
- costs.length > 0 ? `$${costs.reduce((a, b) => a + b, 0).toFixed(4)}` : '-';
body.push([
'TOTAL',
'',
@@ -363,7 +376,6 @@ function formatUsageTable(rows: UsageRow[]): string {
String(sum((u) => u.cacheReadTokens)),
String(sum((u) => u.cacheWriteTokens)),
String(sum((u) => u.totalTokens)),
- totalCost,
]);
const widths = header.map((h, i) =>
Math.max(h.length, ...body.map((row) => row[i].length)),
@@ -386,7 +398,7 @@ Commands:
tell Send mail to another session's inbox
inbox | mail Read own inbox (requires FTOWN_SESSION_ID)
running Check if session PTY is running
- usage Token/cost usage per session (--json for raw)
+ usage Model/token usage per session (--json for raw)
remove Stop and remove a session (archived as a tombstone)
archive List archived (removed) sessions
revive Recreate a removed session from its tombstone
diff --git a/bridge/src/model-pricing.ts b/bridge/src/model-pricing.ts
deleted file mode 100644
index 4e0944c..0000000
--- a/bridge/src/model-pricing.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-// Per-model token pricing used to estimate session cost in usage-collector.ts.
-//
-// ⚠️ These rates are ESTIMATES maintained by hand from public price sheets
-// (USD per 1M tokens). They are not fetched from any API and can drift from
-// the real billed rates — treat costUsd as an approximation. Models with no
-// entry here (unknown/new/synthetic ids) yield costUsd === undefined rather
-// than a guessed number.
-
-export interface ModelPrice {
- /** USD per 1M uncached input tokens. */
- inPerM: number;
- /** USD per 1M output tokens. */
- outPerM: number;
- /** USD per 1M cache-read input tokens (~0.1x input on Anthropic). */
- cacheReadPerM: number;
- /** USD per 1M cache-write input tokens (~1.25x input on Anthropic; 0 on OpenAI). */
- cacheWritePerM: number;
-}
-
-/**
- * Keyed by model-id prefix — see priceFor() for the matching rule. Anthropic
- * ids sometimes carry date suffixes (claude-haiku-4-5-20251001), OpenAI codex
- * ids carry variant suffixes (gpt-5.6-luna); prefix matching absorbs both.
- */
-export const PRICES: Record = {
- // Anthropic — cache read = 0.1x input, cache write (5m) = 1.25x input.
- 'claude-fable-5': { inPerM: 10, outPerM: 50, cacheReadPerM: 1, cacheWritePerM: 12.5 },
- 'claude-opus-4-8': { inPerM: 5, outPerM: 25, cacheReadPerM: 0.5, cacheWritePerM: 6.25 },
- 'claude-opus-4-7': { inPerM: 5, outPerM: 25, cacheReadPerM: 0.5, cacheWritePerM: 6.25 },
- 'claude-opus-4-6': { inPerM: 5, outPerM: 25, cacheReadPerM: 0.5, cacheWritePerM: 6.25 },
- 'claude-opus-4-5': { inPerM: 5, outPerM: 25, cacheReadPerM: 0.5, cacheWritePerM: 6.25 },
- 'claude-sonnet-5': { inPerM: 3, outPerM: 15, cacheReadPerM: 0.3, cacheWritePerM: 3.75 },
- 'claude-sonnet-4-5': { inPerM: 3, outPerM: 15, cacheReadPerM: 0.3, cacheWritePerM: 3.75 },
- 'claude-sonnet-4-6': { inPerM: 3, outPerM: 15, cacheReadPerM: 0.3, cacheWritePerM: 3.75 },
- 'claude-haiku-4-5': { inPerM: 1, outPerM: 5, cacheReadPerM: 0.1, cacheWritePerM: 1.25 },
-
- // OpenAI codex rollouts (model ids observed in ~/.codex/sessions rollout
- // files: gpt-5.4, gpt-5.4-mini, gpt-5.5, gpt-5.6-luna/sol/terra). Rates
- // estimated from the gpt-5 family price sheet; cached input = 0.1x input,
- // cache writes are free on OpenAI.
- 'gpt-5.4-mini': { inPerM: 0.25, outPerM: 2, cacheReadPerM: 0.025, cacheWritePerM: 0 },
- 'gpt-5.4': { inPerM: 1.25, outPerM: 10, cacheReadPerM: 0.125, cacheWritePerM: 0 },
- 'gpt-5.5': { inPerM: 1.25, outPerM: 10, cacheReadPerM: 0.125, cacheWritePerM: 0 },
- 'gpt-5.6': { inPerM: 1.25, outPerM: 10, cacheReadPerM: 0.125, cacheWritePerM: 0 },
-};
-
-/**
- * Longest-prefix match so date/variant suffixes resolve to their base entry
- * (claude-opus-4-8-20260115 → claude-opus-4-8; gpt-5.6-luna → gpt-5.6) while
- * more specific entries win over shorter ones (gpt-5.4-mini over gpt-5.4).
- * Returns undefined for unknown models — callers must then omit costUsd.
- */
-export function priceFor(model: string): ModelPrice | undefined {
- let best: string | undefined;
- for (const key of Object.keys(PRICES)) {
- if (model === key || model.startsWith(key)) {
- if (best === undefined || key.length > best.length) best = key;
- }
- }
- return best === undefined ? undefined : PRICES[best];
-}
diff --git a/bridge/src/session-controller.test.ts b/bridge/src/session-controller.test.ts
index 6d978ea..aa8f5a8 100644
--- a/bridge/src/session-controller.test.ts
+++ b/bridge/src/session-controller.test.ts
@@ -219,7 +219,6 @@ describe('SessionController.usage', () => {
cacheWriteTokens: 4,
totalTokens: 10,
models: ['claude-sonnet-5'],
- costUsd: 0.0001,
harness: 'claude',
collectedAt: '2026-01-01T00:00:00.000Z',
...overrides,
diff --git a/bridge/src/terminal-pump.test.ts b/bridge/src/terminal-pump.test.ts
index 8c12e0c..2f5ba23 100644
--- a/bridge/src/terminal-pump.test.ts
+++ b/bridge/src/terminal-pump.test.ts
@@ -222,7 +222,6 @@ describe('TerminalPump usage collection', () => {
cacheWriteTokens: 40,
totalTokens: 100,
models: ['claude-sonnet-5'],
- costUsd: 0.001,
harness: 'claude',
collectedAt: new Date().toISOString(),
...overrides,
diff --git a/bridge/src/types.ts b/bridge/src/types.ts
index cf32b09..a133d5b 100644
--- a/bridge/src/types.ts
+++ b/bridge/src/types.ts
@@ -6,15 +6,24 @@ import type { LoopHarness, ShellType } from './harness-registry.js';
export type SessionRuntime = 'tmux' | 'direct';
-/** Per-session token/cost usage, extracted from harness-native session files. */
+/** Per-model token breakdown within a session. */
+export interface ModelUsage {
+ model: string;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+}
+
+/** Per-session token usage, extracted from harness-native session files. */
export interface SessionUsage {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
totalTokens: number; // sum of the four
- models: string[]; // distinct, order of first appearance
- costUsd?: number; // omitted when any model lacks pricing
+ models: string[]; // distinct, first-appearance order (always present)
+ perModel?: ModelUsage[]; // per-model breakdown when attributable (claude); absent for codex totals
harness: string; // which extractor produced it
collectedAt: string; // ISO
}
diff --git a/bridge/src/usage-collector.test.ts b/bridge/src/usage-collector.test.ts
index 2648401..3cf2cdf 100644
--- a/bridge/src/usage-collector.test.ts
+++ b/bridge/src/usage-collector.test.ts
@@ -39,7 +39,7 @@ describe('collectSessionUsage — claude extractor', () => {
const workingDir = '/tmp/proj.x';
const sessionId = 'aaaa-bbbb';
- it('sums per-message usage, dedupes repeated message ids, skips synthetic rows, prices per model', async () => {
+ it('sums per-message usage, dedupes repeated message ids, skips synthetic rows, attributes per model', async () => {
const claudeProjectsDir = join(root, 'claude-sums');
const dir = join(claudeProjectsDir, claudeProjectSlug(workingDir));
await mkdir(dir, { recursive: true });
@@ -59,7 +59,7 @@ describe('collectSessionUsage — claude extractor', () => {
// msg2: sonnet-5 again
claudeLine('msg2', 'claude-sonnet-5', { input_tokens: 10, output_tokens: 20 }),
'this is not json',
- // msg3: haiku (second model, date-suffixed id exercising prefix pricing)
+ // msg3: haiku (second model, date-suffixed id)
claudeLine('msg3', 'claude-haiku-4-5-20251001', {
input_tokens: 1000, output_tokens: 100,
cache_read_input_tokens: 200, cache_creation_input_tokens: 400,
@@ -80,37 +80,26 @@ describe('collectSessionUsage — claude extractor', () => {
assert.equal(usage.cacheWriteTokens, 900);
assert.equal(usage.totalTokens, 1110 + 320 + 1200 + 900);
assert.deepEqual(usage.models, ['claude-sonnet-5', 'claude-haiku-4-5-20251001']);
- // sonnet-5: (100*3 + 200*15 + 1000*0.3 + 500*3.75)/1e6 = 0.005475
- // + (10*3 + 20*15)/1e6 = 0.000330
- // haiku: (1000*1 + 100*5 + 200*0.1 + 400*1.25)/1e6 = 0.002020
- assert.ok(usage.costUsd !== undefined);
- assert.ok(Math.abs(usage.costUsd - 0.007825) < 1e-9, String(usage.costUsd));
+ // Per-model attribution: msg1 + msg2 land on sonnet-5, msg3 on haiku.
+ assert.deepEqual(usage.perModel, [
+ {
+ model: 'claude-sonnet-5',
+ inputTokens: 110,
+ outputTokens: 220,
+ cacheReadTokens: 1000,
+ cacheWriteTokens: 500,
+ },
+ {
+ model: 'claude-haiku-4-5-20251001',
+ inputTokens: 1000,
+ outputTokens: 100,
+ cacheReadTokens: 200,
+ cacheWriteTokens: 400,
+ },
+ ]);
assert.ok(usage.collectedAt);
});
- it('omits costUsd when any model lacks pricing', async () => {
- const claudeProjectsDir = join(root, 'claude-unknown');
- const dir = join(claudeProjectsDir, claudeProjectSlug(workingDir));
- await mkdir(dir, { recursive: true });
- await writeFile(
- join(dir, `${sessionId}.jsonl`),
- [
- claudeLine('m1', 'claude-sonnet-5', { input_tokens: 10, output_tokens: 10 }),
- claudeLine('m2', 'totally-unknown-model', { input_tokens: 5, output_tokens: 5 }),
- ].join('\n'),
- );
-
- const usage = await collectSessionUsage(
- { shellType: 'claude', claudeSessionId: sessionId, workingDir },
- { claudeProjectsDir },
- );
-
- assert.ok(usage);
- assert.equal(usage.costUsd, undefined);
- assert.equal(usage.totalTokens, 30);
- assert.deepEqual(usage.models, ['claude-sonnet-5', 'totally-unknown-model']);
- });
-
it('returns null when the transcript file is missing', async () => {
const usage = await collectSessionUsage(
{ shellType: 'claude', claudeSessionId: 'no-such-session', workingDir },
@@ -123,7 +112,7 @@ describe('collectSessionUsage — claude extractor', () => {
describe('collectSessionUsage — codex extractor', () => {
const codexSessionId = '019d2b5c-d671-7863-9688-d9be287e46a6';
- it('uses the LAST token_count totals, splits cached from input, cost from single model', async () => {
+ it('uses the LAST token_count totals, splits cached from input, no perModel', async () => {
const codexSessionsDir = join(root, 'codex-last-wins');
const dir = join(codexSessionsDir, '2026', '07', '01');
await mkdir(dir, { recursive: true });
@@ -165,9 +154,8 @@ describe('collectSessionUsage — codex extractor', () => {
assert.equal(usage.cacheWriteTokens, 0);
assert.equal(usage.totalTokens, 11305);
assert.deepEqual(usage.models, ['gpt-5.4-mini']);
- // (2188*0.25 + 29*2 + 9088*0.025)/1e6
- assert.ok(usage.costUsd !== undefined);
- assert.ok(Math.abs(usage.costUsd - 0.0008322) < 1e-9, String(usage.costUsd));
+ // Codex carries only cumulative totals — never a per-model breakdown.
+ assert.equal(usage.perModel, undefined);
});
it('returns null when no rollout file matches the session id', async () => {
diff --git a/bridge/src/usage-collector.ts b/bridge/src/usage-collector.ts
index edd67fa..84a562b 100644
--- a/bridge/src/usage-collector.ts
+++ b/bridge/src/usage-collector.ts
@@ -4,11 +4,10 @@ import { createInterface } from 'node:readline';
import { homedir } from 'node:os';
import { join } from 'node:path';
-import { priceFor } from './model-pricing.js';
-import type { Session, SessionUsage } from './types.js';
+import type { ModelUsage, Session, SessionUsage } from './types.js';
/**
- * Per-session token/cost usage extraction from harness-native session files.
+ * Per-session token usage extraction from harness-native session files.
*
* The extractor is keyed by which NATIVE session id is present on the Session,
* not by shellType: provider flavors (zai/kimi/deepseek/fireworks) run the
@@ -111,14 +110,9 @@ async function collectClaudeUsage(
const filePath = join(baseDir, claudeProjectSlug(workingDir), `${claudeSessionId}.jsonl`);
const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
- let inputTokens = 0;
- let outputTokens = 0;
- let cacheReadTokens = 0;
- let cacheWriteTokens = 0;
- let costUsd = 0;
- let pricingComplete = true;
let counted = 0;
- const models: string[] = [];
+ // Keyed by model id; Map preserves first-appearance order.
+ const byModel = new Map();
// An assistant message spans MULTIPLE jsonl lines (one per content block),
// each repeating the same message.id and identical usage — count each
// message id exactly once or sums double/triple.
@@ -139,41 +133,37 @@ async function collectClaudeUsage(
seenMessageIds.add(id);
}
- const input = usage.input_tokens ?? 0;
- const output = usage.output_tokens ?? 0;
- const cacheRead = usage.cache_read_input_tokens ?? 0;
- const cacheWrite = usage.cache_creation_input_tokens ?? 0;
- inputTokens += input;
- outputTokens += output;
- cacheReadTokens += cacheRead;
- cacheWriteTokens += cacheWrite;
- counted += 1;
- if (!models.includes(model)) models.push(model);
-
- // Models can vary mid-session — price per message, not per session.
- const price = priceFor(model);
- if (price) {
- costUsd +=
- (input * price.inPerM +
- output * price.outPerM +
- cacheRead * price.cacheReadPerM +
- cacheWrite * price.cacheWritePerM) /
- 1_000_000;
- } else {
- pricingComplete = false;
+ // Models can vary mid-session — attribute tokens per message's model.
+ let acc = byModel.get(model);
+ if (!acc) {
+ acc = { model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
+ byModel.set(model, acc);
}
+ acc.inputTokens += usage.input_tokens ?? 0;
+ acc.outputTokens += usage.output_tokens ?? 0;
+ acc.cacheReadTokens += usage.cache_read_input_tokens ?? 0;
+ acc.cacheWriteTokens += usage.cache_creation_input_tokens ?? 0;
+ counted += 1;
}
if (counted === 0) return null;
+ const perModel = [...byModel.values()];
+ const sum = (pick: (m: ModelUsage) => number): number =>
+ perModel.reduce((acc, m) => acc + pick(m), 0);
+ const inputTokens = sum((m) => m.inputTokens);
+ const outputTokens = sum((m) => m.outputTokens);
+ const cacheReadTokens = sum((m) => m.cacheReadTokens);
+ const cacheWriteTokens = sum((m) => m.cacheWriteTokens);
+
return {
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
- models,
- ...(pricingComplete ? { costUsd } : {}),
+ models: perModel.map((m) => m.model),
+ perModel,
harness: 'claude',
collectedAt: new Date().toISOString(),
};
@@ -273,20 +263,8 @@ async function collectCodexUsage(
const outputTokens = lastTotals.output;
const cacheWriteTokens = 0;
- // With only cumulative totals there is no per-model attribution: cost is
- // computable only when the whole session ran on a single priced model.
- let costUsd: number | undefined;
- if (models.length === 1) {
- const price = priceFor(models[0]);
- if (price) {
- costUsd =
- (inputTokens * price.inPerM +
- outputTokens * price.outPerM +
- cacheReadTokens * price.cacheReadPerM) /
- 1_000_000;
- }
- }
-
+ // Codex rollouts carry only cumulative totals — there is no per-model
+ // attribution, so perModel is deliberately absent (models lists what ran).
return {
inputTokens,
outputTokens,
@@ -294,7 +272,6 @@ async function collectCodexUsage(
cacheWriteTokens,
totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
models,
- ...(costUsd !== undefined ? { costUsd } : {}),
harness: 'codex',
collectedAt: new Date().toISOString(),
};
diff --git a/bridge/src/wire-types.ts b/bridge/src/wire-types.ts
index 88a64b8..b7e9a57 100644
--- a/bridge/src/wire-types.ts
+++ b/bridge/src/wire-types.ts
@@ -19,7 +19,19 @@ export interface BridgePointer {
}
/**
- * Per-session token/cost usage, as returned by GET /api/sessions/:id/usage.
+ * Per-model token breakdown within a session.
+ * Mirror of ModelUsage in ./types.ts — keep the two shapes in sync.
+ */
+export interface ModelUsage {
+ model: string;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+}
+
+/**
+ * Per-session token usage, as returned by GET /api/sessions/:id/usage.
* Mirror of SessionUsage in ./types.ts — keep the two shapes in sync.
*/
export interface SessionUsage {
@@ -28,8 +40,8 @@ export interface SessionUsage {
cacheReadTokens: number;
cacheWriteTokens: number;
totalTokens: number; // sum of the four
- models: string[]; // distinct, order of first appearance
- costUsd?: number; // omitted when any model lacks pricing
+ models: string[]; // distinct, first-appearance order (always present)
+ perModel?: ModelUsage[]; // per-model breakdown when attributable (claude); absent for codex totals
harness: string; // which extractor produced it
collectedAt: string; // ISO
}
diff --git a/ui/src/lib/format-usage.ts b/ui/src/lib/format-usage.ts
index 5d27e75..a21f560 100644
--- a/ui/src/lib/format-usage.ts
+++ b/ui/src/lib/format-usage.ts
@@ -7,14 +7,19 @@ export function formatTokens(n: number): string {
return String(n);
}
-/** Short one-line summary, e.g. "1.2M in · 45K out · $0.42" (cost omitted when unknown). */
+/** Strip common vendor-style prefixes from a model id for brevity, e.g. "claude-opus-4" -> "opus-4". */
+function shortModelName(model: string): string {
+ return model.replace(/^(claude-|gpt-|gemini-|grok-)/, "");
+}
+
+/** Short one-line summary, e.g. "1.2M in · 45K out · opus-4" (model suffix omitted unless exactly one model). */
export function formatUsage(u: SessionUsage): string {
const parts = [`${formatTokens(u.inputTokens)} in`, `${formatTokens(u.outputTokens)} out`];
- if (u.costUsd !== undefined) parts.push(`$${u.costUsd.toFixed(2)}`);
+ if (u.models.length === 1) parts.push(shortModelName(u.models[0]));
return parts.join(" · ");
}
-/** Long-form breakdown for a tooltip/title: all token classes plus the model list. */
+/** Long-form breakdown for a tooltip/title: all token classes plus a per-model breakdown (or model list). */
export function formatUsageDetail(u: SessionUsage): string {
const lines = [
`Input: ${formatTokens(u.inputTokens)}`,
@@ -23,7 +28,14 @@ export function formatUsageDetail(u: SessionUsage): string {
`Cache write: ${formatTokens(u.cacheWriteTokens)}`,
`Total: ${formatTokens(u.totalTokens)}`,
];
- if (u.costUsd !== undefined) lines.push(`Cost: $${u.costUsd.toFixed(2)}`);
- if (u.models.length > 0) lines.push(`Models: ${u.models.join(", ")}`);
+ if (u.perModel && u.perModel.length > 0) {
+ for (const m of u.perModel) {
+ lines.push(
+ `${m.model}: ${formatTokens(m.inputTokens)} in · ${formatTokens(m.outputTokens)} out · ${formatTokens(m.cacheReadTokens)} cacheR · ${formatTokens(m.cacheWriteTokens)} cacheW`
+ );
+ }
+ } else if (u.models.length > 0) {
+ lines.push(`Models: ${u.models.join(", ")}`);
+ }
return lines.join("\n");
}
diff --git a/ui/src/types.ts b/ui/src/types.ts
index 5efa2ec..03d7fa2 100644
--- a/ui/src/types.ts
+++ b/ui/src/types.ts
@@ -22,6 +22,15 @@ export interface Session {
usage?: SessionUsage;
}
+/** Per-model token breakdown within a session's usage. */
+export interface ModelUsage {
+ model: string;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+}
+
/** Persisted per-session usage totals, recorded by the bridge on session completion/update. */
export interface SessionUsage {
inputTokens: number;
@@ -30,7 +39,7 @@ export interface SessionUsage {
cacheWriteTokens: number;
totalTokens: number;
models: string[];
- costUsd?: number;
+ perModel?: ModelUsage[];
harness: string;
collectedAt: string; // ISO
}
From db12fbb6ba1feeb71ea5635fee6080a1197fd5b9 Mon Sep 17 00:00:00 2001
From: Foad Kesheh
Date: Thu, 16 Jul 2026 09:38:26 -0300
Subject: [PATCH 4/8] test(e2e): helper foundation for lifecycle/usage/security
specs + CI gating
New helpers: second-user login + per-user Centrifugo tokens; a raw
Centrifugo v5 client that attempts cross-tenant subscribe/publish and
reports the rejection code (103) distinctly from a connect refusal;
loopback bridge-API probe (Host/Origin/bearer); claude/codex transcript
fixture seeders (verified slug rule); bridge restart for resurrection.
e2e.yml gains push:main + nightly triggers and npm/playwright caching.
---
.github/workflows/e2e.yml | 24 +++-
e2e/helpers/app.ts | 57 ++++++++-
e2e/helpers/bridge-api.ts | 132 ++++++++++++++++++++
e2e/helpers/bridge-fixtures.ts | 132 ++++++++++++++++++++
e2e/helpers/bridge-process.ts | 104 ++++++++++++++++
e2e/helpers/centrifugo-raw.ts | 221 +++++++++++++++++++++++++++++++++
e2e/helpers/centrifugo.ts | 47 +++++++
e2e/helpers/config.ts | 24 ++++
e2e/helpers/loops.ts | 58 +++++++++
9 files changed, 793 insertions(+), 6 deletions(-)
create mode 100644 e2e/helpers/bridge-api.ts
create mode 100644 e2e/helpers/bridge-fixtures.ts
create mode 100644 e2e/helpers/bridge-process.ts
create mode 100644 e2e/helpers/centrifugo-raw.ts
create mode 100644 e2e/helpers/loops.ts
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 17fcf9b..496ba89 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -2,7 +2,11 @@ name: e2e
on:
pull_request:
+ push:
+ branches: [main]
workflow_dispatch:
+ schedule:
+ - cron: '0 7 * * *'
jobs:
direct-transport-e2e:
@@ -14,6 +18,18 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 24
+ cache: 'npm'
+ cache-dependency-path: |
+ ui/package-lock.json
+ bridge/package-lock.json
+ e2e/package-lock.json
+
+ - name: Cache Playwright browsers
+ id: playwright-cache
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/ms-playwright
+ key: ${{ runner.os }}-playwright-${{ hashFiles('e2e/package-lock.json') }}
# The bridge spawns sessions with a hardcoded `/bin/zsh -l`; tmux is the
# preferred PTY runtime (falls back to direct node-pty if absent).
@@ -62,10 +78,16 @@ jobs:
npm ci
npm run build
- - name: Install Playwright chromium
+ - name: Install Playwright chromium (browsers + OS deps)
+ if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: e2e
run: npx playwright install --with-deps chromium
+ - name: Install Playwright OS deps only (browsers cached)
+ if: steps.playwright-cache.outputs.cache-hit == 'true'
+ working-directory: e2e
+ run: npx playwright install-deps chromium
+
- name: Start UI + bridge (recorded PIDs, scratch HOME)
run: bash e2e/scripts/start-services.sh
diff --git a/e2e/helpers/app.ts b/e2e/helpers/app.ts
index 5e78f37..388fce8 100644
--- a/e2e/helpers/app.ts
+++ b/e2e/helpers/app.ts
@@ -1,7 +1,14 @@
import { expect, type Page } from "@playwright/test";
import { UI_BASE_URL } from "./config";
-const PASSWORD = "e2e-password-123";
+/** Default password for every e2e-registered user (CI-local; nothing sensitive). */
+export const E2E_PASSWORD = "e2e-password-123";
+
+/** A registered/loggable user credential pair. */
+export interface UserCreds {
+ email: string;
+ password: string;
+}
/**
* The run-scoped user email. The bridge's token `sub` and the dashboard user MUST
@@ -15,16 +22,26 @@ export function sharedEmail(): string {
return email;
}
+/**
+ * Mint a fresh, unique, not-yet-registered credential pair for an isolated user
+ * (e.g. "user B" in a cross-tenant test). The email is unique per call so two
+ * users never collide within or across runs.
+ */
+export function makeUser(prefix = "e2e-b"): UserCreds {
+ const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ return { email: `${prefix}+${unique}@ftown.test`, password: E2E_PASSWORD };
+}
+
/**
* Register via the same endpoint the app exposes. Idempotent: F4 made register
* non-enumerating, so it returns a generic 200 for both new and existing
* accounts — any 2xx is success.
*/
-export async function registerUser(email: string): Promise {
+export async function registerUser(email: string, password: string = E2E_PASSWORD): Promise {
const res = await fetch(`${UI_BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email, password: PASSWORD }),
+ body: JSON.stringify({ email, password }),
});
const body = await res.text();
if (!res.ok) {
@@ -33,14 +50,44 @@ export async function registerUser(email: string): Promise {
}
/** Fill the /login credentials form and land on /dashboard. */
-export async function login(page: Page, email: string): Promise {
+export async function login(page: Page, email: string, password: string = E2E_PASSWORD): Promise {
await page.goto("/login");
await page.locator("#email").fill(email);
- await page.locator("#password").fill(PASSWORD);
+ await page.locator("#password").fill(password);
await page.getByRole("button", { name: "Sign In" }).click();
await page.waitForURL("**/dashboard", { timeout: 30_000 });
}
+/**
+ * Register (idempotent) AND log `page` in as the given user, landing on
+ * /dashboard. Omit `creds` to mint a fresh isolated user — useful for a second
+ * browser context that must act as user B against user A's resources. Returns
+ * the credentials actually used.
+ */
+export async function registerAndLogin(page: Page, creds?: UserCreds): Promise {
+ const user = creds ?? makeUser();
+ await registerUser(user.email, user.password);
+ await login(page, user.email, user.password);
+ return user;
+}
+
+/**
+ * Fetch the connect token for whoever `page` is currently logged in as, from
+ * POST /api/auth/token. The route returns an HS256 JWT (aud "ftown:centrifugo",
+ * sub = the session email) — the same token the browser uses to connect to
+ * Centrifugo. Uses the page context's auth cookies, so the token is scoped to
+ * that page's user. Throws on non-2xx (e.g. 401 when the page is not logged in).
+ */
+export async function getCentrifugoToken(page: Page): Promise {
+ const res = await page.request.post(`${UI_BASE_URL}/api/auth/token`);
+ if (!res.ok()) {
+ throw new Error(`/api/auth/token failed: ${res.status()} ${await res.text()}`);
+ }
+ const json = (await res.json()) as { token?: string };
+ if (!json.token) throw new Error("/api/auth/token returned no token");
+ return json.token;
+}
+
/**
* Wait until at least one bridge is online. The "create session" button is
* `disabled={!hasBridges}`, so its becoming enabled is the authoritative signal.
diff --git a/e2e/helpers/bridge-api.ts b/e2e/helpers/bridge-api.ts
new file mode 100644
index 0000000..8cc1b0d
--- /dev/null
+++ b/e2e/helpers/bridge-api.ts
@@ -0,0 +1,132 @@
+import { request as httpRequest, type IncomingMessage } from "node:http";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { BRIDGE_HOME } from "./config";
+
+/**
+ * Direct access to the bridge's loopback HTTP API (127.0.0.1:) for testing
+ * its Host / Origin / bearer guards (LocalApiServer in bridge/src). Uses raw
+ * node:http rather than fetch/undici because those forbid overriding the `Host`
+ * header, which the 421 (Misdirected Request) guard test needs to spoof.
+ *
+ * The bridge advertises its loopback port + in-memory bearer token by writing
+ * $HOME/.ftown/bridge.json on startup (index.ts). Under the e2e scratch HOME
+ * (e2e/.bridge-home) that pointer is the discovery source for both.
+ */
+
+/** Shape the bridge writes to $HOME/.ftown/bridge.json. */
+export interface BridgePointer {
+ port: number;
+ token: string;
+ bridgeId: string;
+ pid: number;
+ startedAt: string;
+ harness?: string;
+ harnessCli?: string;
+}
+
+/**
+ * Read the running bridge's self-advert pointer ($bridgeHome/.ftown/bridge.json).
+ * Carries the loopback API `port` and bearer `token` (regenerated every bridge
+ * start — never persisted, so always read it fresh). Throws if the file is
+ * absent (bridge not running) or malformed.
+ */
+export function readBridgePointer(bridgeHome: string = BRIDGE_HOME): BridgePointer {
+ const path = join(bridgeHome, ".ftown", "bridge.json");
+ let raw: string;
+ try {
+ raw = readFileSync(path, "utf8");
+ } catch {
+ throw new Error(`bridge pointer not found at ${path} (is the bridge running?)`);
+ }
+ const parsed = JSON.parse(raw) as Partial;
+ if (typeof parsed.port !== "number" || typeof parsed.token !== "string") {
+ throw new Error(`bridge pointer at ${path} is malformed: ${raw}`);
+ }
+ return parsed as BridgePointer;
+}
+
+export interface BridgeApiResult {
+ status: number;
+ /** Parsed JSON body when the response is JSON; otherwise the raw text. */
+ body: T;
+}
+
+export interface BridgeApiOptions {
+ /** Override the `Origin` header. Omit to send none (the common legit case). */
+ origin?: string;
+ /**
+ * Override the `Host` header (the request still connects to 127.0.0.1:).
+ * Use a non-loopback value to drive the 421 guard. Defaults to
+ * `127.0.0.1:`.
+ */
+ host?: string;
+ /**
+ * Bearer token. Omit (undefined) to use the pointer's token (legit call).
+ * Pass `null` to send NO Authorization header (drives the 401 guard). Pass a
+ * string to send an explicit (e.g. wrong) token.
+ */
+ bearer?: string | null;
+ /** JSON request body; serialized and sent with Content-Type: application/json. */
+ body?: unknown;
+ /** Scratch HOME to read the pointer from. Defaults to e2e/.bridge-home. */
+ bridgeHome?: string;
+}
+
+/**
+ * Perform one request against the bridge loopback HTTP API and return its status
+ * + parsed body. NEVER throws on a non-2xx status — the status IS the assertion
+ * target (expect 421 for a spoofed Host, 403 for a non-localhost Origin, 401 for
+ * a missing/wrong bearer, and 2xx/4xx business codes for legit calls).
+ *
+ * @param method HTTP method, e.g. "GET" | "POST" | "DELETE".
+ * @param path API path beginning with "/", e.g. "/api/sessions".
+ */
+export function bridgeApiFetch(
+ method: string,
+ path: string,
+ options: BridgeApiOptions = {},
+): Promise {
+ const pointer = readBridgePointer(options.bridgeHome);
+ const hostHeader = options.host ?? `127.0.0.1:${pointer.port}`;
+ const bearer = options.bearer === undefined ? pointer.token : options.bearer;
+
+ const headers: Record = { Host: hostHeader };
+ if (options.origin !== undefined) headers.Origin = options.origin;
+ if (bearer !== null) headers.Authorization = `Bearer ${bearer}`;
+
+ let payload: string | undefined;
+ if (options.body !== undefined) {
+ payload = JSON.stringify(options.body);
+ headers["Content-Type"] = "application/json";
+ headers["Content-Length"] = String(Buffer.byteLength(payload));
+ }
+
+ return new Promise((resolve, reject) => {
+ const req = httpRequest(
+ { host: "127.0.0.1", port: pointer.port, method, path, headers },
+ (res: IncomingMessage) => {
+ const chunks: Buffer[] = [];
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
+ res.on("end", () => {
+ const text = Buffer.concat(chunks).toString("utf8");
+ const status = res.statusCode ?? 0;
+ const contentType = res.headers["content-type"] ?? "";
+ let body: unknown = text;
+ if (contentType.includes("application/json") && text) {
+ try {
+ body = JSON.parse(text);
+ } catch {
+ body = text;
+ }
+ }
+ resolve({ status, body });
+ });
+ },
+ );
+ req.on("error", reject);
+ if (payload !== undefined) req.write(payload);
+ req.end();
+ });
+}
diff --git a/e2e/helpers/bridge-fixtures.ts b/e2e/helpers/bridge-fixtures.ts
new file mode 100644
index 0000000..a0f6eee
--- /dev/null
+++ b/e2e/helpers/bridge-fixtures.ts
@@ -0,0 +1,132 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+import { BRIDGE_HOME } from "./config";
+
+/**
+ * Seed harness-native transcript fixtures the bridge's usage-collector reads, so
+ * a usage spec can assert token extraction WITHOUT running a real LLM. Files are
+ * written under the bridge's scratch HOME (e2e/.bridge-home); the running bridge
+ * has HOME overridden there, so collectSessionUsage() — which defaults to
+ * $HOME/.claude/projects and $HOME/.codex/sessions — reads exactly these files.
+ */
+
+/**
+ * Claude Code project-directory slug: every non-alphanumeric character of the
+ * working directory becomes '-'. Verified identical to claudeProjectSlug in
+ * bridge/src/usage-collector.ts (`workingDir.replace(/[^a-zA-Z0-9]/g, '-')`).
+ * Exported so specs can compute the expected transcript path.
+ */
+export function claudeProjectSlug(workingDir: string): string {
+ return workingDir.replace(/[^a-zA-Z0-9]/g, "-");
+}
+
+/** Per-message usage counts for a seeded Claude assistant turn. */
+export interface TranscriptMessage {
+ model: string;
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+}
+
+/**
+ * Write a Claude Code transcript fixture at
+ * $bridgeHome/.claude/projects//.jsonl
+ * where = claudeProjectSlug(workingDir). Each message becomes one
+ * `assistant` JSONL line with a UNIQUE message.id and the given usage counts,
+ * mapped to the exact fields collectClaudeUsage sums:
+ * input → usage.input_tokens
+ * output → usage.output_tokens
+ * cacheRead → usage.cache_read_input_tokens
+ * cacheWrite → usage.cache_creation_input_tokens
+ * Unique ids matter: the collector dedups by message.id, so reused ids would be
+ * counted once. Returns the absolute path written.
+ */
+export async function seedClaudeTranscript(
+ bridgeHome: string,
+ claudeSessionId: string,
+ workingDir: string,
+ messages: TranscriptMessage[],
+): Promise {
+ const dir = join(bridgeHome, ".claude", "projects", claudeProjectSlug(workingDir));
+ await mkdir(dir, { recursive: true });
+ const filePath = join(dir, `${claudeSessionId}.jsonl`);
+
+ const lines = messages.map((m, i) =>
+ JSON.stringify({
+ type: "assistant",
+ message: {
+ id: `msg-${i}-${Math.random().toString(36).slice(2, 10)}`,
+ model: m.model,
+ usage: {
+ input_tokens: m.input,
+ output_tokens: m.output,
+ cache_read_input_tokens: m.cacheRead,
+ cache_creation_input_tokens: m.cacheWrite,
+ },
+ },
+ }),
+ );
+
+ await writeFile(filePath, lines.length ? lines.join("\n") + "\n" : "", "utf8");
+ return filePath;
+}
+
+/** Cumulative Codex token totals (codex counts cached INSIDE input). */
+export interface CodexTotals {
+ input: number;
+ cached: number;
+ output: number;
+}
+
+function pad2(n: number): string {
+ return String(n).padStart(2, "0");
+}
+
+/**
+ * Write a Codex rollout fixture at
+ * $bridgeHome/.codex/sessions////rollout--.jsonl
+ * with a `turn_context` line carrying the model and a final `token_count` line
+ * carrying cumulative total_token_usage — the shape collectCodexUsage reads
+ * (bridge/src/usage-collector.ts). findCodexRollout matches any file starting
+ * `rollout-` and ending `-.jsonl` under the date partition. Returns the
+ * absolute path written.
+ */
+export async function seedCodexRollout(
+ bridgeHome: string,
+ codexSessionId: string,
+ opts: { model: string; totals: CodexTotals; date?: Date },
+): Promise {
+ const date = opts.date ?? new Date();
+ const year = String(date.getFullYear());
+ const month = pad2(date.getMonth() + 1);
+ const day = pad2(date.getDate());
+
+ const dir = join(bridgeHome, ".codex", "sessions", year, month, day);
+ await mkdir(dir, { recursive: true });
+ const filePath = join(dir, `rollout-${year}-${month}-${day}T00-00-00-${codexSessionId}.jsonl`);
+
+ const lines = [
+ JSON.stringify({ type: "turn_context", payload: { model: opts.model } }),
+ JSON.stringify({
+ type: "event_msg",
+ payload: {
+ type: "token_count",
+ info: {
+ total_token_usage: {
+ input_tokens: opts.totals.input,
+ cached_input_tokens: opts.totals.cached,
+ output_tokens: opts.totals.output,
+ },
+ },
+ },
+ }),
+ ];
+
+ await writeFile(filePath, lines.join("\n") + "\n", "utf8");
+ return filePath;
+}
+
+/** Convenience: default bridge scratch HOME (e2e/.bridge-home). */
+export { BRIDGE_HOME };
diff --git a/e2e/helpers/bridge-process.ts b/e2e/helpers/bridge-process.ts
new file mode 100644
index 0000000..5aed618
--- /dev/null
+++ b/e2e/helpers/bridge-process.ts
@@ -0,0 +1,104 @@
+import { spawn } from "node:child_process";
+import { openSync, readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { E2E_DIR, BRIDGE_HOME, UI_BASE_URL } from "./config";
+import { mintBridgeBootstrapToken } from "./jwt";
+import { sharedEmail } from "./app";
+import { waitForBridgePresence } from "./centrifugo";
+
+/**
+ * Stop and restart the e2e bridge for resurrection tests. start-services.sh
+ * launches the bridge as `node dist/index.js --token --api-url ` with
+ * HOME overridden to e2e/.bridge-home, recording its PID in e2e/.bridge.pid.
+ * This helper kills that exact PID and relaunches an identical process against
+ * the SAME scratch HOME and data dir, so the new bridge resumes its persisted
+ * identity + rotating refresh token and resurrects its live sessions.
+ *
+ * A fresh bootstrap --token is minted as a fallback, but the persisted refresh
+ * token (in $HOME/.ftown/data) takes precedence on resume, so a plain restart
+ * re-onboards without any dashboard interaction. The new PID is written back to
+ * e2e/.bridge.pid so stop-services.sh still tears down the right process, and
+ * the helper waits until the bridge is present on bridges:presence#
+ * before returning.
+ */
+
+export interface RestartBridgeOptions {
+ /** e2e dir holding .bridge.pid + .run-email. Default: this helper's e2e dir. */
+ e2eDir?: string;
+ /** Scratch HOME for the bridge. Default: e2e/.bridge-home. */
+ bridgeHome?: string;
+ /** UI API url passed as --api-url. Default: UI_BASE_URL. */
+ apiUrl?: string;
+ /** Max ms to wait for the new bridge to reappear online. Default 40s. */
+ presenceTimeoutMs?: number;
+}
+
+function isAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function waitForExit(pid: number, timeoutMs: number): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (isAlive(pid)) {
+ if (Date.now() >= deadline) throw new Error(`bridge pid ${pid} did not exit within ${timeoutMs}ms`);
+ await new Promise((r) => setTimeout(r, 200));
+ }
+}
+
+/** Stop the recorded bridge, start a fresh one on the same HOME, return new PID. */
+export async function restartBridge(options: RestartBridgeOptions = {}): Promise {
+ const e2eDir = options.e2eDir ?? E2E_DIR;
+ const bridgeHome = options.bridgeHome ?? BRIDGE_HOME;
+ const apiUrl = options.apiUrl ?? UI_BASE_URL;
+ const repoDir = join(e2eDir, "..");
+ const pidPath = join(e2eDir, ".bridge.pid");
+
+ // --- stop the currently recorded bridge ---
+ let oldPid: number | undefined;
+ try {
+ oldPid = parseInt(readFileSync(pidPath, "utf8").trim(), 10);
+ } catch {
+ oldPid = undefined;
+ }
+ if (oldPid && !Number.isNaN(oldPid) && isAlive(oldPid)) {
+ process.kill(oldPid, "SIGTERM");
+ try {
+ await waitForExit(oldPid, 8000);
+ } catch {
+ if (isAlive(oldPid)) process.kill(oldPid, "SIGKILL");
+ await waitForExit(oldPid, 4000);
+ }
+ }
+
+ // --- start a fresh bridge against the SAME scratch HOME ---
+ const email = sharedEmail();
+ const token = mintBridgeBootstrapToken(email);
+ const logFd = openSync(join(e2eDir, "bridge.log"), "a");
+
+ const child = spawn(
+ "node",
+ ["dist/index.js", "--token", token, "--api-url", apiUrl],
+ {
+ cwd: join(repoDir, "bridge"),
+ env: { ...process.env, HOME: bridgeHome },
+ stdio: ["ignore", logFd, logFd],
+ detached: true,
+ },
+ );
+ child.unref();
+
+ if (typeof child.pid !== "number") {
+ throw new Error("failed to spawn bridge process (no pid)");
+ }
+ writeFileSync(pidPath, `${child.pid}\n`);
+
+ // --- wait until the new bridge is online again ---
+ await waitForBridgePresence(email, { min: 1, timeoutMs: options.presenceTimeoutMs ?? 40_000 });
+ return child.pid;
+}
diff --git a/e2e/helpers/centrifugo-raw.ts b/e2e/helpers/centrifugo-raw.ts
new file mode 100644
index 0000000..fb385c8
--- /dev/null
+++ b/e2e/helpers/centrifugo-raw.ts
@@ -0,0 +1,221 @@
+import { CENTRIFUGO_WS_URL } from "./config";
+
+/**
+ * Raw Centrifugo bidirectional client (JSON protocol) over the Node global
+ * WebSocket — no `centrifuge` dependency, no `ws`. The security-critical probe:
+ * given a connect token, attempt to subscribe to / publish on an ARBITRARY
+ * channel and report exactly how Centrifugo responded.
+ *
+ * Centrifugo v5 wire protocol (bidirectional JSON): each command is a JSON
+ * object `{ id, : {...} }`; replies correlate by `id` and are either
+ * `{ id, : {...} }` on success or `{ id, error: { code, message } }` on
+ * rejection. Frames may batch several newline-delimited objects; an empty object
+ * `{}` from the server is a ping we answer with `{}`.
+ *
+ * Outcome model — a channel-authorization decision vs a connection failure are
+ * DELIBERATELY distinct:
+ * - resolve { ok: true } → Centrifugo accepted it.
+ * - resolve { ok: false, error: { code, message }} → Centrifugo rejected the
+ * subscribe/publish itself (e.g. code 103 "permission denied" for a
+ * user-limited channel `ch#otherUser` whose owner ≠ the token's `sub`).
+ * - reject (throw) → the connection never
+ * established: transport error, timeout, or the CONNECT command itself was
+ * refused (bad/foreign/expired token). A rejected connect is a precondition
+ * failure, surfaced as a throw so tests never mistake it for a per-channel
+ * authz denial.
+ */
+
+/** A Centrifugo protocol-level error (subscribe/publish rejection). */
+export interface CentrifugoError {
+ code: number;
+ message: string;
+}
+
+/** Outcome of an attempted subscribe/publish against a channel. */
+export interface CentrifugoAttempt {
+ ok: boolean;
+ error?: CentrifugoError;
+}
+
+const CONNECT_TIMEOUT_MS = 10_000;
+const OP_TIMEOUT_MS = 8_000;
+
+interface Reply {
+ id?: number;
+ error?: CentrifugoError;
+ connect?: unknown;
+ subscribe?: unknown;
+ publish?: unknown;
+}
+
+/**
+ * One short-lived raw connection. Opens the socket, sends CONNECT, and exposes a
+ * single command round-trip. Callers use `attemptSubscribe` / `attemptPublish`
+ * rather than this class directly.
+ */
+class RawCentrifugoConnection {
+ private readonly ws: WebSocket;
+ private nextId = 1;
+ private readonly pending = new Map void>();
+ private buffer = "";
+
+ private constructor(ws: WebSocket) {
+ this.ws = ws;
+ this.ws.addEventListener("message", (ev: MessageEvent) => this.onMessage(ev));
+ }
+
+ /** Open a socket and complete the CONNECT handshake, or throw. */
+ static async open(token: string): Promise {
+ const ws = new WebSocket(CENTRIFUGO_WS_URL);
+ const conn = new RawCentrifugoConnection(ws);
+
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error("centrifugo connect timeout")), CONNECT_TIMEOUT_MS);
+ const fail = (err: Error): void => {
+ clearTimeout(timer);
+ try { ws.close(); } catch { /* already closing */ }
+ reject(err);
+ };
+ ws.addEventListener("error", () => fail(new Error("centrifugo websocket error")), { once: true });
+ ws.addEventListener("close", (ev: CloseEvent) => fail(new Error(`centrifugo socket closed before connect (code ${ev.code})`)), { once: true });
+ ws.addEventListener("open", () => {
+ conn
+ .send<{ connect: unknown }>("connect", { token, name: "e2e-raw" })
+ .then(() => { clearTimeout(timer); resolve(); })
+ .catch((err: unknown) => fail(err instanceof Error ? err : new Error(String(err))));
+ }, { once: true });
+ });
+
+ return conn;
+ }
+
+ /** Send one command and await its correlated reply; rejects on a reply `error`. */
+ private send(method: "connect" | "subscribe" | "publish", params: Record): Promise {
+ const id = this.nextId++;
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ this.pending.delete(id);
+ reject(new Error(`centrifugo ${method} timeout`));
+ }, OP_TIMEOUT_MS);
+ this.pending.set(id, (reply) => {
+ clearTimeout(timer);
+ if (reply.error) {
+ reject(new CentrifugoReplyError(reply.error));
+ return;
+ }
+ resolve(reply as T);
+ });
+ try {
+ this.ws.send(JSON.stringify({ id, [method]: params }));
+ } catch (err) {
+ clearTimeout(timer);
+ this.pending.delete(id);
+ reject(err instanceof Error ? err : new Error(String(err)));
+ }
+ });
+ }
+
+ /** Attempt a subscribe; a reply error resolves as { ok:false, error }. */
+ async subscribe(channel: string): Promise {
+ return this.attempt("subscribe", { channel });
+ }
+
+ /** Attempt a publish; a reply error resolves as { ok:false, error }. */
+ async publish(channel: string, data: unknown): Promise {
+ return this.attempt("publish", { channel, data });
+ }
+
+ private async attempt(method: "subscribe" | "publish", params: Record): Promise {
+ try {
+ await this.send(method, params);
+ return { ok: true };
+ } catch (err) {
+ if (err instanceof CentrifugoReplyError) {
+ return { ok: false, error: err.centrifugoError };
+ }
+ throw err;
+ }
+ }
+
+ close(): void {
+ try { this.ws.close(); } catch { /* already closing */ }
+ }
+
+ private onMessage(ev: MessageEvent): void {
+ const text = typeof ev.data === "string" ? ev.data : String(ev.data);
+ // Frames may batch newline-delimited JSON objects; accumulate partials.
+ this.buffer += text;
+ let idx: number;
+ while ((idx = this.buffer.indexOf("\n")) >= 0) {
+ const line = this.buffer.slice(0, idx);
+ this.buffer = this.buffer.slice(idx + 1);
+ this.dispatch(line);
+ }
+ // A single, un-terminated object (the common case) is a complete frame.
+ if (this.buffer.trim().length > 0 && !this.buffer.includes("\n")) {
+ const whole = this.buffer;
+ this.buffer = "";
+ this.dispatch(whole);
+ }
+ }
+
+ private dispatch(line: string): void {
+ const trimmed = line.trim();
+ if (!trimmed) return;
+ let reply: Reply;
+ try {
+ reply = JSON.parse(trimmed) as Reply;
+ } catch {
+ return;
+ }
+ // Server ping is an empty object; answer with an empty pong to stay alive.
+ if (Object.keys(reply).length === 0) {
+ try { this.ws.send("{}"); } catch { /* socket gone */ }
+ return;
+ }
+ if (typeof reply.id === "number") {
+ const resolver = this.pending.get(reply.id);
+ if (resolver) {
+ this.pending.delete(reply.id);
+ resolver(reply);
+ }
+ }
+ // Pushes (no id) are ignored — the probe only cares about command replies.
+ }
+}
+
+class CentrifugoReplyError extends Error {
+ readonly centrifugoError: CentrifugoError;
+ constructor(error: CentrifugoError) {
+ super(`centrifugo error ${error.code}: ${error.message}`);
+ this.centrifugoError = error;
+ }
+}
+
+/**
+ * Open a raw connection with `token`, attempt to subscribe to `channel`, then
+ * close. See the module docstring for the outcome model. Code 103 =
+ * "permission denied" (e.g. user-limited-channel sub mismatch).
+ */
+export async function attemptSubscribe(token: string, channel: string): Promise {
+ const conn = await RawCentrifugoConnection.open(token);
+ try {
+ return await conn.subscribe(channel);
+ } finally {
+ conn.close();
+ }
+}
+
+/**
+ * Open a raw connection with `token`, attempt to publish `data` to `channel`,
+ * then close. See the module docstring for the outcome model. Code 103 =
+ * "permission denied".
+ */
+export async function attemptPublish(token: string, channel: string, data: unknown): Promise {
+ const conn = await RawCentrifugoConnection.open(token);
+ try {
+ return await conn.publish(channel, data);
+ } finally {
+ conn.close();
+ }
+}
diff --git a/e2e/helpers/centrifugo.ts b/e2e/helpers/centrifugo.ts
index 5cd50d3..852e0ac 100644
--- a/e2e/helpers/centrifugo.ts
+++ b/e2e/helpers/centrifugo.ts
@@ -97,6 +97,53 @@ export async function commandsRpcClients(email: string): Promise {
return channels[`commands:rpc#${email}`]?.num_clients ?? 0;
}
+interface PresenceResult {
+ result?: { presence?: Record };
+ error?: { message?: string; code?: number };
+}
+
+/**
+ * Number of bridge clients currently present on `bridges:presence#` (0 if
+ * none / bridge offline). Uses the Centrifugo server `presence` API — the same
+ * signal start-services.sh polls to decide the bridge is online, so it is the
+ * authoritative liveness probe for restart/resurrection tests.
+ */
+export async function bridgePresenceCount(email: string): Promise {
+ const res = await fetch(CENTRIFUGO_API_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-API-Key": CENTRIFUGO_API_KEY,
+ },
+ body: JSON.stringify({
+ method: "presence",
+ params: { channel: `bridges:presence#${email}` },
+ }),
+ });
+ if (!res.ok) {
+ throw new Error(`Centrifugo presence API failed: ${res.status} ${await res.text()}`);
+ }
+ const data = (await res.json()) as PresenceResult;
+ if (data.error) return 0;
+ return Object.keys(data.result?.presence ?? {}).length;
+}
+
+/** Poll until `bridges:presence#` has >= `min` clients, or throw on timeout. */
+export async function waitForBridgePresence(
+ email: string,
+ { min = 1, timeoutMs = 40_000, intervalMs = 1000 }: { min?: number; timeoutMs?: number; intervalMs?: number } = {},
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ for (;;) {
+ const n = await bridgePresenceCount(email);
+ if (n >= min) return n;
+ if (Date.now() >= deadline) {
+ throw new Error(`timed out waiting for bridge presence >= ${min} on bridges:presence#${email} (last=${n})`);
+ }
+ await new Promise((r) => setTimeout(r, intervalMs));
+ }
+}
+
/** Poll until `predicate(state)` is true or timeout. Returns final state. */
export async function waitForChannels(
produce: () => Promise,
diff --git a/e2e/helpers/config.ts b/e2e/helpers/config.ts
index 1f7b696..e648ee4 100644
--- a/e2e/helpers/config.ts
+++ b/e2e/helpers/config.ts
@@ -4,6 +4,9 @@
* so the same constants drive Playwright, the shell scripts, and the shim.
*/
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
/** MUST equal centrifugo/config.json `token_hmac_secret_key`. */
export const CENTRIFUGO_TOKEN_SECRET =
process.env.CENTRIFUGO_TOKEN_SECRET ?? "your-centrifugo-token-secret-change-me";
@@ -15,8 +18,29 @@ export const CENTRIFUGO_API_KEY =
export const CENTRIFUGO_API_URL =
process.env.CENTRIFUGO_API_URL ?? "http://localhost:8000/api";
+/**
+ * Bidirectional Centrifugo client websocket endpoint (the `/connection/websocket`
+ * rung the browser and the raw-client helper connect to). Mirrors env.sh's
+ * NEXT_PUBLIC_CENTRIFUGO_URL; the e2e stack listens on :8000.
+ */
+export const CENTRIFUGO_WS_URL =
+ process.env.CENTRIFUGO_WS_URL ??
+ process.env.NEXT_PUBLIC_CENTRIFUGO_URL ??
+ "ws://127.0.0.1:8000/connection/websocket";
+
export const UI_BASE_URL = process.env.UI_BASE_URL ?? "http://localhost:3000";
+/** The e2e directory (this file lives in e2e/helpers/). */
+export const E2E_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
+
+/**
+ * The bridge's scratch HOME. start-services.sh launches the bridge with HOME
+ * overridden to e2e/.bridge-home so it never touches the real ~/.ftown; its
+ * self-advert pointer, transcript dirs, data dir and refresh token all live
+ * under here.
+ */
+export const BRIDGE_HOME = process.env.E2E_BRIDGE_HOME ?? join(E2E_DIR, ".bridge-home");
+
export const TOKEN_AUDIENCE = "ftown:centrifugo";
// F1: /api/auth/bridge now requires a distinct bootstrap audience.
export const BRIDGE_BOOTSTRAP_AUDIENCE = "ftown:bridge-bootstrap";
diff --git a/e2e/helpers/loops.ts b/e2e/helpers/loops.ts
new file mode 100644
index 0000000..f6b19a9
--- /dev/null
+++ b/e2e/helpers/loops.ts
@@ -0,0 +1,58 @@
+import { expect, type Page } from "@playwright/test";
+
+/**
+ * Drive the dashboard's New Loop modal (ui/src/components/LoopFormModal.tsx) to
+ * create an interval-scheduled loop. The modal has no test ids, so fields are
+ * targeted by their placeholder / options (stable, user-visible anchors).
+ */
+
+export type LoopHarness = "claude" | "cursor" | "codex" | "grok" | "opencode" | "shell";
+
+export interface CreateLoopViaUiInput {
+ name: string;
+ /** Interval in ms; must be a whole number of seconds and >= 1000 (modal floor). */
+ everyMs: number;
+ /** Harness the loop runs each fire. Default "shell" (no API key / claude binary). */
+ harness?: LoopHarness;
+ /** The task prompt run each fire (required by the modal). */
+ task: string;
+ /** Optional working directory. */
+ workingDir?: string;
+}
+
+/**
+ * Open the New Loop modal, fill an interval loop, submit, and wait for the modal
+ * to close. Assumes `page` is logged in, on /dashboard, with a bridge online —
+ * the "Create a new loop" button is disabled until a bridge is present. The
+ * interval is entered in SECONDS (the modal's smallest exact unit), so `everyMs`
+ * must be a whole number of seconds; the modal recomputes everyMs = seconds*1000.
+ * Does NOT assert the loop rendered — verify that via the loops API / Centrifugo.
+ */
+export async function createLoopViaUi(page: Page, input: CreateLoopViaUiInput): Promise {
+ const seconds = input.everyMs / 1000;
+ if (!Number.isInteger(seconds) || seconds < 1) {
+ throw new Error(`everyMs must be a whole number of seconds >= 1000 (got ${input.everyMs})`);
+ }
+
+ await page.locator('button[title="Create a new loop"]').click();
+
+ const dialog = page.getByRole("dialog");
+ await expect(dialog.getByRole("heading", { name: "New Loop" })).toBeVisible();
+
+ await dialog.getByPlaceholder("Nightly cleanup, PR triage, etc.").fill(input.name);
+
+ // Schedule defaults to "interval"; set the unit to seconds for an exact everyMs.
+ await dialog.locator('select:has(option[value="s"])').selectOption("s");
+ await dialog.getByPlaceholder("5").fill(String(seconds));
+
+ await dialog.locator('select:has(option[value="grok"])').selectOption(input.harness ?? "shell");
+
+ if (input.workingDir !== undefined) {
+ await dialog.getByPlaceholder("/path/to/project (optional)").fill(input.workingDir);
+ }
+
+ await dialog.getByPlaceholder("What should this loop do each time it fires?").fill(input.task);
+
+ await dialog.getByRole("button", { name: "Create Loop" }).click();
+ await expect(dialog).toBeHidden({ timeout: 30_000 });
+}
From 440bdbf22a33c03c36083eff36fa586c8848a200 Mon Sep 17 00:00:00 2001
From: Foad Kesheh
Date: Thu, 16 Jul 2026 09:47:20 -0300
Subject: [PATCH 5/8] test(e2e): security (IDOR) + lifecycle/usage/loops/mail
specs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Five specs against the frozen helpers:
- idor-channels: cross-tenant Centrifugo wall — B rejected (code 103)
from A's events/terminal/terminal-input/presence and PUBLISH to A's
commands:rpc; positive control proves the wall, not a broken client.
- idor-http: bridge loopback guards (421/403/401/200), token sub
non-spoofable, device revoke/list scoped by sub (verified WHERE sub).
- session-lifecycle: CRUD + resurrection across a real bridge restart
(same sid resumes running, fresh marker round-trips the revived pty).
- usage: seeded transcript → per-model extraction, dedup, codex cache
decomposition, null-for-shell; keyed off persisted native session id.
- loops-and-mail: run accumulation/run-now/disable/delete + preflight
skip; inbox deliver + long-poll waiter path.
---
e2e/tests/idor-channels.spec.ts | 181 ++++++++++++++
e2e/tests/idor-http.spec.ts | 179 ++++++++++++++
e2e/tests/loops-and-mail.spec.ts | 351 ++++++++++++++++++++++++++++
e2e/tests/session-lifecycle.spec.ts | 165 +++++++++++++
e2e/tests/usage.spec.ts | 270 +++++++++++++++++++++
5 files changed, 1146 insertions(+)
create mode 100644 e2e/tests/idor-channels.spec.ts
create mode 100644 e2e/tests/idor-http.spec.ts
create mode 100644 e2e/tests/loops-and-mail.spec.ts
create mode 100644 e2e/tests/session-lifecycle.spec.ts
create mode 100644 e2e/tests/usage.spec.ts
diff --git a/e2e/tests/idor-channels.spec.ts b/e2e/tests/idor-channels.spec.ts
new file mode 100644
index 0000000..d6096da
--- /dev/null
+++ b/e2e/tests/idor-channels.spec.ts
@@ -0,0 +1,181 @@
+import { test, expect, type BrowserContext } from "@playwright/test";
+import {
+ sharedEmail,
+ registerUser,
+ login,
+ waitForBridgeOnline,
+ createShellSession,
+ getCentrifugoToken,
+ registerAndLogin,
+ type UserCreds,
+} from "../helpers/app";
+import { sessionIdsFor, waitForNewSessionId, commandsRpcClients } from "../helpers/centrifugo";
+import {
+ attemptSubscribe,
+ attemptPublish,
+ type CentrifugoAttempt,
+} from "../helpers/centrifugo-raw";
+
+/**
+ * IDOR / cross-tenant isolation regression suite.
+ *
+ * The ENTIRE cross-tenant wall in this system is two things working together:
+ * 1. Centrifugo's `allow_user_limited_channels`, and
+ * 2. the mandatory `#` suffix on every per-user channel.
+ * A user-limited channel `ch#` may only be subscribed/published by a
+ * connection whose token `sub` equals ``. If that config or the suffix
+ * convention ever regresses, one tenant can read another tenant's terminal,
+ * inject keystrokes, or drive another tenant's bridge. This suite proves the
+ * wall holds by having a fully-valid user B — its OWN, accepted Centrifugo
+ * token — attempt to reach into user A's namespace and be refused every time.
+ *
+ * This is DEFENSIVE regression testing: we assert REJECTION only. We never try
+ * to actually exfiltrate data or execute a command; a well-formed probe is used
+ * purely so the rejection is the authorization layer's doing, not a malformed
+ * frame's.
+ *
+ * "Walled" outcome model (see helpers/centrifugo-raw.ts docstring):
+ * - resolve { ok:false, error.code === 103 } → per-channel authz denial. WALLED.
+ * - throw (connect/subscribe refused entirely) → B never entered the namespace.
+ * Also WALLED (accepted here).
+ * - resolve { ok:true } → THE WALL IS OPEN. Test FAILS
+ * loudly, naming the channel.
+ *
+ * User A must be the shared-email user because the bridge only serves that
+ * identity, and a real shell session can only be created against an online
+ * bridge (the "create session" button is disabled otherwise). User B is a fresh,
+ * isolated account in a second browser context.
+ */
+
+interface TenantA {
+ email: string;
+ sid: string;
+ token: string;
+}
+
+let contextA: BrowserContext;
+let contextB: BrowserContext;
+let tenantA: TenantA;
+let userB: UserCreds;
+let tokenB: string;
+
+/**
+ * Assert that a subscribe/publish attempt by B against one of A's channels is
+ * walled. A thrown rejection is accepted (connect/subscribe refused outright);
+ * a resolved outcome MUST be ok:false — an ok:true means the wall is open and is
+ * surfaced as a loud failure naming the exposed channel. Where the denial
+ * resolves, we additionally require Centrifugo permission-denied code 103.
+ */
+async function expectWalled(
+ channel: string,
+ attempt: () => Promise,
+): Promise {
+ let result: CentrifugoAttempt;
+ try {
+ result = await attempt();
+ } catch {
+ // Connect or subscribe/publish was refused entirely — B never reached into
+ // A's namespace. This is a "walled" outcome; nothing resolved ok:true.
+ return;
+ }
+ expect(
+ result.ok,
+ `SECURITY WALL OPEN: user B (${userB.email}) reached A's channel "${channel}" ` +
+ `with B's own valid Centrifugo token — it resolved ok:true. Cross-tenant ` +
+ `isolation is breached (allow_user_limited_channels / #email suffix regression).`,
+ ).toBe(false);
+ expect(
+ result.error?.code,
+ `"${channel}" was denied but not with Centrifugo permission-denied code 103 ` +
+ `(got ${JSON.stringify(result.error)})`,
+ ).toBe(103);
+}
+
+test.describe("IDOR: cross-tenant channel isolation", () => {
+ test.beforeAll(async ({ browser }) => {
+ // --- User A: the bridge-served shared-email user, in its own context. ---
+ contextA = await browser.newContext();
+ const pageA = await contextA.newPage();
+ const emailA = sharedEmail();
+ await registerUser(emailA);
+ await login(pageA, emailA);
+ await waitForBridgeOnline(pageA);
+
+ // Create a REAL shell session and capture its transport-independent sid via
+ // the terminal-input:# channel the bridge subscribes on create.
+ const before = await sessionIdsFor(emailA);
+ await createShellSession(pageA, `idor-A-${Date.now()}`);
+ const sid = await waitForNewSessionId(emailA, before);
+ const tokenA = await getCentrifugoToken(pageA);
+ tenantA = { email: emailA, sid, token: tokenA };
+
+ // --- User B: a fresh, isolated tenant in a second context. ---
+ contextB = await browser.newContext();
+ const pageB = await contextB.newPage();
+ userB = await registerAndLogin(pageB); // mints a fresh makeUser() identity
+ tokenB = await getCentrifugoToken(pageB);
+ });
+
+ test.afterAll(async () => {
+ await contextA?.close();
+ await contextB?.close();
+ });
+
+ test("B cannot subscribe to A's session events channel", async () => {
+ const channel = `events:${tenantA.sid}#${tenantA.email}`;
+ await expectWalled(channel, () => attemptSubscribe(tokenB, channel));
+ });
+
+ test("B cannot subscribe to A's terminal output channel", async () => {
+ const channel = `terminal:${tenantA.sid}#${tenantA.email}`;
+ await expectWalled(channel, () => attemptSubscribe(tokenB, channel));
+ });
+
+ test("B cannot subscribe to A's terminal-input (keystroke-injection) channel", async () => {
+ const channel = `terminal-input:${tenantA.sid}#${tenantA.email}`;
+ await expectWalled(channel, () => attemptSubscribe(tokenB, channel));
+ });
+
+ test("B cannot subscribe to A's bridge presence channel", async () => {
+ const channel = `bridges:presence#${tenantA.email}`;
+ await expectWalled(channel, () => attemptSubscribe(tokenB, channel));
+ });
+
+ test("B cannot PUBLISH to A's command/RPC channel", async () => {
+ // The load-bearing wall: the bridge executes any command arriving on
+ // commands:rpc# with no per-caller payload check, so publish-authz is
+ // the ONLY thing stopping B from driving A's bridge. A well-formed-ish
+ // envelope is used so the rejection is authorization's doing, not a bad frame.
+ const channel = `commands:rpc#${tenantA.email}`;
+ const bridgeClientsBefore = await commandsRpcClients(tenantA.email);
+
+ await expectWalled(channel, () =>
+ attemptPublish(tokenB, channel, { type: "list_sessions", requestId: "idor-probe" }),
+ );
+
+ // Defense in depth: because Centrifugo rejected the publish, the envelope
+ // never reached A's bridge, so it cannot have acted. The observable proxy is
+ // that A's bridge remains the sole, undisturbed subscriber on its command
+ // channel — B's rejected publish neither joined it nor knocked the bridge off.
+ const bridgeClientsAfter = await commandsRpcClients(tenantA.email);
+ expect(
+ bridgeClientsAfter,
+ `A's bridge subscriber count on ${channel} changed after B's rejected publish ` +
+ `(${bridgeClientsBefore} → ${bridgeClientsAfter}) — the bridge may have reacted`,
+ ).toBe(bridgeClientsBefore);
+ expect(bridgeClientsAfter, `A's bridge should still be listening on ${channel}`).toBeGreaterThanOrEqual(1);
+ });
+
+ test("positive control: B CAN subscribe to B's OWN command/RPC channel", async () => {
+ // Proves the raw client + B's token work and that the rejections above are
+ // authorization, not a broken helper: B on its own user-limited channel is
+ // allowed and must resolve ok:true.
+ const channel = `commands:rpc#${userB.email}`;
+ const result = await attemptSubscribe(tokenB, channel);
+ expect(
+ result.ok,
+ `positive control failed: B could not subscribe to its OWN channel "${channel}" ` +
+ `(${JSON.stringify(result.error)}) — the rejections above may be a broken client, not authz`,
+ ).toBe(true);
+ });
+});
diff --git a/e2e/tests/idor-http.spec.ts b/e2e/tests/idor-http.spec.ts
new file mode 100644
index 0000000..216bdb5
--- /dev/null
+++ b/e2e/tests/idor-http.spec.ts
@@ -0,0 +1,179 @@
+import { test, expect, type APIRequestContext } from "@playwright/test";
+import { makeUser, registerAndLogin, sharedEmail, E2E_PASSWORD } from "../helpers/app";
+import { bridgeApiFetch, readBridgePointer } from "../helpers/bridge-api";
+import { UI_BASE_URL } from "../helpers/config";
+
+/**
+ * Defensive authZ / IDOR regression suite. Every test asserts that a guard
+ * RETURNS THE SECURE STATUS for hostile input — it never exploits anything and
+ * never mutates cross-tenant state. Two groups:
+ *
+ * GROUP 1 — the bridge's loopback HTTP API (bind 127.0.0.1) triple-guards
+ * /api/**: a spoofed Host ⇒ 421, a non-localhost Origin ⇒ 403, a missing/wrong
+ * bearer ⇒ 401. A positive control (legit pointer bearer + loopback host) ⇒ 200
+ * proves the guards deny ONLY bad input.
+ *
+ * GROUP 2 — the Next.js UI API routes (NextAuth-protected, owner-scoped). Acting
+ * as user B (or unauthenticated) against user A's resources must never leak or
+ * mutate A's data: the connect-token `sub` is the session email (unspoofable),
+ * unauthenticated calls are rejected, revoking A's bridge as B is a 0-row no-op
+ * (404) that leaves A's bridge intact, and B's device list never contains A's
+ * bridgeId.
+ *
+ * User A is the harness's shared user (sharedEmail()) — the running e2e bridge
+ * was onboarded to it via /api/auth/bridge, so a bridge_refresh row for
+ * pointer.bridgeId is owned by A. User B is a fresh, isolated account.
+ */
+
+/** Decode a JWT payload (base64url) WITHOUT verifying — we only inspect claims. */
+function decodeJwtPayload(token: string): Record {
+ const parts = token.split(".");
+ if (parts.length !== 3) throw new Error(`not a JWT (expected 3 parts): ${token}`);
+ const json = Buffer.from(parts[1], "base64url").toString("utf8");
+ return JSON.parse(json) as Record;
+}
+
+interface DeviceListBody {
+ devices?: Array<{ bridgeId?: string; revoked?: boolean }>;
+}
+
+/** GET /api/bridges/devices with the given (authenticated or anon) request context. */
+async function getDevices(req: APIRequestContext): Promise<{ status: number; body: DeviceListBody }> {
+ const res = await req.get(`${UI_BASE_URL}/api/bridges/devices`);
+ const body = res.status() === 200 ? ((await res.json()) as DeviceListBody) : {};
+ return { status: res.status(), body };
+}
+
+test.describe("GROUP 1 — bridge loopback API guards", () => {
+ test("wrong Host ⇒ 421 (Misdirected Request)", async () => {
+ const res = await bridgeApiFetch("GET", "/api/sessions", { host: "evil.example.com" });
+ expect(res.status, "spoofed Host must be rejected with 421").toBe(421);
+ });
+
+ test("non-localhost Origin ⇒ 403", async () => {
+ const res = await bridgeApiFetch("GET", "/api/sessions", { origin: "http://evil.example.com" });
+ expect(res.status, "non-localhost Origin must be rejected with 403").toBe(403);
+ });
+
+ test("missing bearer ⇒ 401", async () => {
+ const res = await bridgeApiFetch("GET", "/api/sessions", { bearer: null });
+ expect(res.status, "missing Authorization must be rejected with 401").toBe(401);
+ });
+
+ test("wrong bearer ⇒ 401", async () => {
+ const res = await bridgeApiFetch("GET", "/api/sessions", { bearer: "wrong-token-xxxx" });
+ expect(res.status, "wrong bearer token must be rejected with 401").toBe(401);
+ });
+
+ test("positive control: legit pointer bearer + loopback host/origin ⇒ 200", async () => {
+ const res = await bridgeApiFetch("GET", "/api/sessions", {});
+ expect(res.status, "guards must ADMIT a legitimate loopback+bearer call").toBe(200);
+ });
+});
+
+test.describe("GROUP 2 — UI API route authZ / IDOR", () => {
+ test("Centrifugo connect-token sub is the session email — body input is ignored", async ({ browser }) => {
+ const context = await browser.newContext();
+ try {
+ const page = await context.newPage();
+ const b = await registerAndLogin(page, makeUser("e2e-idor-b"));
+
+ // Attempt to spoof the subject via the request body. The route (POST() with
+ // no request param) signs session.user.email and ignores input entirely.
+ const res = await page.request.post(`${UI_BASE_URL}/api/auth/token`, {
+ data: { sub: "attacker@evil.example.com", email: "attacker@evil.example.com" },
+ });
+ expect(res.status(), "authenticated token mint must succeed").toBe(200);
+ const { token } = (await res.json()) as { token?: string };
+ expect(token, "route must return a token").toBeTruthy();
+
+ const claims = decodeJwtPayload(token!);
+ expect(claims.sub, "token sub must be the session email, never body-controlled").toBe(b.email);
+ expect(claims.aud, "token audience must be pinned to the Centrifugo aud").toBe("ftown:centrifugo");
+ } finally {
+ await context.close();
+ }
+ });
+
+ test("unauthenticated /api/auth/token and /api/bridges/devices are rejected", async ({ browser }) => {
+ // Fresh context, never logged in ⇒ no NextAuth session cookie.
+ const anon = await browser.newContext();
+ try {
+ const tokenRes = await anon.request.post(`${UI_BASE_URL}/api/auth/token`);
+ expect(tokenRes.status(), "anon token mint must be denied").not.toBe(200);
+ expect([401, 403], "anon /api/auth/token secure status").toContain(tokenRes.status());
+
+ const devRes = await anon.request.get(`${UI_BASE_URL}/api/bridges/devices`);
+ expect(devRes.status(), "anon device list must be denied").not.toBe(200);
+ expect([401, 403], "anon /api/bridges/devices secure status").toContain(devRes.status());
+ } finally {
+ await anon.close();
+ }
+ });
+
+ test("device revoke IDOR — B cannot revoke A's bridge; A's bridge survives", async ({ browser }) => {
+ const aBridgeId = readBridgePointer().bridgeId;
+ expect(aBridgeId, "running bridge must advertise a bridgeId").toBeTruthy();
+
+ const aCtx = await browser.newContext();
+ const bCtx = await browser.newContext();
+ try {
+ // User A = the harness user that owns the running bridge.
+ const aPage = await aCtx.newPage();
+ await registerAndLogin(aPage, { email: sharedEmail(), password: E2E_PASSWORD });
+
+ // Precondition: A owns pointer.bridgeId and it is not revoked.
+ const before = await getDevices(aPage.request);
+ expect(before.status).toBe(200);
+ const aDeviceBefore = (before.body.devices ?? []).find((d) => d.bridgeId === aBridgeId);
+ expect(aDeviceBefore, `A must own bridge ${aBridgeId} before the IDOR attempt`).toBeTruthy();
+ expect(aDeviceBefore?.revoked, "A's bridge must start un-revoked").toBe(false);
+
+ // User B = fresh, isolated account.
+ const bPage = await bCtx.newPage();
+ await registerAndLogin(bPage, makeUser("e2e-idor-b"));
+
+ // B attempts to revoke A's bridge. revokeDevice is scoped by sub, so the
+ // UPDATE matches 0 rows for B ⇒ the route returns 404 {error:"Device not found"}.
+ const revoke = await bPage.request.post(`${UI_BASE_URL}/api/bridges/devices/revoke`, {
+ data: { bridgeId: aBridgeId },
+ });
+ expect(
+ revoke.status(),
+ `IDOR: B must NOT be able to revoke A's bridge ${aBridgeId} — expected secure 404`,
+ ).toBe(404);
+ // If the route ever returns 2xx here, the assertion above already fails
+ // loudly naming the exposed bridgeId.
+
+ // CRUCIAL: A's bridge is untouched — still present and still un-revoked.
+ const after = await getDevices(aPage.request);
+ expect(after.status).toBe(200);
+ const aDeviceAfter = (after.body.devices ?? []).find((d) => d.bridgeId === aBridgeId);
+ expect(aDeviceAfter, `A's bridge ${aBridgeId} must still exist after B's revoke attempt`).toBeTruthy();
+ expect(aDeviceAfter?.revoked, `A's bridge ${aBridgeId} must remain un-revoked (no cross-tenant mutation)`).toBe(
+ false,
+ );
+ } finally {
+ await aCtx.close();
+ await bCtx.close();
+ }
+ });
+
+ test("device list isolation — B's device list never contains A's bridgeId", async ({ browser }) => {
+ const aBridgeId = readBridgePointer().bridgeId;
+ const bCtx = await browser.newContext();
+ try {
+ const bPage = await bCtx.newPage();
+ await registerAndLogin(bPage, makeUser("e2e-idor-b"));
+
+ const { status, body } = await getDevices(bPage.request);
+ expect(status, "B's authenticated device list must succeed").toBe(200);
+ const ids = (body.devices ?? []).map((d) => d.bridgeId);
+ expect(ids, `IDOR: B's device list leaked A's bridge ${aBridgeId}`).not.toContain(aBridgeId);
+ // B paired no bridge, so its list is empty — proving strict owner-scoping.
+ expect(body.devices ?? [], "fresh user B must see zero devices").toHaveLength(0);
+ } finally {
+ await bCtx.close();
+ }
+ });
+});
diff --git a/e2e/tests/loops-and-mail.spec.ts b/e2e/tests/loops-and-mail.spec.ts
new file mode 100644
index 0000000..621fdc3
--- /dev/null
+++ b/e2e/tests/loops-and-mail.spec.ts
@@ -0,0 +1,351 @@
+import { test, expect, type Page } from "@playwright/test";
+import { sharedEmail, registerUser, login, waitForBridgeOnline } from "../helpers/app";
+import { createLoopViaUi } from "../helpers/loops";
+import { bridgeApiFetch } from "../helpers/bridge-api";
+
+/**
+ * E2E coverage for the two loopback surfaces that had none: the loop
+ * scheduler/controller (bridge/src/loop-controller.ts + loop-scheduler.ts) and
+ * the freshly-extracted MailDeliveryService (bridge/src/mail-delivery.ts).
+ *
+ * Everything here drives the bridge's loopback HTTP API directly via
+ * `bridgeApiFetch` (127.0.0.1:, bearer from the bridge.json pointer). The
+ * one UI touch is `createLoopViaUi` — it exercises the LoopFormModal create path.
+ *
+ * Real route shapes used (bridge/src/local-api-server.ts):
+ * - GET /api/loops -> 200 { loops: Loop[] }
+ * - POST /api/loops -> 201 { loop: Loop } (bridgeId forced by controller)
+ * - GET /api/loops/:id -> 200 { loop: Loop } | 404
+ * - PATCH /api/loops/:id -> 200 { loop: Loop }
+ * - DELETE /api/loops/:id -> 200 { removed: boolean, loopId }
+ * - POST /api/loops/:id/run-now -> 200 { fired: true, loop } | { fired:false, reason }
+ * - GET /api/loops/:id/runs -> 200 { runs: LoopRunRecord[] }
+ * - POST /api/sessions -> 201 { session: WireSession }
+ * - POST /api/sessions/:id/inbox -> 201 { id } (MailDeliveryService.acceptMail)
+ * - GET /api/sessions/:id/inbox -> 200 { messages: MailMessage[] }
+ * query: wait= (long poll), peek=1, all=1, limit=
+ *
+ * NOTE: the scheduler ticks every 30s (LOOP_TICK_INTERVAL_MS), so natural
+ * interval accrual is slow; run-now (scheduler.kick -> immediate out-of-band
+ * tick) is the timely driver for the mutation assertions.
+ */
+
+// ---- typed views of the JSON bodies we assert on (bodies come back as unknown) ----
+
+interface LoopLite {
+ id: string;
+ name: string;
+ enabled: boolean;
+ runCount: number;
+ skipCount: number;
+ lastStatus?: string;
+}
+interface LoopRunRecordLite {
+ id: string;
+ status: string;
+}
+interface WireSessionLite {
+ id: string;
+}
+interface MailMessageLite {
+ id: string;
+ from: string;
+ fromName?: string;
+ body: string;
+ to: string;
+}
+
+// ---- loop helpers (loopback) ----
+
+async function listLoops(): Promise {
+ const res = await bridgeApiFetch("GET", "/api/loops");
+ expect(res.status, "GET /api/loops must succeed").toBe(200);
+ return (res.body as { loops: LoopLite[] }).loops;
+}
+
+async function getLoop(id: string): Promise {
+ const res = await bridgeApiFetch("GET", `/api/loops/${id}`);
+ if (res.status === 404) return null;
+ expect(res.status, `GET /api/loops/${id} must succeed`).toBe(200);
+ return (res.body as { loop: LoopLite }).loop;
+}
+
+async function runsOf(id: string): Promise {
+ const res = await bridgeApiFetch("GET", `/api/loops/${id}/runs`);
+ expect(res.status, `GET /api/loops/${id}/runs must succeed`).toBe(200);
+ return (res.body as { runs: LoopRunRecordLite[] }).runs;
+}
+
+/** Poll the loop list until a loop with the given name appears; return its id. */
+async function findLoopIdByName(name: string): Promise {
+ let id = "";
+ await expect
+ .poll(async () => {
+ const loop = (await listLoops()).find((l) => l.name === name);
+ if (loop) id = loop.id;
+ return Boolean(loop);
+ }, { timeout: 20_000, message: `loop "${name}" never appeared in GET /api/loops` })
+ .toBe(true);
+ return id;
+}
+
+async function deleteLoopQuietly(id: string | undefined): Promise {
+ if (!id) return;
+ try {
+ await bridgeApiFetch("DELETE", `/api/loops/${id}`);
+ } catch {
+ // best-effort cleanup — the assertion that mattered already ran
+ }
+}
+
+/** No run for the loop is currently in the 'running' state (shell runs finish fast). */
+async function noRunActive(id: string): Promise {
+ return (await runsOf(id)).every((r) => r.status !== "running");
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function loginAndBridge(page: Page): Promise {
+ const email = sharedEmail();
+ await registerUser(email);
+ await login(page, email);
+ // Bridge presence enables both the "Create a new session" and "Create a new
+ // loop" buttons; this waits on the former as the authoritative online signal.
+ await waitForBridgeOnline(page);
+}
+
+// ============================ LOOPS ============================
+
+test.describe("loop scheduler / controller", () => {
+ test("create -> runs accumulate -> run-now -> disable -> delete", async ({ page }) => {
+ // The natural first fire can take up to one 30s tick; give the whole flow room.
+ test.setTimeout(120_000);
+
+ const name = `e2e-loop-${Date.now()}`;
+ const marker = `E2ELOOP${Date.now()}`;
+ let loopId: string | undefined;
+
+ await loginAndBridge(page);
+ await createLoopViaUi(page, {
+ name,
+ everyMs: 1000,
+ harness: "shell",
+ task: `echo ${marker}`,
+ });
+
+ loopId = await findLoopIdByName(name);
+ const id = loopId;
+
+ try {
+ // Keep every run node (default retention prunes to 10) so the counts below
+ // are strictly monotonic and never confounded by auto-clear.
+ const patch = await bridgeApiFetch("PATCH", `/api/loops/${id}`, {
+ body: { retention: { autoClearAfterRuns: null } },
+ });
+ expect(patch.status, "PATCH retention must succeed").toBe(200);
+
+ // (1) runs accumulate from the scheduler itself (natural tick, <=30s).
+ await expect
+ .poll(async () => (await runsOf(id)).length, {
+ timeout: 45_000,
+ message: "scheduler never produced a run record",
+ })
+ .toBeGreaterThanOrEqual(1);
+
+ // (2) run-now adds an extra run. Wait out any in-flight run first so the
+ // skip-overlap guard can't turn our manual fire into a no-op.
+ await expect.poll(() => noRunActive(id), { timeout: 20_000 }).toBe(true);
+ const before = (await runsOf(id)).length;
+
+ const runNow = await bridgeApiFetch("POST", `/api/loops/${id}/run-now`);
+ expect(runNow.status, "run-now must succeed").toBe(200);
+ expect((runNow.body as { fired: boolean }).fired, "run-now must fire").toBe(true);
+
+ await expect
+ .poll(async () => (await runsOf(id)).length, {
+ timeout: 20_000,
+ message: "run-now did not produce an extra run",
+ })
+ .toBeGreaterThan(before);
+
+ // (3) disable -> no NEW runs accrue. Let anything in flight settle, snapshot,
+ // wait, and assert the count held (a disabled loop is skipped on every tick,
+ // and the next natural tick is 30s away regardless).
+ const disable = await bridgeApiFetch("PATCH", `/api/loops/${id}`, {
+ body: { enabled: false },
+ });
+ expect(disable.status, "disable PATCH must succeed").toBe(200);
+ expect((disable.body as { loop: LoopLite }).loop.enabled).toBe(false);
+
+ await expect.poll(() => noRunActive(id), { timeout: 20_000 }).toBe(true);
+ const settled = (await runsOf(id)).length;
+ await sleep(6_000);
+ expect(
+ (await runsOf(id)).length,
+ "a disabled loop must not accrue new runs",
+ ).toBe(settled);
+
+ // (4) delete -> gone from the authoritative loop list.
+ const del = await bridgeApiFetch("DELETE", `/api/loops/${id}`);
+ expect(del.status, "DELETE must succeed").toBe(200);
+ expect((del.body as { removed: boolean }).removed, "DELETE must report removed").toBe(true);
+ loopId = undefined;
+
+ await expect
+ .poll(async () => (await listLoops()).some((l) => l.id === id), {
+ timeout: 10_000,
+ message: "deleted loop still present in GET /api/loops",
+ })
+ .toBe(false);
+ expect(await getLoop(id), "GET /api/loops/:id must 404 after delete").toBeNull();
+ } finally {
+ await deleteLoopQuietly(loopId);
+ }
+ });
+
+ test("preflight non-zero -> skip recorded, no run spawned", async () => {
+ // createLoopViaUi exposes no preflight field, so this drives the loopback
+ // create route (POST /api/loops) directly — the cheap, deterministic path.
+ const name = `e2e-preflight-${Date.now()}`;
+ let loopId: string | undefined;
+
+ const create = await bridgeApiFetch("POST", "/api/loops", {
+ body: {
+ name,
+ // bridgeId is forced to the running bridge by the controller; any value works.
+ bridgeId: "ignored-forced-by-controller",
+ schedule: { kind: "interval", everyMs: 1000 },
+ harness: "shell",
+ task: "echo should-never-run",
+ enabled: true,
+ overlapPolicy: "skip",
+ retention: { autoClearAfterRuns: null },
+ // /bin/sh -c 'exit 1' -> non-zero -> the scheduler ABORTS: skip, no session.
+ preflight: { command: "exit 1" },
+ },
+ });
+ expect(create.status, "POST /api/loops must create the loop").toBe(201);
+ loopId = (create.body as { loop: LoopLite }).loop.id;
+ const id = loopId;
+
+ try {
+ await expect
+ .poll(async () => (await getLoop(id))?.skipCount ?? 0, {
+ timeout: 45_000,
+ message: "preflight-failing loop never recorded a skip",
+ })
+ .toBeGreaterThanOrEqual(1);
+
+ const loop = await getLoop(id);
+ expect(loop?.lastStatus, "a skipped fire sets lastStatus=skipped").toBe("skipped");
+ expect(loop?.runCount, "a preflight skip must NOT spawn a run").toBe(0);
+ // The skip path returns before creating a run node.
+ expect((await runsOf(id)).length, "a skip creates no run record").toBe(0);
+ } finally {
+ await deleteLoopQuietly(loopId);
+ }
+ });
+});
+
+// ============================ MAIL ============================
+
+async function createShellSessionApi(name: string): Promise {
+ const res = await bridgeApiFetch("POST", "/api/sessions", {
+ body: { shellType: "shell", name },
+ });
+ expect(res.status, "POST /api/sessions must create a shell session").toBe(201);
+ return (res.body as { session: WireSessionLite }).session.id;
+}
+
+async function deleteSessionQuietly(id: string | undefined): Promise {
+ if (!id) return;
+ try {
+ await bridgeApiFetch("DELETE", `/api/sessions/${id}`);
+ } catch {
+ // best-effort
+ }
+}
+
+async function postMail(
+ sessionId: string,
+ mail: { body: string; from: string; fromName?: string },
+): Promise {
+ const res = await bridgeApiFetch("POST", `/api/sessions/${sessionId}/inbox`, { body: mail });
+ expect(res.status, "POST inbox must accept the message").toBe(201);
+ expect((res.body as { id: string }).id, "accepted mail returns an id").toBeTruthy();
+}
+
+test.describe("mail delivery service", () => {
+ test("inbox deliver + read: message is stored with sender and text", async () => {
+ let sessionId: string | undefined;
+ try {
+ sessionId = await createShellSessionApi(`e2e-mail-${Date.now()}`);
+ const body = `hello-mail-${Date.now()}`;
+
+ await postMail(sessionId, { body, from: "e2e-sender", fromName: "E2E Sender" });
+
+ // peek=1&all=1 reads the full inbox without marking anything delivered.
+ const read = await bridgeApiFetch("GET", `/api/sessions/${sessionId}/inbox?peek=1&all=1`);
+ expect(read.status, "GET inbox must succeed").toBe(200);
+ const messages = (read.body as { messages: MailMessageLite[] }).messages;
+
+ const found = messages.find((m) => m.body === body);
+ expect(found, "posted message must be present in the inbox").toBeTruthy();
+ expect(found?.from, "sender is preserved").toBe("e2e-sender");
+ expect(found?.fromName, "friendly sender name is preserved").toBe("E2E Sender");
+ expect(found?.to, "recipient is the session id").toBe(sessionId);
+ } finally {
+ await deleteSessionQuietly(sessionId);
+ }
+ });
+
+ test("long-poll delivery: an open poll resolves when mail arrives", async () => {
+ let sessionId: string | undefined;
+ try {
+ sessionId = await createShellSessionApi(`e2e-poll-${Date.now()}`);
+ const sid = sessionId;
+
+ // A session only earns the long-poll listen window if it "participates in
+ // mail" (parent/children/orchestrator/prior history). Prime that history:
+ // post one message and drain it, so the subsequent poll actually HOLDS
+ // instead of returning immediately with effectiveWait=0.
+ await postMail(sid, { body: "prime", from: "e2e-primer" });
+ const drain = await bridgeApiFetch("GET", `/api/sessions/${sid}/inbox`); // wait=0 -> drain
+ expect(drain.status).toBe(200);
+
+ const payload = `poll-mail-${Date.now()}`;
+
+ // Open the long poll (wait=5s, well under the 30s server cap) on the now-empty
+ // inbox, then post while it is held. Promise.all overlaps the two; the small
+ // delay makes it overwhelmingly likely the waiter is registered first, which
+ // is the path being proven.
+ const [pollRes] = await Promise.all([
+ bridgeApiFetch("GET", `/api/sessions/${sid}/inbox?wait=5`),
+ (async () => {
+ await sleep(750);
+ await postMail(sid, { body: payload, from: "e2e-waker" });
+ })(),
+ ]);
+
+ expect(pollRes.status, "long poll must resolve 200").toBe(200);
+ const messages = (pollRes.body as { messages: MailMessageLite[] }).messages;
+ const found = messages.find((m) => m.body === payload);
+ expect(found, "long poll must resolve with the message posted while it was open").toBeTruthy();
+ expect(found?.from).toBe("e2e-waker");
+ } finally {
+ await deleteSessionQuietly(sessionId);
+ }
+ });
+
+ // (5) nudge/injection — SKIPPED. The idle nudge IS loopback-observable (it
+ // runner.write()s a one-line pointer into the session PTY, visible via
+ // /api/sessions/:id/screen), but it fires only after MAIL_NUDGE_DELAY_MS (5s)
+ // AND the long single-line pointer is subject to xterm/PTY line-wrapping, so a
+ // substring assertion on the rendered screen is genuinely flaky. Per the task's
+ // "SKIP rather than a flaky check" guidance, we do not assert it here.
+ test.skip("nudge injects a mail pointer into the session PTY", async () => {
+ // Intentionally empty: see the comment above for why this is skipped.
+ });
+});
diff --git a/e2e/tests/session-lifecycle.spec.ts b/e2e/tests/session-lifecycle.spec.ts
new file mode 100644
index 0000000..603260b
--- /dev/null
+++ b/e2e/tests/session-lifecycle.spec.ts
@@ -0,0 +1,165 @@
+import { test, expect } from "@playwright/test";
+import {
+ sharedEmail,
+ registerUser,
+ login,
+ waitForBridgeOnline,
+ createShellSession,
+ openSession,
+ runMarkerInTerminal,
+} from "../helpers/app";
+import { sessionIdsFor, waitForNewSessionId } from "../helpers/centrifugo";
+import { bridgeApiFetch } from "../helpers/bridge-api";
+import { restartBridge } from "../helpers/bridge-process";
+
+/**
+ * Session lifecycle e2e: the refactored launch/lifecycle paths
+ * (createFtownSession / relaunchFtownSession / session-controller / terminal-pump)
+ * that had ZERO coverage beyond fresh-create.
+ *
+ * Harness is SHELL ONLY (New Session modal → shellType "shell" = plain zsh), so
+ * there is no claude binary, no API key and no LLM cost — the terminal-marker
+ * round-trip (xterm has no local echo, so any echoed marker proves a live PTY over
+ * a working bidirectional data plane) is the data-plane proof, exactly as in
+ * direct-transport.spec.ts.
+ *
+ * Two authoritative signals are used:
+ * - The bridge loopback HTTP API via bridgeApiFetch("GET","/api/sessions/:id") →
+ * { session: { status } } (200) or { error } (404). status ∈
+ * 'pending'|'running'|'completed'|'error' (bridge/src/types.ts). A stopped
+ * session becomes 'completed'; a dead/failed one 'error'. The pointer
+ * (port+bearer) is re-read fresh on every call, so it survives a bridge restart.
+ * - The session-list UI badge text: running→"running"/"idle", completed→"done".
+ */
+
+interface SessionEnvelope {
+ session?: { status?: string };
+ error?: string;
+}
+
+/** Poll the bridge API for a session's reported status (or `http:` on non-200). */
+async function statusOf(sid: string): Promise {
+ const res = await bridgeApiFetch("GET", `/api/sessions/${sid}`);
+ const body = res.body as SessionEnvelope;
+ return body.session?.status ?? `http:${res.status}`;
+}
+
+test.describe("session lifecycle", () => {
+ test("create → interact → stop → remove (controller CRUD round-trip)", async ({ page }) => {
+ const email = sharedEmail();
+ await registerUser(email);
+ await login(page, email);
+ await waitForBridgeOnline(page);
+
+ // --- CREATE + INTERACT (data plane up) ---
+ const name = `crud-${Date.now()}`;
+ const before = await sessionIdsFor(email);
+ await createShellSession(page, name);
+ const sid = await waitForNewSessionId(email, before);
+ await openSession(page, name);
+ await runMarkerInTerminal(page, `CRUD${Date.now()}`);
+
+ const row = page.getByRole("button", { name: new RegExp(name) });
+
+ // Bridge confirms it is live before we tear it down.
+ await expect
+ .poll(() => statusOf(sid), { timeout: 20_000, message: `session ${sid} should be running` })
+ .toBe("running");
+
+ // --- STOP via the row context menu (Stop menuitem renders only while running/pending) ---
+ await row.click({ button: "right" });
+ const menu = page.getByRole("menu", { name: "Session actions" });
+ await menu.getByRole("menuitem", { name: "Stop" }).click();
+
+ // session-controller stop() drives status → 'completed' (no 'stopped'/'exited' in the enum).
+ await expect
+ .poll(() => statusOf(sid), { timeout: 30_000, message: `stop must move ${sid} to completed` })
+ .toBe("completed");
+ // …and the UI badge reflects it (completed renders as "done").
+ await expect(row.getByText("done", { exact: true })).toBeVisible({ timeout: 15_000 });
+
+ // --- REMOVE via the row context menu (Remove is always present) ---
+ await row.click({ button: "right" });
+ await page
+ .getByRole("menu", { name: "Session actions" })
+ .getByRole("menuitem", { name: "Remove" })
+ .click();
+
+ // It disappears from the list…
+ await expect(row).toHaveCount(0, { timeout: 15_000 });
+ // …and the controller remove is real end-to-end: GET now 404s.
+ await expect
+ .poll(async () => (await bridgeApiFetch("GET", `/api/sessions/${sid}`)).status, {
+ timeout: 20_000,
+ message: `removed session ${sid} must 404 on the bridge API`,
+ })
+ .toBe(404);
+ });
+
+ test("resurrection across bridge restart (SessionResurrection + relaunch 'resume')", async ({
+ page,
+ }) => {
+ // Restart (≤40s presence wait) + two marker round-trips need headroom over the 90s default.
+ test.setTimeout(180_000);
+
+ const email = sharedEmail();
+ await registerUser(email);
+ await login(page, email);
+ await waitForBridgeOnline(page);
+
+ // --- CREATE + confirm running (marker round-trips) ---
+ const name = `resurrect-${Date.now()}`;
+ const before = await sessionIdsFor(email);
+ await createShellSession(page, name);
+ const sid = await waitForNewSessionId(email, before);
+ await openSession(page, name);
+ await runMarkerInTerminal(page, `PRE${Date.now()}`);
+ await expect
+ .poll(() => statusOf(sid), { timeout: 20_000, message: `session ${sid} should be running` })
+ .toBe("running");
+
+ // --- KILL + respawn the bridge on the SAME scratch HOME ---
+ // The old transport dies with the old process; the new bridge re-onboards off
+ // its persisted identity + refresh token and runs SessionResurrection.
+ await restartBridge();
+
+ // RESURRECTION SIGNAL (the exact assertion): the SAME session id re-appears in
+ // the fresh bridge's session list reporting status 'running' — proving
+ // SessionResurrection reattached (tmux) or relaunchFtownSession(..,'resume')
+ // respawned it. A lost session would 404 (http:404); a session it could not
+ // revive would be marked 'error'. Either fails this poll with detail.
+ await expect
+ .poll(() => statusOf(sid), {
+ timeout: 60_000,
+ message: `session ${sid} must be RESURRECTED as running after bridge restart (404/error ⇒ resurrection failed)`,
+ })
+ .toBe("running");
+
+ // Re-onboard the client against the fresh bridge and re-attach the pty.
+ await page.reload();
+ await waitForBridgeOnline(page);
+ await openSession(page, name);
+ const row = page.getByRole("button", { name: new RegExp(name) });
+ await expect(row.getByText(/running|idle/).first()).toBeVisible({ timeout: 30_000 });
+
+ // A NEW marker proves the resurrected PTY is a LIVE process, not a stale record.
+ await runMarkerInTerminal(page, `POST${Date.now()}`);
+ });
+
+ /**
+ * RETRY: the affordance EXISTS but is unreachable in a shell-only harness.
+ *
+ * Retry is a Dashboard toolbar button (className "btn-warn",
+ * getByRole("button",{name:"Retry"})) that renders ONLY when the selected
+ * session's status === 'error'. It is NOT in the session context menu and is NOT
+ * shown for a 'completed'/'done' session. A plain-zsh shell session that is
+ * stopped becomes 'completed', never 'error', and this harness has no
+ * deterministic, cost-free way to drive a session into 'error' via the UI. Rather
+ * than fabricate a failure state, this case is skipped with the affordance
+ * documented (per the task: SKIP with a note when there is no honest path).
+ */
+ test.skip("retry a stopped/errored session round-trips a new marker", async () => {
+ // Intentionally skipped — Retry UI confirmed to exist (Dashboard toolbar,
+ // error-gated) but not reachable with the shell-only harness. See note above.
+ });
+});
diff --git a/e2e/tests/usage.spec.ts b/e2e/tests/usage.spec.ts
new file mode 100644
index 0000000..a0b1b58
--- /dev/null
+++ b/e2e/tests/usage.spec.ts
@@ -0,0 +1,270 @@
+import { test, expect } from "@playwright/test";
+import { randomUUID } from "node:crypto";
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+import { bridgeApiFetch } from "../helpers/bridge-api";
+import {
+ BRIDGE_HOME,
+ claudeProjectSlug,
+ seedClaudeTranscript,
+ seedCodexRollout,
+} from "../helpers/bridge-fixtures";
+
+/**
+ * Per-session token-usage extraction e2e (bridge/src/usage-collector.ts).
+ *
+ * This is the first end-to-end coverage of the usage feature. It exercises the
+ * REAL running bridge over its loopback HTTP API (bridge-api.ts), not a unit
+ * harness: it creates a session, seeds a harness-native transcript the collector
+ * reads, then asserts `GET /api/sessions/:id/usage` returns the v2 SessionUsage
+ * shape with correct sums, per-model math, model ordering, harness tag, and NO
+ * costUsd field.
+ *
+ * BINDING THE NATIVE SESSION ID — no real claude/codex is run in e2e (shell
+ * sessions only), so the transcript is keyed by a NATIVE id we choose. The clean,
+ * source-honest path is that the bridge's create route accepts and persists
+ * `claudeSessionId` / `codexSessionId` verbatim from the POST body
+ * (bridge/src/create-ftown-session.ts) — the collector then keys off whichever id
+ * is present on the stored Session, independent of shellType
+ * (bridge/src/usage-collector.ts). So we create a plain `shell` session carrying a
+ * chosen native id + a known workingDir, seed a matching transcript under the
+ * bridge's scratch HOME (its $HOME is e2e/.bridge-home, which the collector reads
+ * as ~/.claude/projects and ~/.codex/sessions), and GET the usage. usage() collects
+ * on demand synchronously for a live session, so a single GET after seeding is
+ * deterministic — no polling, no hook POST needed.
+ */
+
+interface ModelUsage {
+ model: string;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+}
+
+interface SessionUsage {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ totalTokens: number;
+ models: string[];
+ perModel?: ModelUsage[];
+ harness: string;
+ collectedAt: string;
+}
+
+interface CreatedSession {
+ id: string;
+ claudeSessionId?: string;
+ codexSessionId?: string;
+ workingDir?: string;
+}
+
+/**
+ * Create a session directly on the bridge loopback API (bypassing the browser).
+ * `createMissingWorkingDir` lets us hand it a fresh scratch workingDir under the
+ * bridge HOME whose slug we control for transcript seeding.
+ */
+async function createSession(body: Record): Promise {
+ const res = await bridgeApiFetch("POST", "/api/sessions", { body });
+ expect(res.status, `create session: ${JSON.stringify(res.body)}`).toBe(201);
+ const session = (res.body as { session: CreatedSession }).session;
+ expect(session.id).toBeTruthy();
+ return session;
+}
+
+/** GET the session's usage snapshot (200 always; body.usage is SessionUsage | null). */
+async function getUsage(sessionId: string): Promise {
+ const res = await bridgeApiFetch("GET", `/api/sessions/${sessionId}/usage`);
+ expect(res.status, `usage fetch: ${JSON.stringify(res.body)}`).toBe(200);
+ return (res.body as { usage: SessionUsage | null }).usage;
+}
+
+/** Best-effort teardown — remove the live session we spawned. */
+async function removeSession(sessionId: string): Promise {
+ await bridgeApiFetch("DELETE", `/api/sessions/${sessionId}`).catch(() => undefined);
+}
+
+/** A unique scratch working directory under the bridge HOME (created on demand). */
+function scratchWorkingDir(): string {
+ return join(BRIDGE_HOME, "usage-e2e", randomUUID());
+}
+
+test.describe("per-session usage collection", () => {
+ test("claude: extracts totals, per-model breakdown, model order, and no costUsd", async () => {
+ const claudeSessionId = randomUUID();
+ const workingDir = scratchWorkingDir();
+ const modelA = "claude-sonnet-4-5";
+ const modelB = "claude-opus-4-1";
+
+ // Two assistant messages, two distinct models, distinct cache read/write.
+ // seedClaudeTranscript gives each line a UNIQUE message.id, so both count.
+ await seedClaudeTranscript(BRIDGE_HOME, claudeSessionId, workingDir, [
+ { model: modelA, input: 100, output: 20, cacheRead: 5, cacheWrite: 3 },
+ { model: modelB, input: 200, output: 40, cacheRead: 10, cacheWrite: 6 },
+ ]);
+
+ const session = await createSession({
+ shellType: "shell",
+ workingDir,
+ createMissingWorkingDir: true,
+ claudeSessionId,
+ });
+
+ try {
+ const usage = await getUsage(session.id);
+ expect(usage).not.toBeNull();
+ if (!usage) throw new Error("unreachable");
+
+ // Totals are element-wise sums over the two messages.
+ expect(usage.inputTokens).toBe(300);
+ expect(usage.outputTokens).toBe(60);
+ expect(usage.cacheReadTokens).toBe(15);
+ expect(usage.cacheWriteTokens).toBe(9);
+ // totalTokens = sum of the four buckets (300 + 60 + 15 + 9).
+ expect(usage.totalTokens).toBe(384);
+
+ // Distinct models in first-appearance order (A seeded before B).
+ expect(usage.models).toEqual([modelA, modelB]);
+
+ // Exact per-model breakdown, same order.
+ expect(usage.perModel).toEqual([
+ { model: modelA, inputTokens: 100, outputTokens: 20, cacheReadTokens: 5, cacheWriteTokens: 3 },
+ { model: modelB, inputTokens: 200, outputTokens: 40, cacheReadTokens: 10, cacheWriteTokens: 6 },
+ ]);
+
+ expect(usage.harness).toBe("claude");
+ expect(typeof usage.collectedAt).toBe("string");
+ // v2 shape carries NO cost — assert the field was dropped, not just falsy.
+ expect(usage).not.toHaveProperty("costUsd");
+ } finally {
+ await removeSession(session.id);
+ }
+ });
+
+ test("claude: dedups repeated message.id (multi-content-block lines count once)", async () => {
+ const claudeSessionId = randomUUID();
+ const workingDir = scratchWorkingDir();
+ const model = "claude-sonnet-4-5";
+
+ // A single assistant turn spans multiple jsonl lines that repeat the same
+ // message.id with identical usage — the collector must count it once. Write
+ // the transcript directly (the shared seeder only emits unique ids) at the
+ // exact path collectClaudeUsage reads: $HOME/.claude/projects//.jsonl.
+ const dir = join(BRIDGE_HOME, ".claude", "projects", claudeProjectSlug(workingDir));
+ await mkdir(dir, { recursive: true });
+ const line = (id: string, input: number, output: number, cr: number, cw: number): string =>
+ JSON.stringify({
+ type: "assistant",
+ message: {
+ id,
+ model,
+ usage: {
+ input_tokens: input,
+ output_tokens: output,
+ cache_read_input_tokens: cr,
+ cache_creation_input_tokens: cw,
+ },
+ },
+ });
+ await writeFile(
+ join(dir, `${claudeSessionId}.jsonl`),
+ [
+ line("dup-msg", 100, 20, 5, 3),
+ line("dup-msg", 100, 20, 5, 3), // duplicate id — must be ignored
+ line("solo-msg", 50, 10, 0, 0),
+ ].join("\n") + "\n",
+ "utf8",
+ );
+
+ const session = await createSession({
+ shellType: "shell",
+ workingDir,
+ createMissingWorkingDir: true,
+ claudeSessionId,
+ });
+
+ try {
+ const usage = await getUsage(session.id);
+ expect(usage).not.toBeNull();
+ if (!usage) throw new Error("unreachable");
+
+ // dup-msg counted ONCE (100/20/5/3) + solo-msg (50/10/0/0).
+ expect(usage.inputTokens).toBe(150);
+ expect(usage.outputTokens).toBe(30);
+ expect(usage.cacheReadTokens).toBe(5);
+ expect(usage.cacheWriteTokens).toBe(3);
+ expect(usage.totalTokens).toBe(188);
+ expect(usage.models).toEqual([model]);
+ expect(usage.perModel).toEqual([
+ { model, inputTokens: 150, outputTokens: 30, cacheReadTokens: 5, cacheWriteTokens: 3 },
+ ]);
+ expect(usage.harness).toBe("claude");
+ } finally {
+ await removeSession(session.id);
+ }
+ });
+
+ test("codex: decomposes cached out of input and omits perModel", async () => {
+ const codexSessionId = randomUUID();
+ const workingDir = scratchWorkingDir();
+ const model = "gpt-5-codex";
+
+ // Codex reports cumulative totals with cached counted INSIDE input.
+ await seedCodexRollout(BRIDGE_HOME, codexSessionId, {
+ model,
+ totals: { input: 11276, cached: 9088, output: 100 },
+ });
+
+ // A plain shell session carrying only a codexSessionId — the collector keys
+ // off the present native id, so it uses the codex extractor regardless of shellType.
+ const session = await createSession({
+ shellType: "shell",
+ workingDir,
+ createMissingWorkingDir: true,
+ codexSessionId,
+ });
+
+ try {
+ const usage = await getUsage(session.id);
+ expect(usage).not.toBeNull();
+ if (!usage) throw new Error("unreachable");
+
+ // cacheRead = min(cached, input) = 9088; input = 11276 - 9088 = 2188.
+ expect(usage.cacheReadTokens).toBe(9088);
+ expect(usage.inputTokens).toBe(2188);
+ expect(usage.outputTokens).toBe(100);
+ // Codex reports no cache writes.
+ expect(usage.cacheWriteTokens).toBe(0);
+ // total = 2188 + 100 + 9088 + 0 — matches codex's own total.
+ expect(usage.totalTokens).toBe(11376);
+ expect(usage.models).toEqual([model]);
+ // Codex rollouts carry no per-model attribution — perModel is absent.
+ expect(usage).not.toHaveProperty("perModel");
+ expect(usage.harness).toBe("codex");
+ } finally {
+ await removeSession(session.id);
+ }
+ });
+
+ test("no-usage harness: a plain shell session yields null, not fabricated numbers", async () => {
+ const workingDir = scratchWorkingDir();
+
+ // No native id and no transcript — the collector has no structured source.
+ const session = await createSession({
+ shellType: "shell",
+ workingDir,
+ createMissingWorkingDir: true,
+ });
+
+ try {
+ const usage = await getUsage(session.id);
+ // Explicit null (a clean "no data" signal), NOT an error and NOT zeros.
+ expect(usage).toBeNull();
+ } finally {
+ await removeSession(session.id);
+ }
+ });
+});
From edfbd74f92a0c2f249da2b42c8d57ee257842e7c Mon Sep 17 00:00:00 2001
From: Foad Kesheh
Date: Thu, 16 Jul 2026 11:37:35 -0300
Subject: [PATCH 6/8] =?UTF-8?q?ci(e2e):=20drop=20nightly=20schedule=20?=
=?UTF-8?q?=E2=80=94=20push:main=20+=20pull=5Frequest=20suffice?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The stack is hermetic (dockerized Postgres, pinned Centrifugo, Neon
shim; no external calls, no auto-updated deps) and every change lands
via PR, so main is gated on merge. Nightly added only runner-drift
coverage at the cost of daily noise.
---
.github/workflows/e2e.yml | 2 --
1 file changed, 2 deletions(-)
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 496ba89..47199b2 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -5,8 +5,6 @@ on:
push:
branches: [main]
workflow_dispatch:
- schedule:
- - cron: '0 7 * * *'
jobs:
direct-transport-e2e:
From d7512cb448514f219a25bff4f3ef4348b3cad98e Mon Sep 17 00:00:00 2001
From: Foad Kesheh
Date: Thu, 16 Jul 2026 14:35:15 -0300
Subject: [PATCH 7/8] fix(security): reject cross-tenant Centrifugo
publications (IDOR/RCE close)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The e2e IDOR suite proved allow_user_limited_channels gates subscribe
only, not publish: any authenticated user could publish create_session/
kill/bridge_exec to another user's commands:rpc#{email} — which the
bridge executed with no caller check — and inject keystrokes via
terminal-input. OSS-native fix: publications carry the publisher's
authenticated sub (ctx.info.user); the bridge's command + terminal-input
handlers and the UI's response handler now drop any publication whose
publisher != channel owner, fail-closed on missing info. 7 unit tests;
the e2e publish test now asserts A's bridge ignores B's create_session
while A's own (matching info.user) still spawns — validating info.user
is populated so the fail-closed guard never breaks owner commands.
Follow-up (lower severity, separate): terminal/sessions/loops/events
still grant needless client-publish (output/state spoofing, not exec).
---
bridge/src/centrifugo-client.test.ts | 175 +++++++++++++++++++++++++++
bridge/src/centrifugo-client.ts | 44 +++++++
e2e/tests/direct-transport.spec.ts | 2 -
e2e/tests/idor-channels.spec.ts | 83 +++++++++----
e2e/tests/idor-http.spec.ts | 22 +++-
e2e/tests/loops-and-mail.spec.ts | 3 +-
e2e/tests/session-lifecycle.spec.ts | 3 -
ui/src/hooks/useBridgeRpc.ts | 14 ++-
8 files changed, 307 insertions(+), 39 deletions(-)
create mode 100644 bridge/src/centrifugo-client.test.ts
diff --git a/bridge/src/centrifugo-client.test.ts b/bridge/src/centrifugo-client.test.ts
new file mode 100644
index 0000000..820ec03
--- /dev/null
+++ b/bridge/src/centrifugo-client.test.ts
@@ -0,0 +1,175 @@
+import { describe, it, beforeEach, afterEach } from 'node:test';
+import assert from 'node:assert/strict';
+
+import { CentrifugoClient } from './centrifugo-client.js';
+import type { Command } from './types.js';
+
+/**
+ * Publisher-identity guard tests for the two inbound client-publish handlers.
+ *
+ * Threat recap: Centrifugo gates SUBSCRIBE but not PUBLISH on user-limited
+ * channels, so any authenticated user can publish to another user's
+ * `commands:rpc#{owner}` / `terminal-input:{sid}#{owner}`. The bridge must act
+ * ONLY on publications whose authenticated publisher (`ctx.info.user`) equals the
+ * channel owner, and fail CLOSED when that identity is missing/empty.
+ */
+
+const OWNER = 'owner@example.com';
+const ATTACKER = 'attacker@evil.com';
+
+interface FakePublicationCtx {
+ data: unknown;
+ info?: { user?: string };
+}
+
+/**
+ * Build a CentrifugoClient whose underlying Centrifuge is replaced by a stub that
+ * hands back a fake subscription. The fake records the 'publication' handler the
+ * client registers so a test can invoke it with a fabricated PublicationContext.
+ */
+function makeClientWithFakeSub(): {
+ client: CentrifugoClient;
+ publish: (ctx: FakePublicationCtx) => void;
+} {
+ const client = new CentrifugoClient('ws://127.0.0.1:0/connection/websocket', 'tok', async () => 'tok');
+
+ let publicationHandler: ((ctx: FakePublicationCtx) => void) | undefined;
+ const fakeSub = {
+ on(event: string, cb: (ctx: FakePublicationCtx) => void) {
+ if (event === 'publication') publicationHandler = cb;
+ },
+ subscribe() {},
+ unsubscribe() {},
+ };
+
+ (client as unknown as { client: { newSubscription: () => unknown } }).client = {
+ newSubscription: () => fakeSub,
+ };
+
+ return {
+ client,
+ publish: (ctx: FakePublicationCtx) => {
+ if (!publicationHandler) throw new Error('publication handler was never registered');
+ publicationHandler(ctx);
+ },
+ };
+}
+
+describe('CentrifugoClient.subscribeToCommands — publisher-identity guard (fail-closed)', () => {
+ let warnCalls = 0;
+ const originalWarn = console.warn;
+
+ beforeEach(() => {
+ warnCalls = 0;
+ console.warn = () => {
+ warnCalls += 1;
+ };
+ });
+
+ afterEach(() => {
+ console.warn = originalWarn;
+ });
+
+ it('dispatches a command published by the channel owner', () => {
+ const { client, publish } = makeClientWithFakeSub();
+ const received: Command[] = [];
+ client.subscribeToCommands(OWNER, (c) => received.push(c));
+
+ publish({ data: { type: 'list_sessions', requestId: 'r1', payload: {} }, info: { user: OWNER } });
+
+ assert.strictEqual(received.length, 1);
+ assert.strictEqual(received[0].requestId, 'r1');
+ assert.strictEqual(warnCalls, 0);
+ });
+
+ it('drops a command published by another user (cross-tenant RCE close)', () => {
+ const { client, publish } = makeClientWithFakeSub();
+ const received: Command[] = [];
+ client.subscribeToCommands(OWNER, (c) => received.push(c));
+
+ publish({ data: { type: 'create_session', requestId: 'r2', payload: {} }, info: { user: ATTACKER } });
+
+ assert.strictEqual(received.length, 0);
+ assert.strictEqual(warnCalls, 1);
+ });
+
+ it('drops a command when publisher info is absent (fail-closed)', () => {
+ const { client, publish } = makeClientWithFakeSub();
+ const received: Command[] = [];
+ client.subscribeToCommands(OWNER, (c) => received.push(c));
+
+ publish({ data: { type: 'create_session', requestId: 'r3', payload: {} } });
+
+ assert.strictEqual(received.length, 0);
+ });
+
+ it('drops a command when publisher info.user is empty (fail-closed)', () => {
+ const { client, publish } = makeClientWithFakeSub();
+ const received: Command[] = [];
+ client.subscribeToCommands(OWNER, (c) => received.push(c));
+
+ publish({ data: { type: 'create_session', requestId: 'r4', payload: {} }, info: { user: '' } });
+
+ assert.strictEqual(received.length, 0);
+ });
+});
+
+describe('CentrifugoClient.subscribeToTerminalInput — publisher-identity guard (fail-closed)', () => {
+ const originalWarn = console.warn;
+
+ beforeEach(() => {
+ console.warn = () => {};
+ });
+
+ afterEach(() => {
+ console.warn = originalWarn;
+ });
+
+ it('delivers input published by the channel owner', () => {
+ const { client, publish } = makeClientWithFakeSub();
+ const inputs: Array<{ sid: string; data: string }> = [];
+ client.subscribeToTerminalInput(
+ OWNER,
+ 'sess-1',
+ (sid, data) => inputs.push({ sid, data }),
+ () => {},
+ () => {},
+ );
+
+ publish({ data: { type: 'input', data: 'ls\n' }, info: { user: OWNER } });
+
+ assert.deepStrictEqual(inputs, [{ sid: 'sess-1', data: 'ls\n' }]);
+ });
+
+ it('drops keystroke injection from another user', () => {
+ const { client, publish } = makeClientWithFakeSub();
+ const inputs: Array<{ sid: string; data: string }> = [];
+ client.subscribeToTerminalInput(
+ OWNER,
+ 'sess-1',
+ (sid, data) => inputs.push({ sid, data }),
+ () => {},
+ () => {},
+ );
+
+ publish({ data: { type: 'input', data: 'rm -rf ~\n' }, info: { user: ATTACKER } });
+
+ assert.strictEqual(inputs.length, 0);
+ });
+
+ it('drops input when publisher info is absent (fail-closed)', () => {
+ const { client, publish } = makeClientWithFakeSub();
+ const inputs: Array<{ sid: string; data: string }> = [];
+ client.subscribeToTerminalInput(
+ OWNER,
+ 'sess-1',
+ (sid, data) => inputs.push({ sid, data }),
+ () => {},
+ () => {},
+ );
+
+ publish({ data: { type: 'input', data: 'whoami\n' } });
+
+ assert.strictEqual(inputs.length, 0);
+ });
+});
diff --git a/bridge/src/centrifugo-client.ts b/bridge/src/centrifugo-client.ts
index 310d0e6..3711c04 100644
--- a/bridge/src/centrifugo-client.ts
+++ b/bridge/src/centrifugo-client.ts
@@ -22,6 +22,29 @@ type DirectCommandHandler = (msg: DirectCommandMessage) => void;
const MAX_PUBLISH_BYTES = 460_000;
+/**
+ * Fail-closed publisher-identity check for inbound client publications on a
+ * user-limited channel `ch#{owner}`.
+ *
+ * Threat: Centrifugo's `allow_user_limited_channels` gates SUBSCRIBE only, not
+ * PUBLISH; with `allow_publish_for_client:true` any authenticated user can
+ * PUBLISH to another user's `commands:rpc#{owner}` / `terminal-input:{sid}#{owner}`.
+ * The bridge executes commands and applies keystrokes from those channels, so an
+ * attacker's publish would be cross-tenant RCE / keystroke injection.
+ *
+ * Defense: Centrifugo attaches the PUBLISHER's authenticated identity to every
+ * client publication as `ctx.info.user` (ClientInfo.user). The bridge and the
+ * owner's UI both connect with token `sub == owner`, so their legitimate
+ * publishes carry `info.user == owner`. We accept ONLY those and drop everything
+ * else — a mismatched publisher, and (fail-closed) any publication whose info is
+ * missing/empty (e.g. a server-API publish with no client context, or an empty
+ * anonymous user). `owner` is always a non-empty email, so the empty-string
+ * anonymous user can never match.
+ */
+function isOwnerPublication(ctx: PublicationContext, owner: string): boolean {
+ return owner.length > 0 && ctx.info?.user === owner;
+}
+
function byteLen(str: string): number {
return Buffer.byteLength(str, 'utf8');
}
@@ -198,6 +221,15 @@ export class CentrifugoClient {
const sub = this.client.newSubscription(channel);
sub.on('publication', (ctx: PublicationContext) => {
+ // Fail-closed: only the channel owner (info.user == userId) may drive this
+ // terminal. Drop keystroke/resize/init from any other publisher — see
+ // isOwnerPublication for the cross-user-publish threat.
+ if (!isOwnerPublication(ctx, userId)) {
+ console.warn(
+ `[Centrifugo] Dropped foreign publication on ${channel} from user="${ctx.info?.user ?? ''}"`,
+ );
+ return;
+ }
const msg = ctx.data as { type: string; data?: string; cols?: number; rows?: number };
if (msg.type === 'input' && msg.data !== undefined) {
onInput(sessionId, msg.data);
@@ -226,6 +258,18 @@ export class CentrifugoClient {
const sub = this.client.newSubscription(channel);
sub.on('publication', (ctx: PublicationContext) => {
+ // Fail-closed RCE close: execute commands ONLY from the channel owner
+ // (info.user == userId). The bridge's own command_response/signal echoes
+ // and the owner UI's commands all carry info.user == userId and pass; a
+ // foreign publisher's create_session/kill/bridge_exec is dropped here.
+ // See isOwnerPublication for the cross-user-publish threat. This filters
+ // strictly on publisher identity, never on message type.
+ if (!isOwnerPublication(ctx, userId)) {
+ console.warn(
+ `[Centrifugo] Dropped foreign command publication on ${channel} from user="${ctx.info?.user ?? ''}"`,
+ );
+ return;
+ }
const data = ctx.data as Record;
if (data.type === 'command_response') {
return;
diff --git a/e2e/tests/direct-transport.spec.ts b/e2e/tests/direct-transport.spec.ts
index 9905f81..74bdcd9 100644
--- a/e2e/tests/direct-transport.spec.ts
+++ b/e2e/tests/direct-transport.spec.ts
@@ -1,7 +1,6 @@
import { test, expect, type BrowserContext, type Page } from "@playwright/test";
import {
sharedEmail,
- registerUser,
login,
waitForBridgeOnline,
createShellSession,
@@ -78,7 +77,6 @@ async function blockWebRtc(context: BrowserContext): Promise {
/** Login → create shell session → prove the terminal round-trips a marker. */
async function terminalFlow(page: Page, sessionName: string, marker: string): Promise {
const email = sharedEmail();
- await registerUser(email);
await login(page, email);
await waitForBridgeOnline(page);
diff --git a/e2e/tests/idor-channels.spec.ts b/e2e/tests/idor-channels.spec.ts
index d6096da..91b4ecf 100644
--- a/e2e/tests/idor-channels.spec.ts
+++ b/e2e/tests/idor-channels.spec.ts
@@ -1,7 +1,6 @@
import { test, expect, type BrowserContext } from "@playwright/test";
import {
sharedEmail,
- registerUser,
login,
waitForBridgeOnline,
createShellSession,
@@ -29,10 +28,13 @@ import {
* wall holds by having a fully-valid user B — its OWN, accepted Centrifugo
* token — attempt to reach into user A's namespace and be refused every time.
*
- * This is DEFENSIVE regression testing: we assert REJECTION only. We never try
- * to actually exfiltrate data or execute a command; a well-formed probe is used
- * purely so the rejection is the authorization layer's doing, not a malformed
- * frame's.
+ * This is DEFENSIVE regression testing. For the four SUBSCRIBE walls we assert
+ * REJECTION only (a well-formed probe so the rejection is the authorization
+ * layer's doing, not a malformed frame's). The command-channel PUBLISH case is
+ * different: Centrifugo intentionally ACCEPTS a cross-user client publish
+ * (allow_user_limited_channels gates subscribe, not publish), so the wall there
+ * is DEFENSE-IN-DEPTH AT THE BRIDGE — that test asserts A's bridge never ACTS on
+ * B's published command, not that Centrifugo rejects it.
*
* "Walled" outcome model (see helpers/centrifugo-raw.ts docstring):
* - resolve { ok:false, error.code === 103 } → per-channel authz denial. WALLED.
@@ -97,7 +99,6 @@ test.describe("IDOR: cross-tenant channel isolation", () => {
contextA = await browser.newContext();
const pageA = await contextA.newPage();
const emailA = sharedEmail();
- await registerUser(emailA);
await login(pageA, emailA);
await waitForBridgeOnline(pageA);
@@ -141,29 +142,63 @@ test.describe("IDOR: cross-tenant channel isolation", () => {
await expectWalled(channel, () => attemptSubscribe(tokenB, channel));
});
- test("B cannot PUBLISH to A's command/RPC channel", async () => {
- // The load-bearing wall: the bridge executes any command arriving on
- // commands:rpc# with no per-caller payload check, so publish-authz is
- // the ONLY thing stopping B from driving A's bridge. A well-formed-ish
- // envelope is used so the rejection is authorization's doing, not a bad frame.
+ test("B cannot DRIVE A's bridge by publishing a command (bridge ignores foreign publisher)", async () => {
+ // The command channel is the ONE place Centrifugo does NOT wall B out:
+ // allow_user_limited_channels gates SUBSCRIBE only, and allow_publish_for_client
+ // lets any authenticated client PUBLISH to another user's channel — so B's
+ // publish to commands:rpc# is ACCEPTED (may resolve ok:true). The real wall
+ // is now DEFENSE-IN-DEPTH AT THE BRIDGE, not at Centrifugo: the bridge drops
+ // any publication whose authenticated publisher (ctx.info.user) != the channel
+ // owner. So we assert the behavioral truth — B's create_session never causes
+ // A's bridge to actually spawn a session.
const channel = `commands:rpc#${tenantA.email}`;
const bridgeClientsBefore = await commandsRpcClients(tenantA.email);
+ const before = await sessionIdsFor(tenantA.email);
+
+ // B publishes a REAL create_session to A's command channel. Centrifugo accepts
+ // the publish (that acceptance is no longer the security boundary); the bridge
+ // must ignore it because ctx.info.user == B != A.
+ await attemptPublish(tokenB, channel, {
+ type: "create_session",
+ payload: { command: "/bin/zsh -l", shellType: "shell", name: `idor-B-inject-${Date.now()}` },
+ requestId: `idor-B-${Date.now()}`,
+ });
+
+ // Give A's bridge a beat to (not) act, then assert A's live-session inventory
+ // is unchanged — the bridge dropped B's foreign publication, so no session was
+ // spawned. sessionIdsFor is a transport-independent inventory (one
+ // terminal-input:# channel per live session the bridge serves).
+ await new Promise((r) => setTimeout(r, 3000));
+ const afterInject = await sessionIdsFor(tenantA.email);
+ expect(
+ [...afterInject].filter((id) => !before.has(id)),
+ `SECURITY: B's create_session published to ${channel} caused A's bridge to spawn a ` +
+ `session — the bridge acted on a FOREIGN publisher. Cross-tenant RCE via command publish.`,
+ ).toEqual([]);
- await expectWalled(channel, () =>
- attemptPublish(tokenB, channel, { type: "list_sessions", requestId: "idor-probe" }),
- );
-
- // Defense in depth: because Centrifugo rejected the publish, the envelope
- // never reached A's bridge, so it cannot have acted. The observable proxy is
- // that A's bridge remains the sole, undisturbed subscriber on its command
- // channel — B's rejected publish neither joined it nor knocked the bridge off.
+ // The bridge must remain the sole, undisturbed subscriber — B's publish must
+ // not have joined the channel or knocked the bridge off.
const bridgeClientsAfter = await commandsRpcClients(tenantA.email);
- expect(
- bridgeClientsAfter,
- `A's bridge subscriber count on ${channel} changed after B's rejected publish ` +
- `(${bridgeClientsBefore} → ${bridgeClientsAfter}) — the bridge may have reacted`,
- ).toBe(bridgeClientsBefore);
expect(bridgeClientsAfter, `A's bridge should still be listening on ${channel}`).toBeGreaterThanOrEqual(1);
+ expect(bridgeClientsBefore).toBeGreaterThanOrEqual(1);
+
+ // Positive control + fail-CLOSED validation: A's OWN publish to the SAME
+ // channel — carrying info.user == A — MUST still drive the bridge. This proves
+ // (a) the guard is not a globally-broken command path, and (b) Centrifugo
+ // actually populates ctx.info.user for client publishes, so the owner's own
+ // commands are not silently broken by the fail-closed guard.
+ const ownResult = await attemptPublish(tenantA.token, channel, {
+ type: "create_session",
+ payload: { command: "/bin/zsh -l", shellType: "shell", name: `idor-A-control-${Date.now()}` },
+ requestId: `idor-A-${Date.now()}`,
+ });
+ expect(
+ ownResult.ok,
+ `A's own publish to ${channel} was rejected by Centrifugo (${JSON.stringify(ownResult.error)}) — ` +
+ `the positive control cannot run`,
+ ).toBe(true);
+ const newId = await waitForNewSessionId(tenantA.email, before);
+ expect(newId, "A's own create_session (info.user == A) should have spawned a new session").toBeTruthy();
});
test("positive control: B CAN subscribe to B's OWN command/RPC channel", async () => {
diff --git a/e2e/tests/idor-http.spec.ts b/e2e/tests/idor-http.spec.ts
index 216bdb5..ff5daa1 100644
--- a/e2e/tests/idor-http.spec.ts
+++ b/e2e/tests/idor-http.spec.ts
@@ -1,5 +1,5 @@
import { test, expect, type APIRequestContext } from "@playwright/test";
-import { makeUser, registerAndLogin, sharedEmail, E2E_PASSWORD } from "../helpers/app";
+import { login, registerUser, makeUser, sharedEmail, E2E_PASSWORD, type UserCreds } from "../helpers/app";
import { bridgeApiFetch, readBridgePointer } from "../helpers/bridge-api";
import { UI_BASE_URL } from "../helpers/config";
@@ -72,11 +72,23 @@ test.describe("GROUP 1 — bridge loopback API guards", () => {
});
test.describe("GROUP 2 — UI API route authZ / IDOR", () => {
+ let sharedB: UserCreds;
+
+ test.beforeAll(async () => {
+ // One isolated "B" user shared by every GROUP 2 test that needs a fresh,
+ // non-A identity — registration is rate-limited per CI IP, so we mint B
+ // once here instead of once per test. Sharing is safe: B never pairs a
+ // bridge or owns any resource in any of these tests.
+ sharedB = makeUser("e2e-idor-b");
+ await registerUser(sharedB.email, sharedB.password);
+ });
+
test("Centrifugo connect-token sub is the session email — body input is ignored", async ({ browser }) => {
const context = await browser.newContext();
try {
const page = await context.newPage();
- const b = await registerAndLogin(page, makeUser("e2e-idor-b"));
+ await login(page, sharedB.email, sharedB.password);
+ const b = sharedB;
// Attempt to spoof the subject via the request body. The route (POST() with
// no request param) signs session.user.email and ignores input entirely.
@@ -120,7 +132,7 @@ test.describe("GROUP 2 — UI API route authZ / IDOR", () => {
try {
// User A = the harness user that owns the running bridge.
const aPage = await aCtx.newPage();
- await registerAndLogin(aPage, { email: sharedEmail(), password: E2E_PASSWORD });
+ await login(aPage, sharedEmail(), E2E_PASSWORD);
// Precondition: A owns pointer.bridgeId and it is not revoked.
const before = await getDevices(aPage.request);
@@ -131,7 +143,7 @@ test.describe("GROUP 2 — UI API route authZ / IDOR", () => {
// User B = fresh, isolated account.
const bPage = await bCtx.newPage();
- await registerAndLogin(bPage, makeUser("e2e-idor-b"));
+ await login(bPage, sharedB.email, sharedB.password);
// B attempts to revoke A's bridge. revokeDevice is scoped by sub, so the
// UPDATE matches 0 rows for B ⇒ the route returns 404 {error:"Device not found"}.
@@ -164,7 +176,7 @@ test.describe("GROUP 2 — UI API route authZ / IDOR", () => {
const bCtx = await browser.newContext();
try {
const bPage = await bCtx.newPage();
- await registerAndLogin(bPage, makeUser("e2e-idor-b"));
+ await login(bPage, sharedB.email, sharedB.password);
const { status, body } = await getDevices(bPage.request);
expect(status, "B's authenticated device list must succeed").toBe(200);
diff --git a/e2e/tests/loops-and-mail.spec.ts b/e2e/tests/loops-and-mail.spec.ts
index 621fdc3..a0cd8ee 100644
--- a/e2e/tests/loops-and-mail.spec.ts
+++ b/e2e/tests/loops-and-mail.spec.ts
@@ -1,5 +1,5 @@
import { test, expect, type Page } from "@playwright/test";
-import { sharedEmail, registerUser, login, waitForBridgeOnline } from "../helpers/app";
+import { sharedEmail, login, waitForBridgeOnline } from "../helpers/app";
import { createLoopViaUi } from "../helpers/loops";
import { bridgeApiFetch } from "../helpers/bridge-api";
@@ -109,7 +109,6 @@ function sleep(ms: number): Promise {
async function loginAndBridge(page: Page): Promise {
const email = sharedEmail();
- await registerUser(email);
await login(page, email);
// Bridge presence enables both the "Create a new session" and "Create a new
// loop" buttons; this waits on the former as the authoritative online signal.
diff --git a/e2e/tests/session-lifecycle.spec.ts b/e2e/tests/session-lifecycle.spec.ts
index 603260b..0f62e8b 100644
--- a/e2e/tests/session-lifecycle.spec.ts
+++ b/e2e/tests/session-lifecycle.spec.ts
@@ -1,7 +1,6 @@
import { test, expect } from "@playwright/test";
import {
sharedEmail,
- registerUser,
login,
waitForBridgeOnline,
createShellSession,
@@ -47,7 +46,6 @@ async function statusOf(sid: string): Promise {
test.describe("session lifecycle", () => {
test("create → interact → stop → remove (controller CRUD round-trip)", async ({ page }) => {
const email = sharedEmail();
- await registerUser(email);
await login(page, email);
await waitForBridgeOnline(page);
@@ -103,7 +101,6 @@ test.describe("session lifecycle", () => {
test.setTimeout(180_000);
const email = sharedEmail();
- await registerUser(email);
await login(page, email);
await waitForBridgeOnline(page);
diff --git a/ui/src/hooks/useBridgeRpc.ts b/ui/src/hooks/useBridgeRpc.ts
index 84630d4..6763b7d 100644
--- a/ui/src/hooks/useBridgeRpc.ts
+++ b/ui/src/hooks/useBridgeRpc.ts
@@ -32,9 +32,9 @@ export interface BridgeRpcSubscriptionLike {
state: string;
subscribe(): void;
publish(data: unknown): Promise;
- on(event: "publication", listener: (ctx: { data: unknown }) => void): unknown;
+ on(event: "publication", listener: (ctx: { data: unknown; info?: { user?: string } }) => void): unknown;
on(event: "subscribed", listener: () => void): unknown;
- off(event: "publication", listener: (ctx: { data: unknown }) => void): unknown;
+ off(event: "publication", listener: (ctx: { data: unknown; info?: { user?: string } }) => void): unknown;
off(event: "subscribed", listener: () => void): unknown;
}
@@ -211,7 +211,15 @@ export function useBridgeRpc(client: CentrifugoClientLike | null, userId: string
// or useCentrifugo's inbound-signal listener would be silently stripped
// (e.g. under StrictMode double-mount, where this cleanup runs while
// useCentrifugo's listener must stay alive).
- const onCommandsPublication = (ctx: { data: unknown }) => {
+ const onCommandsPublication = (ctx: { data: unknown; info?: { user?: string } }) => {
+ // Fail-closed: Centrifugo gates SUBSCRIBE but not PUBLISH on user-limited
+ // channels, so a foreign authenticated user could publish a spoofed
+ // command_response to resolve/pollute our pending RPCs. Accept only
+ // publications from the channel owner (the bridge connects with sub ==
+ // userId, so its responses carry info.user == userId). Missing/mismatched
+ // publisher identity is dropped.
+ if (ctx.info?.user !== userId) return;
+
const data = ctx.data as CommandResponseMessage;
if (data.type === "command_response" && data.response) {
From 42743eac4667e4b45d4b6a962a9351c7671b2fa6 Mon Sep 17 00:00:00 2001
From: Foad Kesheh
Date: Thu, 16 Jul 2026 15:03:44 -0300
Subject: [PATCH 8/8] test(e2e): harden restartBridge + loop-disable assertion
(verified live)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
restartBridge now gates on the NEW bridge's pointer (fresh pid) before
returning — the old bridge unlinks bridge.json on SIGTERM and its
Centrifugo presence lingers, so the helper was resolving on stale
presence before the new pointer existed. Loop-disable test asserts
run-count stability across a full 30s scheduler tick instead of run-record
status (records stay 'running' until a 30s finalize grace). Both run
green twice against the local stack.
---
e2e/helpers/bridge-process.ts | 41 +++++++++++++++++++++++++++-
e2e/tests/loops-and-mail.spec.ts | 47 +++++++++++++++++++-------------
2 files changed, 68 insertions(+), 20 deletions(-)
diff --git a/e2e/helpers/bridge-process.ts b/e2e/helpers/bridge-process.ts
index 5aed618..4b0b0d3 100644
--- a/e2e/helpers/bridge-process.ts
+++ b/e2e/helpers/bridge-process.ts
@@ -6,6 +6,7 @@ import { E2E_DIR, BRIDGE_HOME, UI_BASE_URL } from "./config";
import { mintBridgeBootstrapToken } from "./jwt";
import { sharedEmail } from "./app";
import { waitForBridgePresence } from "./centrifugo";
+import { readBridgePointer } from "./bridge-api";
/**
* Stop and restart the e2e bridge for resurrection tests. start-services.sh
@@ -51,6 +52,38 @@ async function waitForExit(pid: number, timeoutMs: number): Promise {
}
}
+/**
+ * Poll until $bridgeHome/.ftown/bridge.json exists, parses, AND advertises the
+ * NEW bridge's pid — i.e. the freshly-spawned process has finished startup and
+ * (re)written its own pointer. This is the readiness gate that presence alone
+ * cannot give us: on SIGTERM the old bridge unlinks bridge.json (index.ts
+ * cleanupPointer), while its Centrifugo presence entry lingers for the presence
+ * TTL. So waitForBridgePresence can return on the STALE old entry seconds before
+ * the new bridge has rewritten the pointer, leaving readBridgePointer to throw
+ * "bridge pointer not found". Gating on pid === expectedPid guarantees we read
+ * the new bridge's advert (fresh port + bearer), never a leftover or half-write.
+ */
+async function waitForBridgePointer(
+ bridgeHome: string,
+ expectedPid: number,
+ timeoutMs: number,
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ for (;;) {
+ try {
+ if (readBridgePointer(bridgeHome).pid === expectedPid) return;
+ } catch {
+ // absent or mid-write — keep polling until the new bridge lands its pointer
+ }
+ if (Date.now() >= deadline) {
+ throw new Error(
+ `new bridge (pid ${expectedPid}) never wrote a readable bridge.json under ${bridgeHome} within ${timeoutMs}ms`,
+ );
+ }
+ await new Promise((r) => setTimeout(r, 250));
+ }
+}
+
/** Stop the recorded bridge, start a fresh one on the same HOME, return new PID. */
export async function restartBridge(options: RestartBridgeOptions = {}): Promise {
const e2eDir = options.e2eDir ?? E2E_DIR;
@@ -98,7 +131,13 @@ export async function restartBridge(options: RestartBridgeOptions = {}): Promise
}
writeFileSync(pidPath, `${child.pid}\n`);
- // --- wait until the new bridge is online again ---
+ // --- wait until the new bridge is fully ready ---
+ // First block on the NEW bridge's own pointer (pid-matched): this is what the
+ // loopback API (bridgeApiFetch → readBridgePointer) depends on, and it survives
+ // the stale-presence race described on waitForBridgePointer. Then confirm it is
+ // actually online for the UI/resurrection path. bridge.json is written before
+ // Centrifugo join (index.ts), so ordering pointer→presence needs no extra wait.
+ await waitForBridgePointer(bridgeHome, child.pid, 30_000);
await waitForBridgePresence(email, { min: 1, timeoutMs: options.presenceTimeoutMs ?? 40_000 });
return child.pid;
}
diff --git a/e2e/tests/loops-and-mail.spec.ts b/e2e/tests/loops-and-mail.spec.ts
index a0cd8ee..f4a4403 100644
--- a/e2e/tests/loops-and-mail.spec.ts
+++ b/e2e/tests/loops-and-mail.spec.ts
@@ -98,11 +98,6 @@ async function deleteLoopQuietly(id: string | undefined): Promise {
}
}
-/** No run for the loop is currently in the 'running' state (shell runs finish fast). */
-async function noRunActive(id: string): Promise {
- return (await runsOf(id)).every((r) => r.status !== "running");
-}
-
function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -119,8 +114,10 @@ async function loginAndBridge(page: Page): Promise {
test.describe("loop scheduler / controller", () => {
test("create -> runs accumulate -> run-now -> disable -> delete", async ({ page }) => {
- // The natural first fire can take up to one 30s tick; give the whole flow room.
- test.setTimeout(120_000);
+ // The natural first fire can take up to one 30s tick, and step (3) waits out a
+ // further full tick (>30s) to prove disable stops future fires; give the whole
+ // flow room above the 90s default.
+ test.setTimeout(150_000);
const name = `e2e-loop-${Date.now()}`;
const marker = `E2ELOOP${Date.now()}`;
@@ -153,14 +150,21 @@ test.describe("loop scheduler / controller", () => {
})
.toBeGreaterThanOrEqual(1);
- // (2) run-now adds an extra run. Wait out any in-flight run first so the
- // skip-overlap guard can't turn our manual fire into a no-op.
- await expect.poll(() => noRunActive(id), { timeout: 20_000 }).toBe(true);
+ // (2) run-now adds an extra run. The skip-overlap guard keys on the actual
+ // process being ALIVE (runner.isRunning), NOT on the run-record status —
+ // which lingers 'running' for a full ~30s scheduler grace after the shell
+ // process already exited (loop-scheduler finalize grace). A shell `echo`
+ // exits in milliseconds, so run-now fires; poll until fired:true to stay
+ // robust to the rare case where a natural tick's run is momentarily live.
const before = (await runsOf(id)).length;
- const runNow = await bridgeApiFetch("POST", `/api/loops/${id}/run-now`);
- expect(runNow.status, "run-now must succeed").toBe(200);
- expect((runNow.body as { fired: boolean }).fired, "run-now must fire").toBe(true);
+ await expect
+ .poll(async () => {
+ const runNow = await bridgeApiFetch("POST", `/api/loops/${id}/run-now`);
+ expect(runNow.status, "run-now must succeed").toBe(200);
+ return (runNow.body as { fired: boolean }).fired;
+ }, { timeout: 20_000, message: "run-now never fired (kept skipping on overlap)" })
+ .toBe(true);
await expect
.poll(async () => (await runsOf(id)).length, {
@@ -169,21 +173,26 @@ test.describe("loop scheduler / controller", () => {
})
.toBeGreaterThan(before);
- // (3) disable -> no NEW runs accrue. Let anything in flight settle, snapshot,
- // wait, and assert the count held (a disabled loop is skipped on every tick,
- // and the next natural tick is 30s away regardless).
+ // (3) disable -> no NEW runs accrue. Disabling does not kill an in-flight run
+ // (it only makes isDue() false on every future tick), and a finalizing run
+ // updates its status WITHOUT adding a run node — so run COUNT, not run
+ // status, is the honest signal here. Snapshot the count just after disabling,
+ // then wait out a FULL scheduler tick (>30s, LOOP_TICK_INTERVAL_MS) — long
+ // enough that an *enabled* interval loop would have fired at least once more —
+ // and assert the count never moved. That is the proof disable stops future
+ // fires, without depending on the 30s run-record finalize grace.
const disable = await bridgeApiFetch("PATCH", `/api/loops/${id}`, {
body: { enabled: false },
});
expect(disable.status, "disable PATCH must succeed").toBe(200);
expect((disable.body as { loop: LoopLite }).loop.enabled).toBe(false);
- await expect.poll(() => noRunActive(id), { timeout: 20_000 }).toBe(true);
+ await sleep(2_000); // let any tick racing the disable settle before snapshotting
const settled = (await runsOf(id)).length;
- await sleep(6_000);
+ await sleep(35_000); // > one full 30s tick: an enabled loop WOULD fire in this window
expect(
(await runsOf(id)).length,
- "a disabled loop must not accrue new runs",
+ "a disabled loop must not accrue new runs across a full scheduler tick",
).toBe(settled);
// (4) delete -> gone from the authoritative loop list.