@@ -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
0 commit comments