Skip to content

Commit d54d86f

Browse files
authored
fix(login): persist workspaces and surface manual-callback errors (#512) (#513)
Fixes #512. The CLI login path used local, lagging copies of the auth helpers that dropped workspace tracking and collapsed manual-callback validation errors into "Cancelled.". - resolveAccountSelection now surfaces token/org candidates as tracked workspaces (including the --org override path); persistAccountPool persists/merges them and reports inserted/updated/rebound so the CLI stops always printing "Added account". - classifyManualCallbackInput distinguishes code/cancelled/invalid/state-mismatch so login --manual surfaces real validation errors instead of "Cancelled.". - Logic extracted into pure, unit-tested helpers (applyAccountPoolResults, manual-callback classifier); full suite 4425 passing.
1 parent 2887c27 commit d54d86f

8 files changed

Lines changed: 1122 additions & 100 deletions

lib/codex-manager.ts

Lines changed: 107 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ import {
1010
sanitizeEmail,
1111
selectBestAccountCandidate,
1212
shouldUpdateAccountIdFromToken,
13+
type Workspace,
1314
} from "./accounts.js";
1415
import {
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";
4040
import { runAccountCommand } from "./codex-manager/commands/account.js";
4141
import { 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";
4251
import { runBudgetCommand } from "./codex-manager/commands/budget.js";
4352
import { runBridgeCommand } from "./codex-manager/commands/bridge.js";
4453
import { 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
};
207217
type PromptTone = "accent" | "success" | "warning" | "danger" | "muted";
208218
const 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+
13831425
async 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+
20492093
async 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

Comments
 (0)