mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 06:24:32 +00:00
harden sharing + dashboard for public launch
Security: - pairing: cap PIN attempts (close window after 5 wrong guesses) so a 6-digit PIN cannot be brute-forced within the TTL on a 0.0.0.0 listener. - web dashboard: reject non-loopback Host (defeats DNS rebinding that could read unsanitized local usage) and cross-origin requests (CSRF); require application/json on the pair endpoint. - store tokens with 0600 perms (was world-readable), 0700 dir. Robustness: - client: per-request timeout so a hung/asleep peer cannot hang a pull; pullDevices fetches remotes concurrently and isolates failures. - dashboard: normalize peer payloads at the boundary and add an error boundary so a peer on a different version cannot white-screen the SPA; finite-guard fmtNum/compactUsd. Tests: PIN attempt-cap test added (1269 pass).
This commit is contained in:
parent
33c9771b5f
commit
d44c46c591
12 changed files with 154 additions and 33 deletions
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-outer-background p-2.5">
|
||||
|
|
|
|||
|
|
@ -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<string, number | string> = { 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
|
||||
|
|
|
|||
|
|
@ -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<Current>
|
||||
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 }> = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-outer-background p-8 text-center">
|
||||
<p className="text-sm text-foreground">Something went wrong rendering the dashboard.</p>
|
||||
<p className="max-w-md text-xs text-tertiary-foreground">{String(this.state.error.message)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => location.reload()}
|
||||
className="rounded-md border border-border bg-card px-3 py-1.5 text-xs text-foreground hover:bg-interactive-secondary"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 30_000, retry: 1 } },
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ function call(
|
|||
path: string,
|
||||
headers: Record<string, string> = {},
|
||||
body?: string,
|
||||
timeoutMs = 8000,
|
||||
): Promise<Response> {
|
||||
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<Respo
|
|||
// Approve-style pairing: no PIN. The peer prompts its user to approve; this
|
||||
// request stays open until they accept or decline.
|
||||
export function pairRequest(ep: PeerEndpoint, name: string): Promise<Response> {
|
||||
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<Response> {
|
||||
|
|
|
|||
|
|
@ -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<DeviceUsage> => {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<T>(path: string, fallback: T): Promise<T> {
|
|||
}
|
||||
}
|
||||
|
||||
// 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<void> {
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue