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.