diff --git a/dash/src/App.tsx b/dash/src/App.tsx index 21fda451..fe0c8f0e 100644 --- a/dash/src/App.tsx +++ b/dash/src/App.tsx @@ -353,7 +353,7 @@ export function App() { const showCombined = multi && view === 'all' const viewTitle = showCombined ? 'All devices' : (primary ? primary.name + (primary.local ? ' · this Mac' : '') : 'Loading…') - const label = local?.payload?.current.label ?? '' + const label = local?.payload?.current?.label ?? '' return (
diff --git a/dash/src/components/UsageChart.tsx b/dash/src/components/UsageChart.tsx index 4a6bd442..6d1ff091 100644 --- a/dash/src/components/UsageChart.tsx +++ b/dash/src/components/UsageChart.tsx @@ -131,7 +131,8 @@ export function UsageChart({ daily, unit = 'cost' }: { daily: DailyEntry[]; unit export function DeviceUsageChart({ devices, unit = 'cost' }: { devices: DeviceUsage[]; unit?: Unit }) { const { rows, series, labels } = useMemo(() => { const named = devices.filter((d) => d.payload) - const dates = [...new Set(named.flatMap((d) => d.payload!.history.daily.map((e) => e.date)))].sort((a, b) => a.localeCompare(b)) + const dailyOf = (d: DeviceUsage) => d.payload?.history?.daily ?? [] + const dates = [...new Set(named.flatMap((d) => dailyOf(d).map((e) => e.date)))].sort((a, b) => a.localeCompare(b)) const series: Series[] = named.map((d, i) => ({ key: `d${i}`, label: d.name + (d.local ? ' (this Mac)' : ''), @@ -140,7 +141,7 @@ export function DeviceUsageChart({ devices, unit = 'cost' }: { devices: DeviceUs const rowData = dates.map((date) => { const row: Record = { period: date } named.forEach((d, i) => { - const e = d.payload!.history.daily.find((x) => x.date === date) + const e = dailyOf(d).find((x) => x.date === date) row[`d${i}`] = e ? (unit === 'tokens' ? e.inputTokens + e.outputTokens : e.cost) : 0 }) return row diff --git a/dash/src/lib/api.ts b/dash/src/lib/api.ts index 5b42f86b..b8ad57e0 100644 --- a/dash/src/lib/api.ts +++ b/dash/src/lib/api.ts @@ -62,10 +62,47 @@ export type DeviceUsage = { error?: string } +// A device may run a different CodeBurn version and send a payload missing +// fields we treat as required. Fill safe defaults at the boundary so the UI +// can iterate them without crashing (the alternative is a white screen for an +// innocent local user because a peer sent an old shape). +function normalizePayload(p?: Payload): Payload | undefined { + if (!p) return p + const c = (p.current ?? {}) as Partial + return { + generated: p.generated, + current: { + label: c.label ?? '', + cost: c.cost ?? 0, + calls: c.calls ?? 0, + sessions: c.sessions ?? 0, + oneShotRate: c.oneShotRate ?? null, + inputTokens: c.inputTokens ?? 0, + outputTokens: c.outputTokens ?? 0, + cacheHitPercent: c.cacheHitPercent ?? 0, + codexCredits: c.codexCredits ?? 0, + topActivities: c.topActivities ?? [], + topModels: c.topModels ?? [], + providers: c.providers ?? {}, + topProjects: c.topProjects ?? [], + tools: c.tools ?? [], + subagents: c.subagents ?? [], + skills: c.skills ?? [], + mcpServers: c.mcpServers ?? [], + modelEfficiency: c.modelEfficiency ?? [], + localModelSavings: c.localModelSavings ?? { totalUSD: 0 }, + retryTax: c.retryTax ?? { totalUSD: 0, retries: 0 }, + routingWaste: c.routingWaste ?? { totalSavingsUSD: 0 }, + }, + history: { daily: p.history?.daily ?? [] }, + } +} + export async function fetchDevices(period: Period, provider: string): Promise<{ devices: DeviceUsage[] }> { const res = await fetch(`/api/devices?period=${encodeURIComponent(period)}&provider=${encodeURIComponent(provider)}`) if (!res.ok) throw new Error(`Request failed (${res.status})`) - return res.json() as Promise<{ devices: DeviceUsage[] }> + const data = (await res.json()) as { devices: DeviceUsage[] } + return { devices: (data.devices ?? []).map((d) => ({ ...d, payload: normalizePayload(d.payload) })) } } export const PERIODS: Array<{ key: Period; label: string }> = [ diff --git a/dash/src/lib/utils.ts b/dash/src/lib/utils.ts index 37a400bb..9327cfdc 100644 --- a/dash/src/lib/utils.ts +++ b/dash/src/lib/utils.ts @@ -21,13 +21,17 @@ export function fmtTokens(n: number | undefined | null): string { } export function fmtNum(n: number | undefined | null): string { - return (n ?? 0).toLocaleString() + const v = n == null || !isFinite(n) ? 0 : n + return v.toLocaleString() } export function compactUsd(n: number): string { - if (n >= 1e6) return '$' + (n / 1e6).toFixed(1) + 'M' - if (n >= 1e3) return '$' + (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + 'k' - return '$' + Math.round(n) + if (!isFinite(n)) return '$0' + const sign = n < 0 ? '-' : '' + const a = Math.abs(n) + if (a >= 1e6) return sign + '$' + (a / 1e6).toFixed(1) + 'M' + if (a >= 1e3) return sign + '$' + (a / 1e3).toFixed(a >= 1e4 ? 0 : 1) + 'k' + return sign + '$' + Math.round(a) } // Forest green -> gold -> terracotta ramp for stacked series (mirrors the diff --git a/dash/src/main.tsx b/dash/src/main.tsx index b8ce191b..68fcb0ff 100644 --- a/dash/src/main.tsx +++ b/dash/src/main.tsx @@ -1,18 +1,47 @@ -import { StrictMode } from 'react' +import { Component, StrictMode, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { App } from './App' import './index.css' +// Last-resort guard: a render error (e.g. an unexpected payload from a peer on +// a different version) shows a recoverable message instead of a blank page. +class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { + state = { error: null as Error | null } + static getDerivedStateFromError(error: Error) { + return { error } + } + render() { + if (this.state.error) { + return ( +
+

Something went wrong rendering the dashboard.

+

{String(this.state.error.message)}

+ +
+ ) + } + return this.props.children + } +} + const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 30_000, retry: 1 } }, }) createRoot(document.getElementById('root')!).render( - - - + + + + + , ) diff --git a/src/sharing/client.ts b/src/sharing/client.ts index 60d2886e..12aecd24 100644 --- a/src/sharing/client.ts +++ b/src/sharing/client.ts @@ -24,6 +24,7 @@ function call( path: string, headers: Record = {}, body?: string, + timeoutMs = 8000, ): Promise { return new Promise((resolve, reject) => { const req = request( @@ -54,6 +55,7 @@ function call( }, ) req.on('error', reject) + req.setTimeout(timeoutMs, () => req.destroy(new Error('peer timed out'))) if (body) req.write(body) req.end() }) @@ -70,7 +72,9 @@ export function pair(ep: PeerEndpoint, pin: string, name: string): Promise { - return call(ep, 'POST', '/api/peer/pair-request', {}, JSON.stringify({ name })) + // Stays open while the peer's user decides; give it longer than the server's + // 60s approval prompt. + return call(ep, 'POST', '/api/peer/pair-request', {}, JSON.stringify({ name }), 65_000) } export function fetchUsage(ep: PeerEndpoint, token: string, query: UsageQuery = {}): Promise { diff --git a/src/sharing/host.ts b/src/sharing/host.ts index 158782b4..5ef3f935 100644 --- a/src/sharing/host.ts +++ b/src/sharing/host.ts @@ -89,17 +89,21 @@ export async function pullDevices( const identity = await loadOrCreateIdentity(dir) const remotes = await loadRemotes(dir) - const results: DeviceUsage[] = [{ name: localName, local: true, payload: await localGetUsage(query) }] - for (const r of remotes) { - try { - const res = await fetchUsage({ identity, host: r.host, port: r.port, expectedFingerprint: r.fingerprint }, r.token, query) - if (res.status === 200) results.push({ name: r.name, local: false, payload: res.json as DevicePayload }) - else results.push({ name: r.name, local: false, error: res.status === 401 ? 'not authorized (re-pair?)' : `HTTP ${res.status}` }) - } catch (e) { - results.push({ name: r.name, local: false, error: e instanceof Error ? e.message : String(e) }) - } - } - return results + const local: DeviceUsage = { name: localName, local: true, payload: await localGetUsage(query) } + // Pull every remote concurrently and isolate failures, so one slow or + // powered-off device degrades to an error row instead of blocking the rest. + const remoteResults = await Promise.all( + remotes.map(async (r): Promise => { + try { + const res = await fetchUsage({ identity, host: r.host, port: r.port, expectedFingerprint: r.fingerprint }, r.token, query) + if (res.status === 200) return { name: r.name, local: false, payload: res.json as DevicePayload } + return { name: r.name, local: false, error: res.status === 401 ? 'not authorized (re-pair?)' : `HTTP ${res.status}` } + } catch (e) { + return { name: r.name, local: false, error: e instanceof Error ? e.message : String(e) } + } + }), + ) + return [local, ...remoteResults] } export function renderDevices(results: DeviceUsage[]): string { diff --git a/src/sharing/pairing.ts b/src/sharing/pairing.ts index faec7778..7aab37cd 100644 --- a/src/sharing/pairing.ts +++ b/src/sharing/pairing.ts @@ -44,23 +44,32 @@ export class PairingWindow { readonly pin: string readonly openedAt: number private used = false + private attempts = 0 - constructor(ttlMs = 60_000, now: number = Date.now(), pin: string = generatePin()) { + constructor(ttlMs = 60_000, now: number = Date.now(), pin: string = generatePin(), maxAttempts = 5) { this.ttlMs = ttlMs this.pin = pin this.openedAt = now + this.maxAttempts = maxAttempts } private readonly ttlMs: number + private readonly maxAttempts: number isOpen(now: number = Date.now()): boolean { return !this.used && now - this.openedAt <= this.ttlMs } // Verify a submitted PIN. A correct match consumes the window (one-time use). + // Wrong guesses are counted and the window closes after maxAttempts, so a + // 6-digit PIN cannot be brute-forced by a LAN peer within the TTL. verify(pin: string, now: number = Date.now()): boolean { if (!this.isOpen(now)) return false - if (!constantTimeEqual(pin, this.pin)) return false + if (!constantTimeEqual(pin, this.pin)) { + this.attempts += 1 + if (this.attempts >= this.maxAttempts) this.used = true + return false + } this.used = true return true } diff --git a/src/sharing/sanitize.ts b/src/sharing/sanitize.ts index d0b77cd9..a09f5118 100644 --- a/src/sharing/sanitize.ts +++ b/src/sharing/sanitize.ts @@ -1,9 +1,11 @@ import type { MenubarPayload } from '../menubar-json.js' -// Strip identifying detail before usage leaves the device. We share aggregate -// numbers (cost, tokens, models, tools, activities, daily) but never project -// names, paths, or per-session detail, so "what you are working on" stays on -// the machine that produced it. Only the totals travel. +// Strip identifying detail before usage leaves the device. We never share +// project names, file paths, or per-session detail (the strongest signal of +// "what you are working on"). We DO share aggregate numbers plus model, tool, +// task, subagent, skill, and MCP-server usage, since the dashboard surfaces +// those per device. If a user names a subagent/skill after a client, that name +// would travel; revisit if that becomes a concern. export function sanitizeForSharing(payload: MenubarPayload): MenubarPayload { return { ...payload, diff --git a/src/sharing/store.ts b/src/sharing/store.ts index 717222e7..5418c8dc 100644 --- a/src/sharing/store.ts +++ b/src/sharing/store.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile, mkdir } from 'fs/promises' +import { readFile, writeFile, mkdir, chmod } from 'fs/promises' import { join, dirname } from 'path' import { getConfigFilePath } from '../config.js' @@ -28,9 +28,13 @@ async function readJson(path: string, fallback: T): Promise { } } +// These files hold bearer tokens, so keep them owner-only (0600) like the TLS +// private key. mkdir/writeFile modes only apply on creation, so chmod enforces +// it on files that already exist from an earlier version. async function writeJson(path: string, data: unknown): Promise { - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, JSON.stringify(data, null, 2)) + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + await writeFile(path, JSON.stringify(data, null, 2), { mode: 0o600 }) + await chmod(path, 0o600).catch(() => {}) } // Peers allowed to pull from this device (the sharing side, used by ShareServer). diff --git a/src/web-dashboard.ts b/src/web-dashboard.ts index aa61a5de..f227e632 100644 --- a/src/web-dashboard.ts +++ b/src/web-dashboard.ts @@ -93,6 +93,20 @@ export async function runWebDashboard(opts: { 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 @@ -166,6 +180,11 @@ export async function runWebDashboard(opts: { // 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) diff --git a/tests/sharing/pairing.test.ts b/tests/sharing/pairing.test.ts index 5410cc9e..cd950c13 100644 --- a/tests/sharing/pairing.test.ts +++ b/tests/sharing/pairing.test.ts @@ -66,6 +66,14 @@ describe('PairingWindow', () => { expect(w.verify('123456', 1100)).toBe(true) expect(w.verify('123456', 1200)).toBe(false) }) + it('closes after too many wrong guesses (no brute force within the window)', () => { + const w = new PairingWindow(10_000, 1000, '123456', 5) + for (let i = 0; i < 5; i++) expect(w.verify('000000', 1000 + i)).toBe(false) + // window is now locked even though the TTL has not expired + expect(w.isOpen(1100)).toBe(false) + // and the correct PIN no longer works + expect(w.verify('123456', 1100)).toBe(false) + }) }) describe('PeerStore', () => {