diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b45478a42..f0ba1dd7e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Currency: Korean won (KRW) in the preferred-currency picker, with correct zero-decimal formatting (#2669, refs #2449). Thanks @kes02! - Dashboard v1 snapshot: Claude rows now include all claude-swap accounts (windows, pace, active flag, redacted identity) when the integration is enabled. +- CLI: add a built-in auto-refreshing web dashboard at `/` to `codexbar serve`. - CLI: expose Codex cost-history completeness in JSON and add an experimental provider-native-only scan mode (#2520). Thanks @NickGuAI! - Usage & Spend: add an accessible daily, weekly, and cumulative token-activity heatmap backed by the existing persistent cost scan cache (#2548). Thanks @Yuxin-Qiao! - Settings: the sidebar is now resizable — drag the divider between 200–380pt; the width persists across launches. diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 8c653ccaf3..2a32b42ab2 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -196,8 +196,9 @@ extension CodexBarCLI { Description: Start a foreground HTTP server that exposes existing CLI JSON payloads and a - token-gated dashboard snapshot. The server binds to 127.0.0.1 by default; - `localhost` is normalized to 127.0.0.1. + token-gated dashboard snapshot, with a built-in web UI at /. The static web UI + is always open; it sends a browser-entered token only when fetching snapshot data. + The server binds to 127.0.0.1 by default; `localhost` is normalized to 127.0.0.1. GET /dashboard/v1/snapshot requires "Authorization: Bearer YOUR_TOKEN" and fails closed (401) when no token is configured. Set the token with --dashboard-token or, preferably, the CODEXBAR_DASHBOARD_TOKEN environment variable (argv leaks via ps). @@ -205,10 +206,11 @@ extension CodexBarCLI { request. A non-loopback --host therefore requires both a dashboard token and --allow-plain-http, which records that you accept that trade-off. On a non-loopback host the token also gates /usage and /cost (account data); - /health is always open. Use a TLS-terminating reverse proxy for anything + / and /health are always open. Use a TLS-terminating reverse proxy for anything beyond a trusted network segment. Endpoints: + GET / Built-in web dashboard GET /health GET /usage GET /usage?provider=claude diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index d5dde141e9..4047d41861 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -38,6 +38,7 @@ struct ServeOptions: CommanderParsable { } enum CLIServeRoute: Equatable { + case webUI case health case usage(provider: String?) case cost(provider: String?) @@ -59,6 +60,8 @@ enum CLIServeRouter { let normalizedProvider = provider?.isEmpty == false ? provider : nil switch path { + case "/": + return .webUI case "/health": return .health case "/usage": @@ -109,7 +112,7 @@ struct ServeRuntime { let dashboardAuth: CLIServeDashboardAuth /// True for non-loopback binds: every data route (`/usage`, `/cost`, /// `/dashboard/v1/snapshot`) then requires the bearer token, so account data - /// is never exposed to the network unauthenticated. `/health` stays open. + /// is never exposed to the network unauthenticated. `/` and `/health` stay open. /// Resolved once at startup from the bind host. let dataRoutesRequireAuth: Bool @@ -823,6 +826,8 @@ extension CodexBarCLI { } switch route { + case .webUI: + return CLIServeWebUI.response() case .health: return Self.serveHealthResponse(version: runtime.healthVersion) case let .usage(provider): diff --git a/Sources/CodexBarCLI/CLIServeWebUI.swift b/Sources/CodexBarCLI/CLIServeWebUI.swift new file mode 100644 index 0000000000..9069bb4b0d --- /dev/null +++ b/Sources/CodexBarCLI/CLIServeWebUI.swift @@ -0,0 +1,773 @@ +import Foundation + +enum CLIServeWebUI { + static func response() -> CLILocalHTTPResponse { + CLILocalHTTPResponse( + status: .ok, + body: Data(self.html.utf8), + contentType: "text/html; charset=utf-8", + extraHeaders: [("Cache-Control", "no-store")]) + } + + static let html = #""" + + + + + + + + CodexBar Dashboard + + + +
+
+
+ +

CodexBar

+ +
+
+ Loading… + Stale + +
+
+ +
+

This server requires a dashboard token

+

Enter the bearer token configured for this CodexBar server.

+
+ + +
+
+ +
+

Dashboard unavailable

+

+ +
+ +
+
+ + + + + """# +} diff --git a/Tests/CodexBarTests/CLIServeRawHTTPTests.swift b/Tests/CodexBarTests/CLIServeRawHTTPTests.swift index c10bc96af3..8f221eea4c 100644 --- a/Tests/CodexBarTests/CLIServeRawHTTPTests.swift +++ b/Tests/CodexBarTests/CLIServeRawHTTPTests.swift @@ -126,6 +126,33 @@ struct CLIServeRawHTTPTests { // MARK: - Dashboard snapshot auth (production handler) + @Test + func `web UI returns HTML with no-store`() async throws { + try await Self.withServeRuntime(token: nil, body: { port in + let response = try await Self.rawExchange( + port: port, + request: "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + + #expect(response.statusLine == "HTTP/1.1 200 OK") + #expect(response.headerValue("Content-Type") == "text/html; charset=utf-8") + #expect(response.headerValue("Cache-Control") == "no-store") + #expect(response.body.contains("CodexBar Dashboard")) + #expect(response.body.contains("/dashboard/v1/snapshot")) + }) + } + + @Test + func `web UI stays open when a dashboard token is configured`() async throws { + try await Self.withServeRuntime(token: "secret", bindHost: "0.0.0.0", body: { port in + let response = try await Self.rawExchange( + port: port, + request: "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + + #expect(response.statusLine == "HTTP/1.1 200 OK") + #expect(response.headerValue("Content-Type") == "text/html; charset=utf-8") + }) + } + @Test func `snapshot without credentials returns 401 with challenge and no-store`() async throws { try await Self.withServeRuntime(token: "secret", body: { port in diff --git a/Tests/CodexBarTests/CLIServeRouterTests.swift b/Tests/CodexBarTests/CLIServeRouterTests.swift index 0635449461..2505d406c4 100644 --- a/Tests/CodexBarTests/CLIServeRouterTests.swift +++ b/Tests/CodexBarTests/CLIServeRouterTests.swift @@ -128,7 +128,8 @@ struct CLIServeRouterTests { } @Test - func `routes health usage and cost endpoints`() throws { + func `routes web UI health usage cost and dashboard endpoints`() throws { + #expect(try CLIServeRouter.route(method: "GET", path: "/", queryItems: [:]) == .webUI) #expect(try CLIServeRouter.route(method: "GET", path: "/health", queryItems: [:]) == .health) #expect(try CLIServeRouter.route(method: "GET", path: "/usage", queryItems: [:]) == .usage(provider: nil)) #expect( @@ -160,6 +161,18 @@ struct CLIServeRouterTests { } } + @Test + func `rejects post to web UI`() { + do { + _ = try CLIServeRouter.route(method: "POST", path: "/", queryItems: [:]) + Issue.record("Expected methodNotAllowed") + } catch let error as CLIServeRouteError { + #expect(error == .methodNotAllowed) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + @Test func `rejects unknown paths`() { do { diff --git a/docs/cli.md b/docs/cli.md index 25a5a2b26e..ac3013a906 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -83,18 +83,18 @@ See `docs/configuration.md` for the schema. - Stdout contains only the snapshot document. Diagnostics and optional `--json-output` logs go to stderr. - `--pretty` formats the document. `--timeout ` accepts `0...86400`, defaults to `30`, and uses `0` to disable the command deadline. - Starts no HTTP server and requires no dashboard bearer token. See `docs/dashboard-api.md` for the shared payload contract. -- `codexbar serve` starts a foreground HTTP server for usage and cost JSON plus a token-gated dashboard snapshot. +- `codexbar serve` starts a foreground HTTP server for usage and cost JSON, a token-gated dashboard snapshot, and a built-in web UI at `/`. - `--host ` accepts `localhost` or an IPv4 address and defaults to `127.0.0.1`; `localhost` is normalized to `127.0.0.1`. Binding a non-loopback host requires a dashboard token **and** `--allow-plain-http` (see `docs/dashboard-api.md` for the threat model). - `--port ` defaults to `8080`. - `--refresh-interval ` defaults to `60` and controls the in-memory response cache TTL. - `--request-timeout ` defaults to `30` and bounds each request before returning `504 Gateway Timeout`; use `0` to keep waiting indefinitely. - `--dashboard-token ` sets the static bearer token for `GET /dashboard/v1/snapshot`. Prefer the `CODEXBAR_DASHBOARD_TOKEN` environment variable (it wins over the flag; a flag value leaks via `ps`). Empty or whitespace-only tokens are startup errors. Without a token the snapshot route fails closed with `401`. - - On a **non-loopback** host the token gates **all data routes** — `/usage`, `/cost`, and `/dashboard/v1/snapshot` all require `Authorization: Bearer YOUR_TOKEN`, so account data is never exposed to the network unauthenticated. `/health` is always open. On the default loopback bind, `/usage` and `/cost` stay unauthenticated. + - On a **non-loopback** host the token gates **all data routes** — `/usage`, `/cost`, and `/dashboard/v1/snapshot` all require `Authorization: Bearer YOUR_TOKEN`, so account data is never exposed to the network unauthenticated. The static web UI at `/` and `/health` are always open. On the default loopback bind, `/usage` and `/cost` stay unauthenticated. - `--allow-plain-http` is the explicit acknowledgment that the bearer token crosses the network **in cleartext on every request** when serving on a non-loopback host. `serve` refuses to start on a non-loopback host without it. - Provider config is reloaded for each usage/cost request; cache entries are keyed by the loaded config so provider toggles and source changes do not require restarting `serve`. - Transient refresh failures fall back to the last good response for up to ten refresh intervals (minimum five minutes) so polling clients do not flicker between data and errors; disabled when `--refresh-interval 0`. - The default loopback bind rejects non-loopback `Host` headers; a configured non-loopback `--host` additionally accepts its own name. No CORS, TLS, or daemon mode. - - Endpoints: `GET /health`, `GET /usage`, `GET /usage?provider=`, `GET /cost`, `GET /cost?provider=`, `GET /dashboard/v1/snapshot`. + - Endpoints: `GET /` (web UI), `GET /health`, `GET /usage`, `GET /usage?provider=`, `GET /cost`, `GET /cost?provider=`, `GET /dashboard/v1/snapshot`. - `GET /dashboard/v1/snapshot` requires `Authorization: Bearer YOUR_TOKEN`; responses (and all `401`s) carry `Cache-Control: no-store`. The token is never accepted via query string. See `docs/dashboard-api.md` for the payload contract. - `GET /health` returns `{"status":"ok"}` plus a `version` field with the running build (e.g. `"0.37.2"`) when resolvable; clients can compare it against `codexbar --version` to detect a `serve` process still running an older binary after an update. - Codex usage responses include every visible Codex account, matching the menu bar switcher. diff --git a/docs/dashboard-api.md b/docs/dashboard-api.md index f58f7e1139..285aa73b29 100644 --- a/docs/dashboard-api.md +++ b/docs/dashboard-api.md @@ -26,7 +26,13 @@ Both transports use the same producer and schema-v1 payload. The one-shot comman The HTTP route is gated by a static bearer token and **fails closed**: without a configured token every request answers `401`. The token is only ever read from the `Authorization` header — a query-string parameter named `token` is never accepted. Every response on the dashboard route — including all `401`s and error responses — carries `Cache-Control: no-store`. -On the default loopback bind, `/usage` and `/cost` are unchanged and unauthenticated. On a **non-loopback** bind the same token gates **all data routes**: `/usage`, `/cost`, and `/dashboard/v1/snapshot` each require `Authorization: Bearer YOUR_TOKEN`, so account data never leaves the machine unauthenticated. `/health` is always open (it carries only a status and version string, useful for liveness probes). +On the default loopback bind, `/usage` and `/cost` are unchanged and unauthenticated. On a **non-loopback** bind the same token gates **all data routes**: `/usage`, `/cost`, and `/dashboard/v1/snapshot` each require `Authorization: Bearer YOUR_TOKEN`, so account data never leaves the machine unauthenticated. `/` and `/health` are always open; neither response contains account data. + +## Built-in web UI + +`GET /` serves a self-contained web dashboard that polls `/dashboard/v1/snapshot`. The static HTML is always unauthenticated, including on non-loopback binds, because it contains no account data. When the snapshot route returns `401`, the page asks for the dashboard token, stores it in the browser under the localStorage key `codexbar.dashboardToken`, and sends it in the `Authorization` header on each snapshot request. The token is never added to the URL. + +The UI does not change the transport threat model: `codexbar serve` is plain HTTP. Off-loopback, a token typed into the page transits the network in cleartext like every other request unless a TLS-terminating reverse proxy protects the connection. ## One-shot command semantics @@ -65,7 +71,7 @@ Transport is **plain HTTP**. There is no TLS in `codexbar serve`, which means: - The bearer token crosses the network **in cleartext on every request**. Anyone who can observe the path (same Wi-Fi, ARP spoofing, a compromised switch, your ISP on a routed path) can capture the token and replay it until the server restarts with a new one. - The response bodies — plan labels, usage percentages, email domains, cost figures — cross the network in cleartext too. -- Because non-loopback binds gate `/usage`, `/cost`, and `/dashboard/v1/snapshot` behind the same token, a passive observer sees your account data but an active client without the token gets `401` on every data route. `/health` is the only unauthenticated route off-loopback. +- Because non-loopback binds gate `/usage`, `/cost`, and `/dashboard/v1/snapshot` behind the same token, a passive observer sees your account data but an active client without the token gets `401` on every data route. Only the account-free static UI at `/` and `/health` are unauthenticated off-loopback. Deployments, from safest to least safe: