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', () => {