mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-27 09:22:56 +00:00
Three follow-ups from an adversarial pass over this branch. The title cap was sized against the wrong number. 80 code points was chosen "for both the max-w-40 legend and the tooltip", but max-w-40 is 160px and the legend renders at text-[10px], which shows roughly 32 characters. Everything past that is clipped -- and that is exactly where the short session id, the provider and every collision-tier suffix lived. Two sessions in one repository whose AI titles share a 32-character prefix rendered as the same legend entry, which is worse than main and is the scenario #997 is about. The label now leads with the disambiguator so it is always inside the visible width, and both the legend and the tooltip carry title= so the full label is reachable on hover. injectDashboardBootstrap was not safe by construction. Extracting the helper fixed the $-substitution problem but left the security-critical '<' escaping at the call site 94 lines away, and the new test called the helper with raw JSON.stringify output -- so deleting that escape left every test green while the served page became injectable through any project, device or model name. Nothing in tests/ asserted that escaping at all. The escaping moves inside the helper, with a test that pushes </script> through a payload value. preferredSessionTitle picked alphabetically, not most recently. types.ts documents title as the last ai-title entry, so when one session id yields two summaries the legend could show the superseded one. It now picks the greatest lastTimestamp, keeping the alphabetical order only to break exact ties so the result stays deterministic. Entries are also ordered by key before the collision tiers run, so the same corpus cannot emit a different label set depending on input order.
446 lines
20 KiB
TypeScript
446 lines
20 KiB
TypeScript
import { createServer, type Server } from 'http'
|
|
import { exec } from 'child_process'
|
|
import { readFile } from 'fs/promises'
|
|
import { existsSync } from 'fs'
|
|
import { join, normalize, extname, dirname, sep } from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
import { AddressInfo } from 'net'
|
|
|
|
import { hostname } from 'os'
|
|
|
|
import { loadPricing } from './models.js'
|
|
import { buildMenubarPayloadForRange } from './usage-aggregator.js'
|
|
import type { MenubarPayload } from './menubar-json.js'
|
|
import { periodInfoFromQuery, UsageQueryError } from './cli-date.js'
|
|
import { pullDevices, linkRemote } from './sharing/host.js'
|
|
import { browse } from './sharing/discovery.js'
|
|
import { loadOrCreateIdentity } from './sharing/identity.js'
|
|
import { pairingCode } from './sharing/pairing.js'
|
|
import { getSharingDir, loadRemotes, loadShareAlways, saveShareAlways } from './sharing/store.js'
|
|
import { ShareController } from './sharing/share-controller.js'
|
|
import { sanitizeForSharing } from './sharing/sanitize.js'
|
|
import { buildContextTree, findClaudeSession, listRecentTitledSessions, snapshotRows, type ContextTreeResult, type SessionRef } from './context-tree.js'
|
|
import { buildCodexContextTree, findCodexSession, listRecentCodexSessions } from './context-tree-codex.js'
|
|
|
|
function readBody(req: import('http').IncomingMessage): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
let body = ''
|
|
req.on('data', (c) => {
|
|
body += c
|
|
if (body.length > 1_000_000) reject(new Error('request body too large'))
|
|
})
|
|
req.on('end', () => resolve(body))
|
|
req.on('error', reject)
|
|
})
|
|
}
|
|
|
|
function writeJsonError(res: import('http').ServerResponse, status: number, error: string): void {
|
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
res.end(JSON.stringify({ error }))
|
|
}
|
|
|
|
// Cap on the cached local payload, matched to the parser's own session cache
|
|
// (parser.ts) so the assembled payload is never staler than its source data.
|
|
const LOCAL_PAYLOAD_TTL_MS = 180_000
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
|
|
// Locate the built React dashboard (dist/dash). Works both when running from a
|
|
// published package (dist/dash next to the bundled CLI) and from source.
|
|
function resolveDashDir(): string | null {
|
|
const candidates = [
|
|
process.env['CODEBURN_DASH_DIR'],
|
|
join(HERE, 'dash'),
|
|
join(HERE, '..', 'dist', 'dash'),
|
|
join(HERE, '..', 'dash', 'dist'),
|
|
].filter(Boolean) as string[]
|
|
for (const dir of candidates) {
|
|
if (existsSync(join(dir, 'index.html'))) return dir
|
|
}
|
|
return null
|
|
}
|
|
|
|
const CONTENT_TYPES: Record<string, string> = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.woff2': 'font/woff2',
|
|
'.woff': 'font/woff',
|
|
'.png': 'image/png',
|
|
'.ico': 'image/x-icon',
|
|
'.map': 'application/json',
|
|
}
|
|
|
|
const NOT_BUILT_PAGE =
|
|
'<!doctype html><meta charset="utf-8">' +
|
|
'<body style="font-family:system-ui;background:#0a0a0b;color:#e7e7ea;padding:48px;line-height:1.6">' +
|
|
'<h2>Dashboard not built yet</h2>' +
|
|
'<p>Build the web UI once, then reload:</p>' +
|
|
'<pre style="background:#141417;padding:12px 16px;border-radius:8px;color:#ff8c42">cd dash && npm install && npm run build</pre>' +
|
|
'<p>The CLI keeps serving the live data API in the meantime.</p></body>'
|
|
|
|
function openBrowser(url: string): void {
|
|
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start ""' : 'xdg-open'
|
|
try {
|
|
exec(`${cmd} ${url}`)
|
|
} catch {
|
|
/* user can open it manually */
|
|
}
|
|
}
|
|
|
|
export function injectDashboardBootstrap(html: string, payload: unknown): string {
|
|
const json = JSON.stringify(payload)
|
|
if (json === undefined) throw new TypeError('dashboard bootstrap payload is not serializable')
|
|
// Keep the JSON safe at the boundary where it enters an HTML script. This is
|
|
// deliberately inside the helper so callers cannot forget the escape.
|
|
const safeJson = json.replace(/</g, String.fromCharCode(92) + 'u003c')
|
|
return html.replace('<script type="module"', () => `<script>window.__CODEBURN_BOOTSTRAP__=${safeJson}</script>\n <script type="module"`)
|
|
}
|
|
|
|
export async function runWebDashboard(opts: {
|
|
period: string
|
|
provider: string
|
|
from?: string
|
|
to?: string
|
|
project: string[]
|
|
exclude: string[]
|
|
port: number
|
|
open: boolean
|
|
}): Promise<Server> {
|
|
await loadPricing()
|
|
const dashDir = resolveDashDir()
|
|
|
|
// Sharing this device serves the SANITIZED aggregate (no project names/paths
|
|
// or per-session detail), unlike the local /api/usage which shows everything.
|
|
const shareGetUsage = async (q: { period?: string; from?: string; to?: string }) => {
|
|
const periodInfo = periodInfoFromQuery(q, opts.period)
|
|
return sanitizeForSharing(await buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false }))
|
|
}
|
|
const share = new ShareController(shareGetUsage)
|
|
if (await loadShareAlways()) await share.start(true).catch(() => {})
|
|
|
|
// The server is long-lived, so cache this machine's parsed payload (the CLI
|
|
// process cannot). Store the promise before any await so concurrent identical
|
|
// requests collapse into one parse instead of racing.
|
|
const localPayloadCache = new Map<string, { at: number; payload: Promise<MenubarPayload> }>()
|
|
const rebuildPayload = (key: string, period: string, provider: string, from?: string, to?: string): Promise<MenubarPayload> => {
|
|
const periodInfo = periodInfoFromQuery({ period, from, to }, opts.period)
|
|
const payload = buildMenubarPayloadForRange(periodInfo, { provider, project: opts.project, exclude: opts.exclude, optimize: false })
|
|
const now = Date.now()
|
|
localPayloadCache.set(key, { at: now, payload })
|
|
for (const [k, v] of localPayloadCache) if (now - v.at >= LOCAL_PAYLOAD_TTL_MS) localPayloadCache.delete(k)
|
|
void payload.catch(() => localPayloadCache.delete(key))
|
|
return payload
|
|
}
|
|
const getLocalPayload = (period: string, provider: string, from?: string, to?: string): Promise<MenubarPayload> => {
|
|
const key = `${period}|${provider}|${from ?? ''}|${to ?? ''}`
|
|
const hit = localPayloadCache.get(key)
|
|
if (hit && Date.now() - hit.at < LOCAL_PAYLOAD_TTL_MS) {
|
|
// Stale-while-revalidate: past 75% of the TTL, hand back the cached
|
|
// payload instantly and rebuild behind it, so the TTL expiry never
|
|
// lands its multi-second parse on a user's click.
|
|
if (Date.now() - hit.at > LOCAL_PAYLOAD_TTL_MS * 0.75) void rebuildPayload(key, period, provider, from, to).catch(() => {})
|
|
return hit.payload
|
|
}
|
|
return rebuildPayload(key, period, provider, from, to)
|
|
}
|
|
|
|
// Warm every period tab shortly after startup, sequentially, so the first
|
|
// click on 7d/30d/Month answers from the payload cache instead of paying a
|
|
// full parse. Lifetime is deliberately last-and-optional: it is the rarest
|
|
// tab and the costliest parse. Failures are ignored - a prefetch is never
|
|
// load-bearing.
|
|
const prefetchPeriods = async (): Promise<void> => {
|
|
for (const period of ['today', 'week', '30days', 'month', 'all', 'lifetime']) {
|
|
try { await getLocalPayload(period, opts.provider, opts.from, opts.to) } catch { /* not load-bearing */ }
|
|
}
|
|
}
|
|
setTimeout(() => { void prefetchPeriods() }, 500)
|
|
|
|
// Context trees re-read a whole transcript (up to 100MB), so cache each by
|
|
// file version. Keyed on mtime: an active session invalidates itself.
|
|
const contextTreeCache = new Map<string, Promise<ContextTreeResult>>()
|
|
const getContextTree = (provider: 'claude' | 'codex', ref: SessionRef): Promise<ContextTreeResult> => {
|
|
const key = `${provider}:${ref.sessionId}:${ref.mtimeMs}`
|
|
const hit = contextTreeCache.get(key)
|
|
if (hit) {
|
|
// Re-insert so eviction is LRU rather than insertion order.
|
|
contextTreeCache.delete(key)
|
|
contextTreeCache.set(key, hit)
|
|
return hit
|
|
}
|
|
const tree = provider === 'claude' ? buildContextTree(ref) : buildCodexContextTree(ref)
|
|
contextTreeCache.set(key, tree)
|
|
void tree.catch(() => contextTreeCache.delete(key))
|
|
while (contextTreeCache.size > 12) {
|
|
const oldest = contextTreeCache.keys().next().value
|
|
if (oldest === undefined) break
|
|
contextTreeCache.delete(oldest)
|
|
}
|
|
return tree
|
|
}
|
|
|
|
// Embed this machine's prewarmed payload in index.html for an instant first
|
|
// paint with no data round-trip. Only the local device is inlined: no remote
|
|
// network wait, and paired devices stream in via the live fetch right after.
|
|
const serveIndexHtml = async (res: import('http').ServerResponse, filePath: string): Promise<void> => {
|
|
const html = await readFile(filePath, 'utf8')
|
|
const payload = await getLocalPayload(opts.period, opts.provider, opts.from, opts.to)
|
|
const devices = [{ id: 'local', name: hostname(), local: true, payload }]
|
|
const injected = injectDashboardBootstrap(html, { devices })
|
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
res.end(injected)
|
|
}
|
|
|
|
const server = createServer(async (req, res) => {
|
|
try {
|
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
|
|
// Loopback-only server. Reject any request not addressed to localhost
|
|
// (defeats DNS rebinding, which would otherwise let a website you visit
|
|
// read your local usage) and any cross-origin request (CSRF). The local
|
|
// payload is unsanitized, so this guard is what keeps it on your machine.
|
|
const reqHost = (req.headers.host ?? '').replace(/:\d+$/, '')
|
|
const loopback = reqHost === '127.0.0.1' || reqHost === 'localhost' || reqHost === '::1' || reqHost === '[::1]'
|
|
const origin = req.headers.origin
|
|
const originOk = !origin || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(origin)
|
|
if (!loopback || !originOk) {
|
|
res.writeHead(403, { 'content-type': 'text/plain' })
|
|
res.end('Forbidden')
|
|
return
|
|
}
|
|
|
|
if (url.pathname === '/api/usage') {
|
|
const period = url.searchParams.get('period') ?? opts.period
|
|
const provider = url.searchParams.get('provider') ?? opts.provider
|
|
const from = url.searchParams.get('from') ?? opts.from
|
|
const to = url.searchParams.get('to') ?? opts.to
|
|
let payload
|
|
try {
|
|
payload = await getLocalPayload(period, provider, from, to)
|
|
} catch (err) {
|
|
if (!(err instanceof UsageQueryError)) throw err
|
|
writeJsonError(res, 400, err.message)
|
|
return
|
|
}
|
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
res.end(JSON.stringify(payload))
|
|
return
|
|
}
|
|
|
|
// This machine plus every paired device, each kept separate. Remote
|
|
// payloads arrive already sanitized (aggregate numbers only).
|
|
if (url.pathname === '/api/devices') {
|
|
const period = url.searchParams.get('period') ?? opts.period
|
|
const provider = url.searchParams.get('provider') ?? opts.provider
|
|
const from = url.searchParams.get('from') ?? opts.from
|
|
const to = url.searchParams.get('to') ?? opts.to
|
|
let results
|
|
try {
|
|
results = await pullDevices(() => getLocalPayload(period, provider, from, to), { period, from, to }, hostname(), {})
|
|
} catch (err) {
|
|
if (!(err instanceof UsageQueryError)) throw err
|
|
writeJsonError(res, 400, err.message)
|
|
return
|
|
}
|
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
res.end(JSON.stringify({ devices: results }))
|
|
return
|
|
}
|
|
|
|
// This device's own identity (name + fingerprint) for the pairing UI.
|
|
if (url.pathname === '/api/identity') {
|
|
const id = await loadOrCreateIdentity(getSharingDir())
|
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
res.end(JSON.stringify({ name: id.name, fingerprint: id.fingerprint }))
|
|
return
|
|
}
|
|
|
|
// Discover devices currently sharing on the local network (mDNS). Each
|
|
// carries the confirm code to match, and whether it is already paired.
|
|
if (url.pathname === '/api/devices/scan') {
|
|
const dir = getSharingDir()
|
|
const id = await loadOrCreateIdentity(dir)
|
|
const pairedFps = new Set((await loadRemotes(dir)).map((r) => r.fingerprint))
|
|
const found = await browse(2500)
|
|
const list = found
|
|
.filter((d) => d.fingerprint !== id.fingerprint)
|
|
.map((d) => ({
|
|
name: d.name,
|
|
host: d.host,
|
|
port: d.port,
|
|
fingerprint: d.fingerprint,
|
|
code: pairingCode(id.fingerprint, d.fingerprint),
|
|
paired: pairedFps.has(d.fingerprint),
|
|
}))
|
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
res.end(JSON.stringify({ found: list }))
|
|
return
|
|
}
|
|
|
|
// Pair with a chosen discovered device. Blocks until the other device
|
|
// approves (or declines / times out), then stores the link.
|
|
if (url.pathname === '/api/devices/pair' && req.method === 'POST') {
|
|
if (!(req.headers['content-type'] ?? '').includes('application/json')) {
|
|
res.writeHead(415, { 'content-type': 'application/json' })
|
|
res.end(JSON.stringify({ ok: false, error: 'content-type must be application/json' }))
|
|
return
|
|
}
|
|
const body = JSON.parse((await readBody(req)) || '{}') as { name: string; host: string; port: number; fingerprint: string }
|
|
try {
|
|
const device = await linkRemote(body)
|
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
res.end(JSON.stringify({ ok: true, name: device.name }))
|
|
} catch (err) {
|
|
res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' })
|
|
res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }))
|
|
}
|
|
return
|
|
}
|
|
|
|
// Share-this-device controls. Status carries the pending pairing requests
|
|
// so the SPA can poll one endpoint and surface approvals in the browser.
|
|
if (url.pathname === '/api/share/status') {
|
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
res.end(JSON.stringify(await share.status()))
|
|
return
|
|
}
|
|
if (url.pathname === '/api/share/start' && req.method === 'POST') {
|
|
const body = JSON.parse((await readBody(req)) || '{}') as { always?: boolean }
|
|
let startError: string | undefined
|
|
try {
|
|
await share.start(!!body.always)
|
|
await saveShareAlways(!!body.always)
|
|
} catch (err) {
|
|
// e.g. EADDRINUSE when a CLI `codeburn share` already holds the port.
|
|
startError = err instanceof Error ? err.message : String(err)
|
|
}
|
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
res.end(JSON.stringify({ ...(await share.status()), error: startError }))
|
|
return
|
|
}
|
|
if (url.pathname === '/api/share/stop' && req.method === 'POST') {
|
|
await share.stop()
|
|
await saveShareAlways(false)
|
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
res.end(JSON.stringify(await share.status()))
|
|
return
|
|
}
|
|
// Session ids are resolved against the discovered session files only,
|
|
// never joined into a path from user input, and responses never carry
|
|
// local file paths.
|
|
if (url.pathname === '/api/context/sessions') {
|
|
const provider = url.searchParams.get('provider') ?? 'claude'
|
|
if (provider !== 'claude' && provider !== 'codex') {
|
|
writeJsonError(res, 400, 'provider must be claude or codex')
|
|
return
|
|
}
|
|
const refs = provider === 'claude' ? await listRecentTitledSessions(15) : await listRecentCodexSessions(15)
|
|
const sessions = refs.map((r) => ({ provider, sessionId: r.sessionId, project: r.project, title: r.title, mtimeMs: r.mtimeMs, sizeBytes: r.sizeBytes }))
|
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
res.end(JSON.stringify({ sessions }))
|
|
return
|
|
}
|
|
if (url.pathname === '/api/context/tree') {
|
|
const provider = url.searchParams.get('provider') ?? 'claude'
|
|
const id = url.searchParams.get('id') ?? ''
|
|
if ((provider !== 'claude' && provider !== 'codex') || !id) {
|
|
writeJsonError(res, 400, 'provider (claude|codex) and id are required')
|
|
return
|
|
}
|
|
// findClaudeSession/findCodexSession resolve an id prefix the same way
|
|
// `codeburn context <session>` does (see context-tree.ts's resolveSession) -
|
|
// trust that match instead of re-requiring the full id here, so a prefix
|
|
// that works on the CLI works through the API too.
|
|
const ref = provider === 'claude' ? await findClaudeSession(id) : await findCodexSession(id)
|
|
if (!ref) {
|
|
writeJsonError(res, 404, `no ${provider} session ${id}`)
|
|
return
|
|
}
|
|
const tree = await getContextTree(provider, ref)
|
|
const payload = {
|
|
...tree,
|
|
session: { sessionId: tree.session.sessionId, project: tree.session.project, mtimeMs: tree.session.mtimeMs, sizeBytes: tree.session.sizeBytes },
|
|
effectiveRows: snapshotRows(tree.effective),
|
|
fullRows: snapshotRows(tree.full),
|
|
}
|
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
res.end(JSON.stringify(payload))
|
|
return
|
|
}
|
|
|
|
if (url.pathname === '/api/share/approve' && req.method === 'POST') {
|
|
const body = JSON.parse((await readBody(req)) || '{}') as { id?: string; approve?: boolean }
|
|
const ok = typeof body.id === 'string' && share.resolvePending(body.id, !!body.approve)
|
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
res.end(JSON.stringify({ ok }))
|
|
return
|
|
}
|
|
|
|
if (!dashDir) {
|
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
res.end(NOT_BUILT_PAGE)
|
|
return
|
|
}
|
|
|
|
let pathname = decodeURIComponent(url.pathname)
|
|
if (pathname === '/' || pathname === '') pathname = '/index.html'
|
|
const filePath = normalize(join(dashDir, pathname))
|
|
if (filePath !== dashDir && !filePath.startsWith(dashDir + sep)) {
|
|
res.writeHead(403)
|
|
res.end('Forbidden')
|
|
return
|
|
}
|
|
try {
|
|
if (extname(filePath) === '.html') {
|
|
await serveIndexHtml(res, filePath)
|
|
} else {
|
|
const buf = await readFile(filePath)
|
|
res.writeHead(200, { 'content-type': CONTENT_TYPES[extname(filePath)] ?? 'application/octet-stream' })
|
|
res.end(buf)
|
|
}
|
|
} catch {
|
|
// Unknown path: serve index.html so the SPA can route it.
|
|
await serveIndexHtml(res, join(dashDir, 'index.html'))
|
|
}
|
|
} catch (err) {
|
|
res.writeHead(500, { 'content-type': 'application/json' })
|
|
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
}
|
|
})
|
|
|
|
const port = await new Promise<number>((resolve, reject) => {
|
|
server.once('error', (err: NodeJS.ErrnoException) => {
|
|
if (err.code === 'EADDRINUSE') {
|
|
server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
|
|
} else {
|
|
reject(err)
|
|
}
|
|
})
|
|
server.listen(opts.port, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
|
|
})
|
|
// Durable handler so a post-bind socket error never crashes the process.
|
|
server.on('error', () => {})
|
|
|
|
void Promise.resolve().then(() => getLocalPayload(opts.period, opts.provider, opts.from, opts.to)).catch(() => {})
|
|
|
|
const url = `http://127.0.0.1:${port}`
|
|
if (!dashDir) {
|
|
process.stdout.write(`\n Dashboard UI is not built. Run: cd dash && npm install && npm run build\n`)
|
|
}
|
|
process.stdout.write(`\n CodeBurn dashboard at ${url}\n Press Ctrl+C to stop.\n\n`)
|
|
if (opts.open) openBrowser(url)
|
|
|
|
// Withdraw the mDNS advertisement and close the share server cleanly on exit.
|
|
process.on('SIGINT', () => {
|
|
void share.stop().finally(() => process.exit(0))
|
|
})
|
|
|
|
return server
|
|
|
|
await new Promise<never>(() => {
|
|
/* run until interrupted */
|
|
})
|
|
}
|