-
Notifications
You must be signed in to change notification settings - Fork 823
Add multi-account support for Codex via OAuth token accounts #509
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -219,7 +219,27 @@ struct ProvidersPane: View { | |
| await self.store.refreshProvider(provider, allowDisabled: true) | ||
| } | ||
| } | ||
| }) | ||
| }, | ||
| importFromFile: provider == .codex ? { | ||
| guard let credentials = try? CodexOAuthCredentialsStore.load() else { return } | ||
| let email = Self.codexEmailFromCredentials(credentials) | ||
| let label = email ?? "Codex (\(DateFormatter.localizedString(from: Date(), dateStyle: .short, timeStyle: .none)))" | ||
| self.settings.addTokenAccount(provider: .codex, label: label, token: credentials.accessToken) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we double-check what auth behavior we want after import if we only store accessToken here? Where do refresh and account/workspace identity come from in that case?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I was wondering about that myself, but thinking if I manually add oauth tokens then I've really signed up to refresh them manually. But if there are ways to support refreshing them, then let's explore that as it would remove that burden from the user |
||
| Task { @MainActor in | ||
| await ProviderInteractionContext.$current.withValue(.userInitiated) { | ||
| await self.store.refreshProvider(.codex, allowDisabled: true) | ||
| } | ||
| } | ||
| } : nil) | ||
| } | ||
|
|
||
| private static func codexEmailFromCredentials(_ credentials: CodexOAuthCredentials) -> String? { | ||
| guard let idToken = credentials.idToken, | ||
| let payload = UsageFetcher.parseJWT(idToken) | ||
| else { return nil } | ||
| let profileDict = payload["https://api.openai.com/profile"] as? [String: Any] | ||
| let email = (payload["email"] as? String) ?? (profileDict?["email"] as? String) | ||
| return email?.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| } | ||
|
|
||
| private func makeSettingsContext(provider: UsageProvider) -> ProviderSettingsContext { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -191,6 +191,14 @@ struct CodexProviderImplementation: ProviderImplementation { | |
| } | ||
| } | ||
|
|
||
| @MainActor | ||
| func loginMenuAction(context: ProviderMenuLoginContext) | ||
| -> (label: String, action: MenuDescriptor.MenuAction)? | ||
| { | ||
| guard TokenAccountSupportCatalog.support(for: .codex) != nil else { return nil } | ||
| return ("Add Account...", .settingsProvider) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we walk through the first-time-user path here? If someone has no ~/.codex/auth.json yet, where in this flow do they actually run codex login?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great question :-) I was coming to this from "CodexBar works great, let me add another subscription to monitor" where that subscription was a second OpenAI subscription. I'm not sure I'd put that as part of a first-time onboarding flow. |
||
| } | ||
|
|
||
| @MainActor | ||
| func runLoginFlow(context: ProviderLoginContext) async -> Bool { | ||
| await context.controller.runCodexLoginFlow() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import Foundation | ||
|
|
||
| public struct CodexSettingsReader: Sendable { | ||
| public static let oauthAccessTokenKey = "CODEX_OAUTH_ACCESS_TOKEN" | ||
|
|
||
| public static func oauthAccessToken( | ||
| environment: [String: String] = ProcessInfo.processInfo.environment) -> String? | ||
| { | ||
| if let token = self.cleaned(environment[oauthAccessTokenKey]) { return token } | ||
| return nil | ||
| } | ||
|
|
||
| static func cleaned(_ raw: String?) -> String? { | ||
| guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { | ||
| return nil | ||
| } | ||
|
|
||
| if (value.hasPrefix("\"") && value.hasSuffix("\"")) || | ||
| (value.hasPrefix("'") && value.hasSuffix("'")) | ||
| { | ||
| value.removeFirst() | ||
| value.removeLast() | ||
| } | ||
|
|
||
| value = value.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| return value.isEmpty ? nil : value | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,13 @@ extension TokenAccountSupportCatalog { | |
| injection: .cookieHeader, | ||
| requiresManualCookieSource: true, | ||
| cookieName: "sessionKey"), | ||
| .codex: TokenAccountSupport( | ||
| title: "OAuth tokens", | ||
| subtitle: "Store Codex/OpenAI OAuth access tokens from auth.json.", | ||
| placeholder: "Paste access_token…", | ||
| injection: .environment(key: CodexSettingsReader.oauthAccessTokenKey), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In Usage source = Auto, how do we ensure the selected Codex token account cannot end up showing usage fetched from a fallback CLI account when this OAuth token is stale or invalid?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch, I expect that can happen now you point it out. Let me look into it and get back to you. |
||
| requiresManualCookieSource: false, | ||
| cookieName: nil), | ||
| .zai: TokenAccountSupport( | ||
| title: "API tokens", | ||
| subtitle: "Stored in the CodexBar config file.", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Calling
NSApp.setActivationPolicy(.regular)here permanently promotes this LSUIElement app to a regular app, and this commit never switches it back to.accessory; after opening Settings once, users can be left with a persistent Dock icon/app-switcher presence instead of menu-bar-only behavior. This side effect is triggered on the new settings-focus path and is likely unintended for a “no Dock icon” app.Useful? React with 👍 / 👎.