From d55a1b7fc4c51ead1bdeeaba13fbc55f8f7a0228 Mon Sep 17 00:00:00 2001 From: Peter Urda Date: Sat, 15 Aug 2026 20:38:26 -0700 Subject: [PATCH] fix: follow app privacy setting on serve dashboard The serve dashboard ignored the app's "Hide personal information" toggle and always served full account emails unless --identity was passed. Resolve the identity mode per request when the flag is absent, so the toggle applies without a serve restart, and fold the resolved mode into the response cache key so a body cached before a toggle cannot be replayed after it. An explicit --identity still wins. codexbar dashboard keeps its full default. --- CHANGELOG.md | 1 + Sources/CodexBarCLI/CLIDashboardCommand.swift | 20 +++ Sources/CodexBarCLI/CLIHelpers.swift | 16 ++ Sources/CodexBarCLI/CLIServeCommand.swift | 160 +++++++++++------- .../CLIServeDashboardIdentityTests.swift | 61 +++++++ docs/cli.md | 2 +- docs/dashboard-api.md | 8 +- 7 files changed, 203 insertions(+), 65 deletions(-) create mode 100644 Tests/CodexBarTests/CLIServeDashboardIdentityTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7341bb53e2..9d1dcdb5ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Vertex AI: match Cloud Monitoring quota usage without a `limit_name` to its unambiguous same-metric, same-location limit, restoring quota percentages (#2958). Thanks @MachApple! +- Serve: follow the app's "Hide personal information" setting on the web dashboard when no `--identity` flag is given, resolving the mode per request so the toggle applies without a serve restart. ## 0.50.0 — 2026-08-15 diff --git a/Sources/CodexBarCLI/CLIDashboardCommand.swift b/Sources/CodexBarCLI/CLIDashboardCommand.swift index ff165c6daa..391e1b5576 100644 --- a/Sources/CodexBarCLI/CLIDashboardCommand.swift +++ b/Sources/CodexBarCLI/CLIDashboardCommand.swift @@ -348,6 +348,26 @@ extension CodexBarCLI { } } + /// True when the caller passed `--identity` explicitly. `serve` uses this to tell an + /// explicit choice apart from the absent flag, which follows the app's privacy setting. + static func dashboardIdentityFlagPresent(in values: ParsedValues) -> Bool { + values.options["identity"]?.last != nil + } + + /// Identity detail for one dashboard snapshot request. An explicit `--identity` wins, + /// so a scripted client keeps the mode it asked for. Without the flag the app's + /// "Hide personal information" toggle decides, which keeps the serve dashboard in step + /// with the menu UI. + static func resolveDashboardIdentityMode( + configured: DashboardIdentityMode?, + hidesPersonalInfo: Bool) -> DashboardIdentityMode + { + if let configured { + return configured + } + return hidesPersonalInfo ? .redacted : .full + } + static func decodeDashboardTimeout(from values: ParsedValues) -> TimeInterval? { let raw = values.options["timeout"]?.last ?? String(Int(Self.defaultServeRequestTimeout)) guard let timeout = TimeInterval(raw), diff --git a/Sources/CodexBarCLI/CLIHelpers.swift b/Sources/CodexBarCLI/CLIHelpers.swift index 80a95ce3b8..31e1beeeac 100644 --- a/Sources/CodexBarCLI/CLIHelpers.swift +++ b/Sources/CodexBarCLI/CLIHelpers.swift @@ -195,6 +195,22 @@ extension CodexBarCLI { return UserDefaults.standard.object(forKey: "weeklyProgressWorkDays") as? Int } + /// The app's "Hide personal information" privacy toggle. Read per request so the + /// serve dashboard follows the setting without a restart, the same way reset style + /// and weekly work days already do. + static func hidePersonalInfoFromDefaults() -> Bool { + let domains = [ + "com.steipete.codexbar", + "com.steipete.codexbar.debug", + ] + for domain in domains { + if let value = UserDefaults(suiteName: domain)?.object(forKey: "hidePersonalInfo") as? Bool { + return value + } + } + return UserDefaults.standard.object(forKey: "hidePersonalInfo") as? Bool ?? false + } + static func fetchProviderUsage( provider: UsageProvider, context: ProviderFetchContext) async -> ProviderFetchOutcome diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index 60bf2c4827..9446591617 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -124,10 +124,11 @@ struct ServeRuntime { let requestTimeout: TimeInterval let healthVersion: String? let dashboardAuth: CLIServeDashboardAuth - /// Identity detail for dashboard snapshots. Defaults to `.full`; the - /// `--identity redacted` startup option hides email local parts from every - /// authorized dashboard client. - let dashboardIdentityMode: DashboardIdentityMode + /// Identity detail for dashboard snapshots. `nil` means no `--identity` startup + /// option was given, so each request follows the app's "Hide personal information" + /// setting. An explicit `--identity redacted` hides email local parts from every + /// authorized dashboard client and ignores the app setting. + let dashboardIdentityMode: DashboardIdentityMode? /// 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. `/` and `/health` stay open. @@ -143,7 +144,7 @@ struct ServeRuntime { requestTimeout: TimeInterval, healthVersion: String?, dashboardAuth: CLIServeDashboardAuth, - dashboardIdentityMode: DashboardIdentityMode = .full, + dashboardIdentityMode: DashboardIdentityMode? = nil, bindHost: String) { self.configStore = configStore @@ -699,13 +700,18 @@ extension CodexBarCLI { let bindHost = CLIServeSecurity.bindHost(host) let allowPlainHTTP = Self.decodeServeAllowPlainHTTP(from: values) - guard let dashboardIdentityMode = Self.decodeDashboardIdentityMode(from: values) else { + guard let decodedIdentityMode = Self.decodeDashboardIdentityMode(from: values) else { Self.exit( code: .failure, message: "--identity must be redacted or full.", output: output, kind: .args) } + // An absent flag stays unresolved so each request can read the app's privacy + // setting; an explicit flag is captured once and never second-guessed. + let dashboardIdentityMode = Self.dashboardIdentityFlagPresent(in: values) + ? decodedIdentityMode + : nil if let startupError = Self.validateServeStartup( host: bindHost, hasConfiguredBearer: dashboardBearer != nil, @@ -954,62 +960,94 @@ extension CodexBarCLI { providerOperations: runtime.costOperations))) })) case let .dashboardSnapshot(provider, rawDetail): - // Auth comes first: an unauthenticated request must not warm, read, or - // deduplicate against the response cache. - guard runtime.dashboardAuth.authorize(request) else { - return Self.serveUnauthorizedResponse() - } - let snapshot: CLIServeConfigSnapshot - let operationKey: String - let detail: DashboardSnapshotDetail - let providers: [UsageProvider]? - do { - snapshot = try Self.loadServeConfigSnapshot(configStore: runtime.configStore) - operationKey = try Self.serveOperationKey(kind: "dashboard", provider: provider) - detail = try Self.dashboardSnapshotDetail(rawDetail) - providers = try Self.dashboardSnapshotProviders(provider) - } catch { - let status: CLIHTTPStatus = error is CLIServeArgumentError ? .badRequest : .internalServerError - return Self.addingNoStore(Self.serveError(status: status, message: error.localizedDescription)) - } - if detail == .shell { - return Self.addingNoStore(Self.serveDashboardShell( - config: snapshot.config, - providers: providers, - runtime: runtime)) - } - return await Self.addingNoStore(Self.cachedServeResponse( - request: ServeResponseRequest( - key: operationKey, - configFingerprint: snapshot.cacheToken, - refreshInterval: runtime.refreshInterval, - deadline: requestDeadline, - allowsStaleWhileRevalidate: true), - cache: runtime.cache, - makeResponse: { - await Self.serveDashboardSnapshot( - context: DashboardSnapshotContext( - config: snapshot.config, - usage: ServeUsageContext( - config: snapshot.config, - configFingerprint: snapshot.cacheToken, - refreshInterval: runtime.refreshInterval, - providerTimeout: providerTimeout, - providerDeadline: providerDeadline, - providerOperations: runtime.providerOperations, - includeAllCodexAccounts: false), - costCollection: ServeCostCollectionContext( - configFingerprint: snapshot.cacheToken, - providerTimeout: providerTimeout, - requestDeadline: requestDeadline, - now: { ContinuousClock().now }, - providerOperations: runtime.costOperations), - costRefreshesPricingInBackground: Self.serveCostRefreshesPricingInBackground, - codexBarVersion: runtime.healthVersion), - identityMode: runtime.dashboardIdentityMode, - providers: providers) - })) + return await Self.serveDashboardSnapshotRoute( + request, + provider: provider, + rawDetail: rawDetail, + runtime: runtime, + startedAt: startedAt) + } + } + + /// Handles `/dashboard/v1/snapshot`. Split out of ``handleServeRequest`` so the route's + /// auth, argument, shell, and cached-snapshot phases stay readable in one place. + private static func serveDashboardSnapshotRoute( + _ request: CLILocalHTTPRequest, + provider: String?, + rawDetail: String?, + runtime: ServeRuntime, + startedAt: ContinuousClock.Instant) async -> CLILocalHTTPResponse + { + let requestDeadline = Self.serveRequestDeadline( + startedAt: startedAt, + requestTimeout: runtime.requestTimeout) + let providerTimeout = Self.serveProviderTimeout(requestTimeout: runtime.requestTimeout) + let providerDeadline = Self.serveProviderDeadline( + startedAt: startedAt, + requestTimeout: runtime.requestTimeout) + // Auth comes first: an unauthenticated request must not warm, read, or + // deduplicate against the response cache. + guard runtime.dashboardAuth.authorize(request) else { + return Self.serveUnauthorizedResponse() + } + // Resolved per request, not at startup: the app's "Hide personal information" + // toggle can flip while serve runs. The resolved mode joins the operation key so + // a body cached before the flip can never be replayed after it. + let identityMode = Self.resolveDashboardIdentityMode( + configured: runtime.dashboardIdentityMode, + hidesPersonalInfo: Self.hidePersonalInfoFromDefaults()) + let snapshot: CLIServeConfigSnapshot + let operationKey: String + let detail: DashboardSnapshotDetail + let providers: [UsageProvider]? + do { + snapshot = try Self.loadServeConfigSnapshot(configStore: runtime.configStore) + operationKey = try Self.serveOperationKey( + kind: "dashboard-\(identityMode.rawValue)", + provider: provider) + detail = try Self.dashboardSnapshotDetail(rawDetail) + providers = try Self.dashboardSnapshotProviders(provider) + } catch { + let status: CLIHTTPStatus = error is CLIServeArgumentError ? .badRequest : .internalServerError + return Self.addingNoStore(Self.serveError(status: status, message: error.localizedDescription)) } + if detail == .shell { + return Self.addingNoStore(Self.serveDashboardShell( + config: snapshot.config, + providers: providers, + runtime: runtime)) + } + return await Self.addingNoStore(Self.cachedServeResponse( + request: ServeResponseRequest( + key: operationKey, + configFingerprint: snapshot.cacheToken, + refreshInterval: runtime.refreshInterval, + deadline: requestDeadline, + allowsStaleWhileRevalidate: true), + cache: runtime.cache, + makeResponse: { + await Self.serveDashboardSnapshot( + context: DashboardSnapshotContext( + config: snapshot.config, + usage: ServeUsageContext( + config: snapshot.config, + configFingerprint: snapshot.cacheToken, + refreshInterval: runtime.refreshInterval, + providerTimeout: providerTimeout, + providerDeadline: providerDeadline, + providerOperations: runtime.providerOperations, + includeAllCodexAccounts: false), + costCollection: ServeCostCollectionContext( + configFingerprint: snapshot.cacheToken, + providerTimeout: providerTimeout, + requestDeadline: requestDeadline, + now: { ContinuousClock().now }, + providerOperations: runtime.costOperations), + costRefreshesPricingInBackground: Self.serveCostRefreshesPricingInBackground, + codexBarVersion: runtime.healthVersion), + identityMode: identityMode, + providers: providers) + })) } private static func dashboardSnapshotDetail(_ rawDetail: String?) throws -> DashboardSnapshotDetail { diff --git a/Tests/CodexBarTests/CLIServeDashboardIdentityTests.swift b/Tests/CodexBarTests/CLIServeDashboardIdentityTests.swift new file mode 100644 index 0000000000..1ecf093c67 --- /dev/null +++ b/Tests/CodexBarTests/CLIServeDashboardIdentityTests.swift @@ -0,0 +1,61 @@ +import Commander +import Foundation +import Testing +@testable import CodexBarCLI + +/// `codexbar serve` resolves dashboard identity per request: an explicit `--identity` pins the +/// mode, and an absent flag follows the app's "Hide personal information" setting. The resolved +/// mode also joins the cache key so a body cached before a toggle cannot be replayed after it. +struct CLIServeDashboardIdentityTests { + @Test + func `dashboard identity follows the app privacy setting without a flag`() { + #expect(CodexBarCLI.resolveDashboardIdentityMode( + configured: nil, + hidesPersonalInfo: true) == .redacted) + #expect(CodexBarCLI.resolveDashboardIdentityMode( + configured: nil, + hidesPersonalInfo: false) == .full) + } + + @Test + func `dashboard identity flag overrides the app privacy setting`() { + #expect(CodexBarCLI.resolveDashboardIdentityMode( + configured: .full, + hidesPersonalInfo: true) == .full) + #expect(CodexBarCLI.resolveDashboardIdentityMode( + configured: .redacted, + hidesPersonalInfo: false) == .redacted) + } + + @Test + func `dashboard identity flag presence separates an explicit full from an absent flag`() { + #expect(CodexBarCLI.dashboardIdentityFlagPresent(in: ParsedValues( + positional: [], + options: ["identity": ["full"]], + flags: []))) + #expect(!CodexBarCLI.dashboardIdentityFlagPresent(in: ParsedValues( + positional: [], + options: [:], + flags: []))) + } + + @Test + func `an absent identity flag still decodes to the full default`() { + #expect(CodexBarCLI.decodeDashboardIdentityMode(from: ParsedValues( + positional: [], + options: [:], + flags: [])) == .full) + } + + @Test + func `dashboard operation key separates identity modes`() throws { + let redacted = try CodexBarCLI.serveOperationKey( + kind: "dashboard-\(DashboardIdentityMode.redacted.rawValue)", + provider: nil) + let full = try CodexBarCLI.serveOperationKey( + kind: "dashboard-\(DashboardIdentityMode.full.rawValue)", + provider: nil) + + #expect(redacted != full) + } +} diff --git a/docs/cli.md b/docs/cli.md index c910a4ae49..eba7e71818 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -85,7 +85,7 @@ See `docs/configuration.md` for the schema. - `--output ` atomically writes the snapshot to a file (`0644`) instead of stdout — staged in the destination directory, fsync'd, then renamed over the target so readers never observe a partial document. The parent directory must already exist (it is not created), and stdout stays silent on success. - 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, a token-gated dashboard snapshot, and a built-in web UI at `/`. - - Dashboard snapshot identity defaults to full account emails; use `--identity redacted` to hide email local parts, especially when responses cross a network. + - Dashboard snapshot identity follows the app's "Hide personal information" setting when `--identity` is absent: the toggle on redacts email local parts, off keeps full emails. The setting is read per request, so a change applies without a serve restart. Pass `--identity redacted` or `--identity full` to pin the mode and ignore the app setting, especially when responses cross a network. - `--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. diff --git a/docs/dashboard-api.md b/docs/dashboard-api.md index bdc7283f76..42f72d3f11 100644 --- a/docs/dashboard-api.md +++ b/docs/dashboard-api.md @@ -42,6 +42,8 @@ The UI does not change the transport threat model: `codexbar serve` is plain HTT ordering through each row's `display.sortKey`. - Identity defaults to full account emails. Pass `--identity redacted` to hide email local parts. Provider failures stay in their rows without discarding healthy rows. +- `codexbar serve` differs on one point: without `--identity` it follows the app's "Hide personal information" setting + per request, so the web UI matches the menu UI. `codexbar dashboard` always defaults to full identity. - A valid full, partial, empty, or all-error snapshot exits `0`. Command-wide setup or encoding failure writes a diagnostic to stderr and exits non-zero without writing a substitute document to stdout. - Stdout contains exactly one JSON document plus a trailing newline. `--pretty` changes formatting only; @@ -73,7 +75,7 @@ codexbar serve --dashboard-token YOUR_TOKEN 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, cost figures, and full account emails by default — cross the network in cleartext too. On non-loopback binds, use `--identity redacted` to hide email local parts unless clients need full identity. +- The response bodies — plan labels, usage percentages, cost figures, and account emails — cross the network in cleartext too. On non-loopback binds, pass `--identity redacted` to hide email local parts unless clients need full identity. Pin the flag rather than relying on the app's privacy setting, which a later GUI change can flip back. - 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: @@ -99,7 +101,7 @@ Deployments, from safest to least safe: CODEXBAR_DASHBOARD_TOKEN=... codexbar serve --host 0.0.0.0 --allow-plain-http --identity redacted ``` - A non-loopback `--host` refuses to start without a token, and refuses to start without `--allow-plain-http` — passing that flag is the explicit, operational acceptance that cleartext bearer transport is fine on this network. Full account emails are included in dashboard responses by default, so `--identity redacted` is recommended for these deployments. The token gates all data routes, and the server logs a one-line warning at startup. + A non-loopback `--host` refuses to start without a token, and refuses to start without `--allow-plain-http` — passing that flag is the explicit, operational acceptance that cleartext bearer transport is fine on this network. Account emails are included in dashboard responses unless redacted, and an absent flag follows a GUI setting that can change, so pin `--identity redacted` for these deployments. The token gates all data routes, and the server logs a one-line warning at startup. The server compares tokens in constant time (fixed-length SHA-256 digest comparison), so timing does not leak a matching prefix. That protects the comparison, not the transport: on plain HTTP the token is still readable in transit. @@ -137,7 +139,7 @@ After a fresh cache entry expires, `codexbar serve` may answer immediately with ## Payload -The snapshot is a stable display contract, not a raw dump of provider internals. Identity defaults to full account emails and plan labels. Pass `--identity redacted` to replace email local parts with `redacted` while keeping domains and plan labels. +The snapshot is a stable display contract, not a raw dump of provider internals. Identity defaults to full account emails and plan labels. Pass `--identity redacted` to replace email local parts with `redacted` while keeping domains and plan labels. On `codexbar serve` an absent `--identity` follows the app's "Hide personal information" setting instead of the full default. ```json {