Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions Sources/CodexBarCLI/CLIDashboardCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
16 changes: 16 additions & 0 deletions Sources/CodexBarCLI/CLIHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
160 changes: 99 additions & 61 deletions Sources/CodexBarCLI/CLIServeCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -143,7 +144,7 @@ struct ServeRuntime {
requestTimeout: TimeInterval,
healthVersion: String?,
dashboardAuth: CLIServeDashboardAuth,
dashboardIdentityMode: DashboardIdentityMode = .full,
dashboardIdentityMode: DashboardIdentityMode? = nil,
bindHost: String)
{
self.configStore = configStore
Expand Down Expand Up @@ -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
Comment on lines +712 to +714

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update serve help for the new identity default

When --identity is omitted, these lines now make serve follow the app preference, but both the option help near ServeOptions.identity and CLIHelp.serveHelp still tell users that identity defaults to full account emails. Consequently, someone consulting codexbar serve --help can miss that a GUI toggle changes the HTTP response and can break clients that depend on full email identities; document the preference-following default and the explicit full/redacted overrides in the executable help.

Useful? React with 👍 / 👎.

if let startupError = Self.validateServeStartup(
host: bindHost,
hasConfiguredBearer: dashboardBearer != nil,
Expand Down Expand Up @@ -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 {
Expand Down
61 changes: 61 additions & 0 deletions Tests/CodexBarTests/CLIServeDashboardIdentityTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ See `docs/configuration.md` for the schema.
- `--output <path>` 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 <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 <port>` defaults to `8080`.
- `--refresh-interval <seconds>` defaults to `60` and controls the in-memory response cache TTL.
Expand Down
Loading