Add multi-account token support for Codex and API-key providers#461
Add multi-account token support for Codex and API-key providers#461Minoo7 wants to merge 14 commits intosteipete:mainfrom
Conversation
| if self.settings.codexCookieSource == .manual, | ||
| !self.settings.tokenAccounts(for: .codex).isEmpty | ||
| { | ||
| return nil |
There was a problem hiding this comment.
Could this targetEmail becoming nil in manual token-account mode interact with the account-switch path in a way that keeps stale dashboard state after switching accounts?
| !previous.isEmpty, | ||
| previous != normalized | ||
| { | ||
| let emailChanged = if let previous, !previous.isEmpty, let normalizedOrNil { |
There was a problem hiding this comment.
Should transitions like previous email -> nil or previous tokenAccountID nil -> non-nil be treated as account changes here, or is there a reason we want to keep existing dashboard state in that first switch case?
| private static let headerPatterns: [String] = [ | ||
| #"(?i)-H\s*'Cookie:\s*([^']+)'"#, | ||
| #"(?i)-H\s*\"Cookie:\s*([^\"]+)\""#, | ||
| #"(?i)(?:^|\s)-H\s+'Cookie:\s*([^']+)'"#, |
There was a problem hiding this comment.
Are we intentionally requiring whitespace after -H now, or should compact forms like -H'Cookie: ...' still be accepted?
| #"(?i)(?:--cookie|-b)\s*'([^']+)'"#, | ||
| #"(?i)(?:--cookie|-b)\s*\"([^\"]+)\""#, | ||
| #"(?i)(?:--cookie|-b)\s*([^\s]+)"#, | ||
| #"(?i)(?:^|\s)(?:--cookie|-b)\s+'([^']+)'"#, |
There was a problem hiding this comment.
For --cookie / -b, do we still want to accept compact input like -b'...', or should it only match when there is whitespace?
There was a problem hiding this comment.
Pull request overview
This PR extends CodexBar’s “token accounts” feature to support multi-account workflows for Codex (cookie/manual/OAuth) and several API-key providers, while improving UI/CLI flows for adding, selecting, and validating accounts.
Changes:
- Add token-account support metadata for Codex plus additional API-key providers (Copilot, Kimi K2, Synthetic, Warp, OpenRouter) and ensure token-account selection overrides configured API keys in app/CLI environments.
- Expand Codex manual-cookie and OAuth handling: settings visibility when cookie source is manual, actions to test/save cookies, and improved cookie import status surfacing.
- Add a new CLI subcommand to append token accounts to the config, plus substantial regression test coverage.
Reviewed changes
Copilot reviewed 44 out of 45 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift | Adds coverage for env precedence across more providers and Codex catalog/CLI snapshot behaviors. |
| Tests/CodexBarTests/StatusMenuTests.swift | Adds tests for token-account switching behavior and snapshot preference rules in the status menu. |
| Tests/CodexBarTests/ProviderVersionDetectorTests.swift | Adds tests for version detector output parsing and timeout behavior. |
| Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift | Tests Codex manual-cookie actions (test/save) and token-account visibility rules. |
| Tests/CodexBarTests/OpenAIWebAccountSwitchTests.swift | Tests Codex token-account changes don’t force OpenAI dashboard target email and clear stale dashboard state. |
| Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift | Adds coverage for manual-header session-cookie detection and distinct error messaging. |
| Tests/CodexBarTests/CookieHeaderNormalizerTests.swift | Tests normalization/pair parsing edge cases for cookie headers. |
| Tests/CodexBarTests/CodexOAuthTests.swift | Adds tests for loading OAuth creds from JSON/path and resolving JWT claims. |
| Tests/CodexBarTests/CodexbarTests.swift | Adds tests for Codex OAuth account directory creation and OAuth source-mode preference. |
| Tests/CodexBarTests/CLIWebFallbackTests.swift | Tests Codex web import input selection and app auto-strategy ordering with/without manual cookies. |
| Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift | Extends token-account support catalog for Codex + additional API-key providers. |
| Sources/CodexBarCore/Providers/ProviderVersionDetector.swift | Adds test hook and ensures process exit is awaited before reading output. |
| Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift | Adds Codex oauthCredentialSource to snapshots. |
| Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift | Introduces manual-vs-browser cookie import input and direct manual-cookie usage fetch path. |
| Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift | Adjusts Codex fetch pipeline ordering based on manual cookie source; adds shared response mapper. |
| Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift | Adds manual header parsing for cookie/bearer extraction and usage fetch support. |
| Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift | Adds load/save support for raw JSON or alternate auth file locations. |
| Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthClaimResolver.swift | Adds centralized JWT claim resolution for account/email/plan. |
| Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift | Adds manual cookie validation and captures cookieHeader; improves import candidate selection and Chromium support. |
| Sources/CodexBarCore/CookieHeaderNormalizer.swift | Tightens parsing patterns to avoid accidental flag matches within cookie values. |
| Sources/CodexBarCLI/TokenAccountCLI.swift | Adds Codex OAuth detection and snapshot shaping for CLI token-account selection. |
| Sources/CodexBarCLI/CLIOptions.swift | Adds CLI options for config add-account. |
| Sources/CodexBarCLI/CLIHelp.swift | Updates CLI help text to include the new config subcommand. |
| Sources/CodexBarCLI/CLIEntry.swift | Wires up the new config add-account command. |
| Sources/CodexBarCLI/CLIConfigCommand.swift | Implements config token-account append logic and structured output. |
| Sources/CodexBar/UsageStore+TokenAccounts.swift | Adds supplementary per-account data (credits/dashboard), prioritization, and Codex-specific repair flow. |
| Sources/CodexBar/UsageStore+Refresh.swift | Routes refresh into token-account refresh path whenever accounts exist. |
| Sources/CodexBar/UsageStore.swift | Improves dashboard state invalidation on token-account changes and adds cookie test helper. |
| Sources/CodexBar/StatusItemController+Menu.swift | Improves menu snapshot selection for token accounts and primes token-account snapshots when needed. |
| Sources/CodexBar/StatusItemController+Actions.swift | Updates Codex login error messaging. |
| Sources/CodexBar/SettingsStore+TokenAccounts.swift | Adds updateTokenAccount helper for updating stored token/label. |
| Sources/CodexBar/SettingsStore.swift | Adjusts defaults initialization behavior and keychain gate wiring. |
| Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift | Adds optional “add account via login” hook to token-account descriptors. |
| Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift | Adds OAuth credential source inference into Codex settings snapshots. |
| Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift | Adds Codex token-account visibility rules and manual-cookie actions (test/save). |
| Sources/CodexBar/Providers/Codex/CodexLoginFlow.swift | Updates login flow to report phases while running. |
| Sources/CodexBar/PreferencesProvidersPane+Testing.swift | Updates test harness for new descriptor field. |
| Sources/CodexBar/PreferencesProvidersPane.swift | Adds “Add with browser” login-based account creation for Codex. |
| Sources/CodexBar/PreferencesProviderSettingsRows.swift | Adds UI for login-based account creation and disables UI during in-flight login. |
| Sources/CodexBar/MenuHighlightStyle.swift | Refactors menu highlight environment key implementation. |
| Sources/CodexBar/CodexOAuthAccountStore.swift | Adds filesystem helpers for per-account OAuth profile directories. |
| Sources/CodexBar/CodexLoginRunner.swift | Replaces CLI-based login with native OAuth via AppAuth and local redirect handler. |
| Scripts/package_app.sh | Adjusts code signing args for dev identity without hardened runtime. |
| Package.swift | Adds AppAuth dependency to the macOS app target. |
| Package.resolved | Pins AppAuth-iOS dependency. |
Comments suppressed due to low confidence (1)
Sources/CodexBar/UsageStore+Refresh.swift:44
refreshProvidernow refreshes token accounts whenever any exist, ignoringshowAllTokenAccountsInMenu/shouldFetchAllTokenAccounts. This means periodic refreshes can fetch multiple accounts (up to the limit) even when the UI is configured to show only the switcher bar, which can add unnecessary network/keychain/process work and slow refreshes. Consider restoring the previous gating (only fetch all whenshowAllTokenAccountsInMenuis enabled) and otherwise fetch just the selected account, or make the behavior conditional on whether the menu is open / provider is visible.
let tokenAccounts = self.tokenAccounts(for: provider)
if !tokenAccounts.isEmpty {
await self.refreshTokenAccounts(provider: provider, accounts: tokenAccounts)
return
} else {
_ = await MainActor.run {
self.accountSnapshots.removeValue(forKey: provider)
}
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
| group.addTask { | ||
| let nanos = UInt64(max(0, timeout) * 1_000_000_000) | ||
| try? await Task.sleep(nanoseconds: nanos) |
There was a problem hiding this comment.
The timeout task uses try? await Task.sleep(...) and then unconditionally calls coordinator.cancelForTimeout(). If the task gets cancelled (e.g., because the login task completed first and group.cancelAll() runs), Task.sleep throws CancellationError which is swallowed by try?, so this task will still run cancelForTimeout() and may cancel the HTTP listener / finish the coordinator after a successful completion. Handle cancellation explicitly (e.g., do/try/catch around Task.sleep and return early on cancellation) so cancelForTimeout() only runs when the sleep actually completes without cancellation.
| try? await Task.sleep(nanoseconds: nanos) | |
| do { | |
| try await Task.sleep(nanoseconds: nanos) | |
| } catch is CancellationError { | |
| // The timeout task was cancelled (e.g., because login completed first). | |
| // Return a benign result without touching the coordinator. | |
| return Result(outcome: .timedOut, output: "Timeout task cancelled.") | |
| } catch { | |
| // Any other unexpected error from Task.sleep; also avoid coordinator side-effects. | |
| return Result(outcome: .failed(status: 1), output: "Timeout task failed: \(error)") | |
| } |
| public static func load(rawSource: String) throws -> CodexOAuthCredentials { | ||
| let trimmed = rawSource.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| guard !trimmed.isEmpty else { | ||
| throw CodexOAuthCredentialsError.notFound | ||
| } | ||
|
|
||
| if let authURL = self.authFileURL(forRawSource: trimmed) { | ||
| let data = try Data(contentsOf: authURL) | ||
| return try self.parse(data: data) | ||
| } | ||
|
|
||
| return try self.parse(data: Data(trimmed.utf8)) |
There was a problem hiding this comment.
authFileURL(forRawSource:) returns nil unless the path already exists. As a result, load(rawSource:) will attempt to parse a non-existent file path string as JSON and throw decodeFailed("Invalid JSON") instead of a more accurate .notFound. This also prevents saving refreshed credentials back to a user-supplied file path that doesn't exist yet. Consider detecting JSON vs path by shape (e.g., starts with “{” / contains newlines), otherwise treat the value as a path (tilde-expanded) and throw .notFound if it can’t be read; and allow save(..., rawSource:) to create the file if needed.
| public static func load(rawSource: String) throws -> CodexOAuthCredentials { | |
| let trimmed = rawSource.trimmingCharacters(in: .whitespacesAndNewlines) | |
| guard !trimmed.isEmpty else { | |
| throw CodexOAuthCredentialsError.notFound | |
| } | |
| if let authURL = self.authFileURL(forRawSource: trimmed) { | |
| let data = try Data(contentsOf: authURL) | |
| return try self.parse(data: data) | |
| } | |
| return try self.parse(data: Data(trimmed.utf8)) | |
| /// Heuristically determine whether the given raw string is likely JSON content | |
| /// rather than a file path. | |
| private static func looksLikeJSON(_ raw: String) -> Bool { | |
| // Ignore leading whitespace when checking the first significant character. | |
| let trimmedLeading = raw.drop { $0.isWhitespace } | |
| guard let first = trimmedLeading.first else { | |
| return false | |
| } | |
| if first == "{" || first == "[" || first == "\"" { | |
| return true | |
| } | |
| // Multiline or structured content is more likely to be JSON than a file path. | |
| if raw.contains("\n") || raw.contains("\r") { | |
| return true | |
| } | |
| return false | |
| } | |
| public static func load(rawSource: String) throws -> CodexOAuthCredentials { | |
| let trimmed = rawSource.trimmingCharacters(in: .whitespacesAndNewlines) | |
| guard !trimmed.isEmpty else { | |
| throw CodexOAuthCredentialsError.notFound | |
| } | |
| // If the input looks like JSON, parse it directly as JSON. | |
| if self.looksLikeJSON(trimmed) { | |
| return try self.parse(data: Data(trimmed.utf8)) | |
| } | |
| // Otherwise, treat it as a path: first try existing authFileURL logic. | |
| if let authURL = self.authFileURL(forRawSource: trimmed) { | |
| let data = try Data(contentsOf: authURL) | |
| return try self.parse(data: data) | |
| } | |
| // Fall back to interpreting the raw source as a plain file path (tilde-expanded). | |
| let expandedPath = (trimmed as NSString).expandingTildeInPath | |
| let url = URL(fileURLWithPath: expandedPath) | |
| guard FileManager.default.fileExists(atPath: url.path) else { | |
| throw CodexOAuthCredentialsError.notFound | |
| } | |
| let data = try Data(contentsOf: url) | |
| return try self.parse(data: data) |
|
|
||
| private static func decodeSingleProvider(from values: ParsedValues) -> UsageProvider? { | ||
| guard let raw = values.options["provider"]?.last else { return nil } | ||
| guard case let .single(provider) = ProviderSelection(argument: raw) else { return nil } |
There was a problem hiding this comment.
ProviderSelection(argument:) is a failable initializer, so it returns ProviderSelection?. The pattern match case let .single(provider) = ProviderSelection(argument: raw) won’t compile because it’s matching against an optional. Unwrap first (e.g., guard let selection = ProviderSelection(argument: raw), case let .single(provider) = selection else { ... }).
| guard case let .single(provider) = ProviderSelection(argument: raw) else { return nil } | |
| guard let selection = ProviderSelection(argument: raw), | |
| case let .single(provider) = selection else { return nil } |
Summary
Verification