@@ -10,11 +10,11 @@ import {
1010 sanitizeEmail ,
1111 selectBestAccountCandidate ,
1212 shouldUpdateAccountIdFromToken ,
13+ type Workspace ,
1314} from "./accounts.js" ;
1415import {
1516 createAuthorizationFlow ,
1617 exchangeAuthorizationCode ,
17- parseAuthorizationInput ,
1818 redactOAuthUrlForLog ,
1919 REDIRECT_URI ,
2020} from "./auth/auth.js" ;
@@ -39,6 +39,15 @@ import {
3939} from "./codex-manager/commands/best.js" ;
4040import { runAccountCommand } from "./codex-manager/commands/account.js" ;
4141import { ACCOUNT_MANAGER_COMMANDS } from "./codex-manager/account-manager-commands.js" ;
42+ import {
43+ type AccountPoolWriteOutcome ,
44+ applyAccountPoolResults ,
45+ type ResolvedAccountWrite ,
46+ } from "./codex-manager/account-pool-write.js" ;
47+ import {
48+ classifyManualCallbackInput ,
49+ type ManualCallbackClassification ,
50+ } from "./codex-manager/manual-callback.js" ;
4251import { runBudgetCommand } from "./codex-manager/commands/budget.js" ;
4352import { runBridgeCommand } from "./codex-manager/commands/bridge.js" ;
4453import { runCheckCommand } from "./codex-manager/commands/check.js" ;
@@ -203,6 +212,7 @@ type TokenSuccessWithAccount = TokenSuccess & {
203212 accountIdOverride ?: string ;
204213 accountIdSource ?: AccountIdSource ;
205214 accountLabel ?: string ;
215+ workspaces ?: Workspace [ ] ;
206216} ;
207217type PromptTone = "accent" | "success" | "warning" | "danger" | "muted" ;
208218const log = createLogger ( "codex-manager" ) ;
@@ -1289,16 +1299,39 @@ function resolveAccountSelection(
12891299 tokens : TokenSuccess ,
12901300 orgOverride ?: string ,
12911301) : TokenSuccessWithAccount {
1302+ const candidates = getAccountIdCandidates ( tokens . access , tokens . idToken ) ;
1303+
1304+ // Surface every workspace/organization exposed by the token so the saved
1305+ // account can track them (issue #491/#512). Without this, same-email
1306+ // multi-workspace logins persisted rows with `workspaces: null` and
1307+ // `workspace <account>` was unusable. Built before the `--org` override
1308+ // branch so the explicit-binding flow persists workspaces too (#512).
1309+ const workspaces : Workspace [ ] | undefined =
1310+ candidates . length > 0
1311+ ? candidates . map ( ( candidate ) => ( {
1312+ id : candidate . accountId ,
1313+ name : candidate . label ,
1314+ enabled : true ,
1315+ isDefault : candidate . isDefault ,
1316+ } ) )
1317+ : undefined ;
1318+
12921319 const override = resolveOrgOverride ( orgOverride ) ;
12931320 if ( override ) {
1321+ // Prefer the token candidate's human label for the chosen org so the
1322+ // saved row is identifiable, falling back to a bare manual binding.
1323+ const matched = candidates . find (
1324+ ( candidate ) => candidate . accountId === override ,
1325+ ) ;
12941326 return {
12951327 ...tokens ,
12961328 accountIdOverride : override ,
12971329 accountIdSource : "manual" ,
1330+ accountLabel : matched ?. label ,
1331+ workspaces,
12981332 } ;
12991333 }
13001334
1301- const candidates = getAccountIdCandidates ( tokens . access , tokens . idToken ) ;
13021335 if ( candidates . length === 0 ) {
13031336 return tokens ;
13041337 }
@@ -1311,6 +1344,7 @@ function resolveAccountSelection(
13111344 accountIdOverride : candidate . accountId ,
13121345 accountIdSource : candidate . source ,
13131346 accountLabel : candidate . label ,
1347+ workspaces,
13141348 } ;
13151349 }
13161350 }
@@ -1325,6 +1359,7 @@ function resolveAccountSelection(
13251359 accountIdOverride : best . accountId ,
13261360 accountIdSource : best . source ?? "token" ,
13271361 accountLabel : best . label ,
1362+ workspaces,
13281363 } ;
13291364}
13301365
@@ -1380,13 +1415,20 @@ function applyTokenAccountIdentity(
13801415 return true ;
13811416}
13821417
1418+ /**
1419+ * Result of prompting for a manual OAuth callback URL. The classification lives
1420+ * in {@link classifyManualCallbackInput}; this alias keeps the prompt's return
1421+ * type tied to that single source of truth (issue #512 follow-up).
1422+ */
1423+ type ManualCallbackResult = ManualCallbackClassification ;
1424+
13831425async function promptManualCallback (
13841426 state : string ,
13851427 options : { allowNonTty ?: boolean } = { } ,
1386- ) : Promise < string | null > {
1428+ ) : Promise < ManualCallbackResult > {
13871429 const useInteractivePrompt = input . isTTY && output . isTTY ;
13881430 if ( ! useInteractivePrompt && ! options . allowNonTty ) {
1389- return null ;
1431+ return { type : "cancelled" } ;
13901432 }
13911433
13921434 const rl = createInterface ( { input, output } ) ;
@@ -1436,29 +1478,10 @@ async function promptManualCallback(
14361478 input . once ( "end" , handleInputClosed ) ;
14371479 input . once ( "close" , handleInputClosed ) ;
14381480 } ) ;
1439- if ( answer === null ) {
1440- return null ;
1441- }
1442- if ( answer . includes ( "\u001b" ) ) {
1443- return null ;
1444- }
1445- const normalized = answer . trim ( ) . toLowerCase ( ) ;
1446- if (
1447- normalized . length === 0 ||
1448- normalized === "q" ||
1449- normalized === "quit" ||
1450- normalized === "cancel" ||
1451- normalized === "back"
1452- ) {
1453- return null ;
1454- }
1455- const parsed = parseAuthorizationInput ( answer ) ;
1456- if ( ! parsed . code || ! parsed . state ) return null ;
1457- if ( parsed . state !== state ) return null ;
1458- return parsed . code ;
1481+ return classifyManualCallbackInput ( answer , state ) ;
14591482 } catch ( error ) {
14601483 if ( isAbortError ( error ) || isReadlineClosedError ( error ) ) {
1461- return null ;
1484+ return { type : "cancelled" } ;
14621485 }
14631486 throw error ;
14641487 } finally {
@@ -2007,9 +2030,28 @@ async function runOAuthFlow(
20072030 "warning" ,
20082031 ) ,
20092032 ) ;
2010- code = await promptManualCallback ( state , {
2033+ const manualResult = await promptManualCallback ( state , {
20112034 allowNonTty : signInMode === "manual" ,
20122035 } ) ;
2036+ // A parse/state failure must surface its own validation error instead
2037+ // of being reported as `Cancelled.` like a genuine user abort
2038+ // (issue #512 follow-up). Only an actual cancellation falls through to
2039+ // the cancelled path below.
2040+ if ( manualResult . type === "invalid" ) {
2041+ return {
2042+ type : "failed" ,
2043+ reason : "invalid_response" ,
2044+ message : UI_COPY . oauth . callbackInvalid ,
2045+ } ;
2046+ }
2047+ if ( manualResult . type === "state-mismatch" ) {
2048+ return {
2049+ type : "failed" ,
2050+ reason : "invalid_response" ,
2051+ message : UI_COPY . oauth . callbackStateMismatch ,
2052+ } ;
2053+ }
2054+ code = manualResult . type === "code" ? manualResult . code : null ;
20132055 }
20142056 } finally {
20152057 oauthServer ?. close ( ) ;
@@ -2046,19 +2088,20 @@ async function runSignInFlow(
20462088 return runOAuthFlow ( forceNewLogin , signInMode ) ;
20472089}
20482090
2091+ type PersistAccountPoolOutcome = AccountPoolWriteOutcome ;
2092+
20492093async function persistAccountPool (
20502094 results : TokenSuccessWithAccount [ ] ,
20512095 replaceAll : boolean ,
2052- ) : Promise < void > {
2053- if ( results . length === 0 ) return ;
2096+ ) : Promise < PersistAccountPoolOutcome | null > {
2097+ if ( results . length === 0 ) return null ;
20542098
2055- await withAccountStorageTransaction ( async ( loadedStorage , persist ) => {
2099+ return await withAccountStorageTransaction ( async ( loadedStorage , persist ) => {
20562100 const stored = replaceAll ? null : loadedStorage ;
20572101 const now = Date . now ( ) ;
2058- const accounts = stored ?. accounts ? [ ...stored . accounts ] : [ ] ;
2059- let selectedAccountIndex : number | null = null ;
2102+ const existing = stored ?. accounts ? [ ...stored . accounts ] : [ ] ;
20602103
2061- for ( const result of results ) {
2104+ const writes : ResolvedAccountWrite [ ] = results . map ( ( result ) => {
20622105 const tokenAccountId = extractAccountId ( result . access ) ;
20632106 const accountId = resolveRequestAccountId (
20642107 result . accountIdOverride ,
@@ -2069,85 +2112,41 @@ async function persistAccountPool(
20692112 ? ( result . accountIdSource ??
20702113 ( result . accountIdOverride ? "manual" : "token" ) )
20712114 : undefined ;
2072- const accountLabel = result . accountLabel ;
2073- const accountEmail = sanitizeEmail (
2074- extractAccountEmail ( result . access , result . idToken ) ,
2075- ) ;
2076- const existingIndex = findMatchingAccountIndex (
2077- accounts ,
2078- {
2079- accountId,
2080- email : accountEmail ,
2081- refreshToken : result . refresh ,
2082- } ,
2083- {
2084- allowUniqueAccountIdFallbackWithoutEmail : true ,
2085- } ,
2086- ) ;
2087-
2088- if ( existingIndex === undefined ) {
2089- const newIndex = accounts . length ;
2090- accounts . push ( {
2091- accountId,
2092- accountIdSource,
2093- accountLabel,
2094- email : accountEmail ,
2095- refreshToken : result . refresh ,
2096- accessToken : result . access ,
2097- expiresAt : result . expires ,
2098- enabled : true ,
2099- addedAt : now ,
2100- lastUsed : now ,
2101- } ) ;
2102- selectedAccountIndex = newIndex ;
2103- continue ;
2104- }
2105-
2106- const existing = accounts [ existingIndex ] ;
2107- if ( ! existing ) continue ;
2108-
2109- const nextEmail = accountEmail ?? existing . email ;
2110- const nextAccountId = accountId ?? existing . accountId ;
2111- const nextAccountIdSource = accountId
2112- ? ( accountIdSource ?? existing . accountIdSource )
2113- : existing . accountIdSource ;
2114-
2115- accounts [ existingIndex ] = {
2116- ...existing ,
2117- accountId : nextAccountId ,
2118- accountIdSource : nextAccountIdSource ,
2119- accountLabel : accountLabel ?? existing . accountLabel ,
2120- email : nextEmail ,
2115+ return {
2116+ accountId,
2117+ accountIdSource,
2118+ accountLabel : result . accountLabel ,
2119+ email : sanitizeEmail (
2120+ extractAccountEmail ( result . access , result . idToken ) ,
2121+ ) ,
21212122 refreshToken : result . refresh ,
21222123 accessToken : result . access ,
21232124 expiresAt : result . expires ,
2124- enabled : true ,
2125- lastUsed : now ,
2125+ workspaces : result . workspaces ,
2126+ now,
21262127 } ;
2127- selectedAccountIndex = existingIndex ;
2128- }
2128+ } ) ;
2129+
2130+ const { accounts, activeIndex, outcome } = applyAccountPoolResults ( {
2131+ existing,
2132+ writes,
2133+ priorActiveIndex : stored ?. activeIndex ,
2134+ findMatchingAccountIndex,
2135+ } ) ;
21292136
2130- const fallbackActiveIndex =
2131- accounts . length === 0
2132- ? 0
2133- : Math . max ( 0 , Math . min ( stored ?. activeIndex ?? 0 , accounts . length - 1 ) ) ;
2134- const nextActiveIndex =
2135- accounts . length === 0
2136- ? 0
2137- : selectedAccountIndex === null
2138- ? fallbackActiveIndex
2139- : Math . max ( 0 , Math . min ( selectedAccountIndex , accounts . length - 1 ) ) ;
21402137 const activeIndexByFamily : Partial < Record < ModelFamily , number > > = { } ;
21412138 for ( const family of MODEL_FAMILIES ) {
2142- activeIndexByFamily [ family ] = nextActiveIndex ;
2139+ activeIndexByFamily [ family ] = activeIndex ;
21432140 }
21442141
21452142 await persist ( {
21462143 version : 3 ,
21472144 accounts,
2148- activeIndex : nextActiveIndex ,
2145+ activeIndex,
21492146 activeIndexByFamily,
21502147 } ) ;
2148+
2149+ return outcome ;
21512150 } ) ;
21522151}
21532152
@@ -3226,15 +3225,24 @@ async function runAuthLoginFlow(
32263225 }
32273226
32283227 const resolved = resolveAccountSelection ( tokenResult , loginOptions . org ) ;
3229- await persistAccountPool ( [ resolved ] , false ) ;
3228+ const persistOutcome = await persistAccountPool ( [ resolved ] , false ) ;
32303229 await syncSelectionToCodex ( resolved ) ;
32313230
32323231 const latestStorage = await loadAccounts ( ) ;
32333232 const count = latestStorage ?. accounts . length ?? 1 ;
32343233 existingCount = count ;
32353234 namedBackups = [ ] ;
32363235 onboardingBackupDiscoveryWarning = null ;
3237- console . log ( `Added account. Total: ${ count } ` ) ;
3236+ // Only claim a new saved slot when one was actually appended. A
3237+ // same-email login that maps onto an existing entry updates or
3238+ // rebinds it instead of growing the pool (issue #512).
3239+ const outcomeMessage =
3240+ persistOutcome === "rebound"
3241+ ? `Rebound workspace for existing account. Total: ${ count } `
3242+ : persistOutcome === "updated"
3243+ ? `Updated existing account. Total: ${ count } `
3244+ : `Added account. Total: ${ count } ` ;
3245+ console . log ( outcomeMessage ) ;
32383246 console . log ( "Next steps:" ) ;
32393247 console . log ( " codex-multi-auth status Check that the wrapper is active." ) ;
32403248 console . log (
0 commit comments