Skip to content

Commit 72c6061

Browse files
Montercraft-agents-bot
andcommitted
Fix Codex workspace identity edge cases
Co-Authored-By: Craft Agent <agents-noreply@craft.do>
1 parent c160acc commit 72c6061

7 files changed

Lines changed: 171 additions & 9 deletions

File tree

Sources/CodexBar/Providers/Codex/CodexProviderRuntime.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ final class CodexProviderRuntime: ProviderRuntime {
99
let cookieSource: ProviderCookieSource
1010
let hasManualCookieHeader: Bool
1111
let hasTokenAccounts: Bool
12+
let selectedTokenAccountID: UUID?
1213

1314
var hasManualCredentials: Bool {
1415
self.hasManualCookieHeader || self.hasTokenAccounts
@@ -20,7 +21,8 @@ final class CodexProviderRuntime: ProviderRuntime {
2021
return Self(
2122
cookieSource: settings.codexCookieSource,
2223
hasManualCookieHeader: !manualHeader.isEmpty,
23-
hasTokenAccounts: !settings.tokenAccounts(for: .codex).isEmpty)
24+
hasTokenAccounts: !settings.tokenAccounts(for: .codex).isEmpty,
25+
selectedTokenAccountID: settings.selectedTokenAccount(for: .codex)?.id)
2426
}
2527
}
2628

Sources/CodexBarCore/OpenAIDashboardModels.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ extension OpenAIDashboardSnapshot {
118118
public func toUsageSnapshot(
119119
provider: UsageProvider = .codex,
120120
accountEmail: String? = nil,
121+
accountOrganization: String? = nil,
121122
accountPlan: String? = nil) -> UsageSnapshot?
122123
{
123124
guard let primaryLimit else { return nil }
@@ -126,7 +127,7 @@ extension OpenAIDashboardSnapshot {
126127
let identity = ProviderIdentitySnapshot(
127128
providerID: provider,
128129
accountEmail: resolvedEmail,
129-
accountOrganization: nil,
130+
accountOrganization: accountOrganization,
130131
loginMethod: resolvedPlan)
131132
return UsageSnapshot(
132133
primary: primaryLimit,

Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ public struct OpenAIDashboardBrowserCookieImporter {
9292
let log: (String) -> Void
9393
}
9494

95+
private struct TargetMatchContext {
96+
let targetEmail: String
97+
let targetWorkspaceLabel: String?
98+
let candidateLabel: String
99+
let log: (String) -> Void
100+
}
101+
95102
private static let cookieDomains = ["chatgpt.com", "openai.com"]
96103
private static let cookieClient = BrowserCookieClient()
97104
private static let cookieImportOrder: BrowserCookieImportOrder =
@@ -213,6 +220,7 @@ public struct OpenAIDashboardBrowserCookieImporter {
213220
switch await self.evaluateCandidate(
214221
candidate,
215222
targetEmail: normalizedTarget,
223+
targetWorkspaceLabel: targetWorkspaceLabel,
216224
allowAnyAccount: allowAnyAccount,
217225
log: log)
218226
{
@@ -421,6 +429,7 @@ public struct OpenAIDashboardBrowserCookieImporter {
421429
switch await self.evaluateCandidate(
422430
candidate,
423431
targetEmail: context.targetEmail,
432+
targetWorkspaceLabel: context.targetWorkspaceLabel,
424433
allowAnyAccount: context.allowAnyAccount,
425434
log: context.log)
426435
{
@@ -475,11 +484,17 @@ public struct OpenAIDashboardBrowserCookieImporter {
475484
private func evaluateCandidate(
476485
_ candidate: Candidate,
477486
targetEmail: String?,
487+
targetWorkspaceLabel: String?,
478488
allowAnyAccount: Bool,
479489
log: @escaping (String) -> Void) async -> CandidateEvaluation
480490
{
481491
log("Trying candidate \(candidate.label) (\(candidate.cookies.count) cookies)")
482492

493+
let resolvedWorkspaceLabel = self.resolveWorkspaceLabel(from: candidate.cookies)
494+
if let resolvedWorkspaceLabel, !resolvedWorkspaceLabel.isEmpty {
495+
log("Candidate \(candidate.label) workspace: \(resolvedWorkspaceLabel)")
496+
}
497+
483498
let apiEmail = await self.fetchSignedInEmailFromAPI(cookies: candidate.cookies, logger: log)
484499
if let apiEmail {
485500
log("Candidate \(candidate.label) API email: \(apiEmail)")
@@ -488,7 +503,15 @@ public struct OpenAIDashboardBrowserCookieImporter {
488503
// Prefer the API email when available (fast; avoids WebKit hydration/timeout risks).
489504
if let apiEmail, !apiEmail.isEmpty {
490505
if let targetEmail {
491-
if apiEmail.lowercased() == targetEmail.lowercased() {
506+
if self.matchesTarget(
507+
signedInEmail: apiEmail,
508+
candidateWorkspaceLabel: resolvedWorkspaceLabel,
509+
context: TargetMatchContext(
510+
targetEmail: targetEmail,
511+
targetWorkspaceLabel: targetWorkspaceLabel,
512+
candidateLabel: candidate.label,
513+
log: log))
514+
{
492515
return .match(candidate: candidate, signedInEmail: apiEmail)
493516
}
494517
return .mismatch(candidate: candidate, signedInEmail: apiEmail)
@@ -515,7 +538,15 @@ public struct OpenAIDashboardBrowserCookieImporter {
515538
let resolvedEmail = signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
516539
if let resolvedEmail, !resolvedEmail.isEmpty {
517540
if let targetEmail {
518-
if resolvedEmail.lowercased() == targetEmail.lowercased() {
541+
if self.matchesTarget(
542+
signedInEmail: resolvedEmail,
543+
candidateWorkspaceLabel: resolvedWorkspaceLabel,
544+
context: TargetMatchContext(
545+
targetEmail: targetEmail,
546+
targetWorkspaceLabel: targetWorkspaceLabel,
547+
candidateLabel: candidate.label,
548+
log: log))
549+
{
519550
return .match(candidate: candidate, signedInEmail: resolvedEmail)
520551
}
521552
return .mismatch(candidate: candidate, signedInEmail: resolvedEmail)
@@ -544,6 +575,81 @@ public struct OpenAIDashboardBrowserCookieImporter {
544575
return false
545576
}
546577

578+
private func matchesTarget(
579+
signedInEmail: String,
580+
candidateWorkspaceLabel: String?,
581+
context: TargetMatchContext) -> Bool
582+
{
583+
guard signedInEmail.lowercased() == context.targetEmail.lowercased() else { return false }
584+
585+
let normalizedTargetWorkspace = self.normalizeWorkspaceLabel(context.targetWorkspaceLabel)
586+
guard let normalizedTargetWorkspace else { return true }
587+
588+
let normalizedCandidateWorkspace = self.normalizeWorkspaceLabel(candidateWorkspaceLabel)
589+
guard let normalizedCandidateWorkspace else {
590+
context.log(
591+
"Candidate \(context.candidateLabel) matched email but workspace is unknown; " +
592+
"expected \(normalizedTargetWorkspace)")
593+
return false
594+
}
595+
596+
if normalizedCandidateWorkspace == normalizedTargetWorkspace {
597+
return true
598+
}
599+
600+
context.log(
601+
"Candidate \(context.candidateLabel) matched email but workspace mismatched " +
602+
"(candidate=\(normalizedCandidateWorkspace), target=\(normalizedTargetWorkspace))")
603+
return false
604+
}
605+
606+
private func normalizeWorkspaceLabel(_ label: String?) -> String? {
607+
let trimmed = label?.trimmingCharacters(in: .whitespacesAndNewlines)
608+
guard let trimmed, !trimmed.isEmpty else { return nil }
609+
return trimmed.lowercased()
610+
}
611+
612+
public func _normalizeWorkspaceLabelForTesting(_ label: String?) -> String? {
613+
self.normalizeWorkspaceLabel(label)
614+
}
615+
616+
private func resolveWorkspaceLabel(from cookies: [HTTPCookie]) -> String? {
617+
guard let accountID = cookies.first(where: { $0.name == "_account" })?.value,
618+
!accountID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
619+
let sessionCookie = cookies.first(where: { $0.name == "oai-client-auth-session" })?.value,
620+
let payload = self.decodeBase64URLJSONPayload(fromCookieValue: sessionCookie),
621+
let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
622+
let workspaces = json["workspaces"] as? [[String: Any]]
623+
else {
624+
return nil
625+
}
626+
627+
guard let workspace = workspaces.first(where: {
628+
($0["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) == accountID
629+
}) else {
630+
return nil
631+
}
632+
633+
let name = (workspace["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
634+
if let name, !name.isEmpty { return name }
635+
636+
let kind = (workspace["kind"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
637+
if kind == "personal" { return "Personal" }
638+
return nil
639+
}
640+
641+
private func decodeBase64URLJSONPayload(fromCookieValue value: String) -> Data? {
642+
let prefix = value.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false).first
643+
.map(String.init) ?? value
644+
guard !prefix.isEmpty else { return nil }
645+
var base64 = prefix.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
646+
let remainder = base64.count % 4
647+
if remainder != 0 {
648+
base64 += String(repeating: "=", count: 4 - remainder)
649+
}
650+
return Data(base64Encoded: base64)
651+
}
652+
547653
private func handleMismatch(
548654
candidate: Candidate,
549655
signedInEmail: String,
@@ -552,13 +658,13 @@ public struct OpenAIDashboardBrowserCookieImporter {
552658
{
553659
log("Candidate \(candidate.label) mismatch (\(signedInEmail)); continuing browser search")
554660
diagnostics.mismatches.append(FoundAccount(sourceLabel: candidate.label, email: signedInEmail))
555-
// Mismatch still means we found a valid signed-in session. Persist it keyed by its email so if
556-
// the user switches Codex accounts later, we can reuse this session immediately without another
557-
// Keychain prompt.
661+
// Mismatch still means we found a valid signed-in session. Persist it keyed by the
662+
// candidate's resolved email/workspace so later account switches can reuse the right
663+
// browser state without collapsing same-email workspaces together.
558664
await self.persistCookies(
559665
candidate: candidate,
560666
accountEmail: signedInEmail,
561-
workspaceLabel: nil,
667+
workspaceLabel: self.resolveWorkspaceLabel(from: candidate.cookies),
562668
logger: log)
563669
}
564670

Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,11 @@ extension CodexWebDashboardStrategy {
128128
logger.append(line)
129129
}
130130
let dashboard = try await Self.fetchOpenAIWebDashboard(request, logger: log)
131-
guard let usage = dashboard.toUsageSnapshot(provider: .codex, accountEmail: request.accountEmail) else {
131+
guard let usage = dashboard.toUsageSnapshot(
132+
provider: .codex,
133+
accountEmail: request.accountEmail,
134+
accountOrganization: request.workspaceLabel)
135+
else {
132136
throw OpenAIWebCodexError.missingUsage
133137
}
134138
let credits = dashboard.toCreditsSnapshot()

Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,11 @@ struct OpenAIDashboardBrowserCookieImporterTests {
1313
#expect(msg.contains("Safari=a@example.com"))
1414
#expect(msg.contains("Chrome=b@example.com"))
1515
}
16+
17+
@Test @MainActor
18+
func `normalize workspace label trims and lowercases`() {
19+
let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: BrowserDetection(cacheTTL: 0))
20+
#expect(importer._normalizeWorkspaceLabelForTesting(" Team Workspace ") == "team workspace")
21+
#expect(importer._normalizeWorkspaceLabelForTesting(nil) == nil)
22+
}
1623
}

Tests/CodexBarTests/OpenAIDashboardParserTests.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,29 @@ struct OpenAIDashboardParserTests {
156156
let snapshot = try decoder.decode(OpenAIDashboardSnapshot.self, from: Data(json.utf8))
157157
#expect(snapshot.usageBreakdown.isEmpty)
158158
}
159+
160+
@Test
161+
func `to usage snapshot preserves account organization override`() {
162+
let snapshot = OpenAIDashboardSnapshot(
163+
signedInEmail: "user@example.com",
164+
codeReviewRemainingPercent: nil,
165+
creditEvents: [],
166+
dailyBreakdown: [],
167+
usageBreakdown: [],
168+
creditsPurchaseURL: nil,
169+
primaryLimit: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
170+
secondaryLimit: nil,
171+
creditsRemaining: nil,
172+
accountPlan: "Plus",
173+
updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
174+
175+
let usage = snapshot.toUsageSnapshot(
176+
provider: .codex,
177+
accountEmail: "user@example.com",
178+
accountOrganization: "Team Workspace")
179+
180+
#expect(usage?.accountEmail(for: .codex) == "user@example.com")
181+
#expect(usage?.accountOrganization(for: .codex) == "Team Workspace")
182+
#expect(usage?.loginMethod(for: .codex) == "Plus")
183+
}
159184
}

Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,23 @@ struct TokenAccountEnvironmentPrecedenceTests {
9797
#expect(snapshot.manualCookieHeader == "Cookie: a=b")
9898
}
9999

100+
@Test
101+
func `codex token account selection preserves workspace label in app settings snapshot`() throws {
102+
let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-codex-workspace")
103+
settings.addTokenAccount(
104+
provider: .codex,
105+
label: "user@example.com — Team Workspace",
106+
token: "Cookie: a=b")
107+
108+
let account = try #require(settings.selectedTokenAccount(for: .codex))
109+
let snapshot = settings.codexSettingsSnapshot(tokenOverride: TokenAccountOverride(
110+
provider: .codex,
111+
account: account))
112+
113+
#expect(snapshot.accountEmail == "user@example.com")
114+
#expect(snapshot.workspaceLabel == "Team Workspace")
115+
}
116+
100117
@Test
101118
func `claude OAuth token account overrides environment in app environment builder`() {
102119
let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-claude-app")

0 commit comments

Comments
 (0)