Skip to content

Commit 57b2591

Browse files
authored
Merge PR #90: fix: preserve business accounts that share a workspace accountId
fix: preserve business accounts that share a workspace accountId
2 parents 6dc2521 + f71d7ec commit 57b2591

18 files changed

+3170
-569
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,9 @@ codex auth doctor --json
278278

279279
## Release Notes
280280

281-
- Current stable: [docs/releases/v0.1.5.md](docs/releases/v0.1.5.md)
282-
- Previous stable: [docs/releases/v0.1.4.md](docs/releases/v0.1.4.md)
283-
- Earlier stable: [docs/releases/v0.1.3.md](docs/releases/v0.1.3.md)
281+
- Current stable: [docs/releases/v0.1.9.md](docs/releases/v0.1.9.md)
282+
- Previous stable: [docs/releases/v0.1.8.md](docs/releases/v0.1.8.md)
283+
- Earlier stable: [docs/releases/v0.1.7.md](docs/releases/v0.1.7.md)
284284
- Archived prerelease: [docs/releases/v0.1.0-beta.0.md](docs/releases/v0.1.0-beta.0.md)
285285

286286
## License

docs/README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ Public documentation for `codex-multi-auth`.
2626
| [troubleshooting.md](troubleshooting.md) | Recovery playbooks for install, login, switching, and stale state |
2727
| [privacy.md](privacy.md) | Data handling and local storage behavior |
2828
| [upgrade.md](upgrade.md) | Migration from legacy package and path history |
29-
| [releases/v0.1.8.md](releases/v0.1.8.md) | Stable release notes |
30-
| [releases/v0.1.7.md](releases/v0.1.7.md) | Previous stable release notes |
31-
| [releases/v0.1.6.md](releases/v0.1.6.md) | Earlier stable release notes |
29+
| [releases/v0.1.9.md](releases/v0.1.9.md) | Stable release notes |
30+
| [releases/v0.1.8.md](releases/v0.1.8.md) | Previous stable release notes |
31+
| [releases/v0.1.7.md](releases/v0.1.7.md) | Earlier stable release notes |
32+
| [releases/v0.1.6.md](releases/v0.1.6.md) | Archived stable release notes |
3233
| [releases/v0.1.5.md](releases/v0.1.5.md) | Archived stable release notes |
3334
| [releases/v0.1.0-beta.0.md](releases/v0.1.0-beta.0.md) | Archived prerelease notes |
3435

@@ -43,7 +44,7 @@ Public documentation for `codex-multi-auth`.
4344
| [reference/storage-paths.md](reference/storage-paths.md) | Canonical and compatibility storage paths |
4445
| [reference/public-api.md](reference/public-api.md) | Public API stability and semver contract |
4546
| [reference/error-contracts.md](reference/error-contracts.md) | CLI, JSON, and helper error semantics |
46-
| [releases/v0.1.8.md](releases/v0.1.8.md) | Current stable release notes |
47+
| [releases/v0.1.9.md](releases/v0.1.9.md) | Current stable release notes |
4748
| [releases/v0.1.0-beta.0.md](releases/v0.1.0-beta.0.md) | Archived prerelease reference |
4849
| [User Guides release notes](#user-guides) | Stable, previous, and archived release notes |
4950
| [releases/legacy-pre-0.1-history.md](releases/legacy-pre-0.1-history.md) | Archived pre-0.1 changelog history |

docs/releases/v0.1.9.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Release v0.1.9
2+
3+
Release date: 2026-03-13
4+
Channel: `latest`
5+
6+
## Highlights
7+
8+
- Preserved distinct business accounts that share a workspace `accountId` so no-email login, refresh, and restore paths stop overwriting sibling seats.
9+
- Aligned guarded identity matching across runtime login, CLI recovery, storage normalization, import preview/apply, and entitlement tracking.
10+
- Hardened rollback and regression coverage for concurrent persistence, flagged-account recovery, malformed-token rows, and shared-workspace edge cases.
11+
12+
## Install
13+
14+
```bash
15+
npm i -g @openai/codex
16+
npm i -g codex-multi-auth
17+
```
18+
19+
## Core Operations
20+
21+
```bash
22+
codex auth login
23+
codex auth list
24+
codex auth status
25+
codex auth check
26+
codex auth forecast --live
27+
```
28+
29+
## Validation Snapshot
30+
31+
Release gate commands:
32+
33+
- `npm run clean:repo:check`
34+
- `npm run audit:ci`
35+
- `npm run lint`
36+
- `npm test -- test/documentation.test.ts`
37+
- `npm test -- test/accounts.test.ts test/oc-chatgpt-import-adapter.test.ts test/index.test.ts test/storage.test.ts test/codex-manager-cli.test.ts test/entitlement-cache.test.ts`
38+
39+
Broad validation result:
40+
41+
- `repo-hygiene check passed`
42+
- `npm run audit:ci` passed at the configured high-severity threshold; the remaining `hono` advisory stayed below that gate
43+
- `npm run lint` passed
44+
- `19/19` documentation integrity tests passed after promoting the new stable release notes
45+
- `6/6` targeted shared-account regression suites passed (`405/405` tests)
46+
47+
Known baseline blockers observed during release validation:
48+
49+
- `npm run typecheck` fails on both `origin/main` and this release branch because the workspace cannot currently resolve `@codex-ai/plugin/tool` and already carries unrelated TypeScript errors outside `#90`
50+
- `npm run build` remains blocked by the same pre-existing typecheck baseline
51+
52+
## Merged PRs
53+
54+
- `#90` `fix: preserve business accounts that share a workspace accountId`
55+
56+
## Commits
57+
58+
- PR `#90` carries the guarded account-identity matching fixes and regression coverage that preserve shared-workspace business accounts across login, import, flagged recovery, storage normalization, and entitlement tracking.
59+
- The release bump in this branch promotes `0.1.9` in package metadata and refreshes the stable release-note links in the root docs surfaces.
60+
61+
## Notes
62+
63+
- Bare `accountId` fallback now only applies when the no-email case is unambiguous.
64+
- Entitlement cache identity now prefers the fresh email resolved from the latest token material and avoids refresh-token-derived keys.
65+
- CLI and runtime persistence paths now share the same guarded account matching behavior for shared-workspace business accounts.
66+
67+
## Related
68+
69+
- [../getting-started.md](../getting-started.md)
70+
- [../upgrade.md](../upgrade.md)
71+
- [../reference/commands.md](../reference/commands.md)

index.ts

Lines changed: 47 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ import {
129129
loadFlaggedAccounts,
130130
saveFlaggedAccounts,
131131
clearFlaggedAccounts,
132-
normalizeEmailKey,
132+
findMatchingAccountIndex,
133133
StorageError,
134134
formatStorageErrorHint,
135135
setStorageBackupEnabled,
@@ -526,24 +526,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
526526
const stored = replaceAll ? null : loadedStorage;
527527
const accounts = stored?.accounts ? [...stored.accounts] : [];
528528

529-
const indexByRefreshToken = new Map<string, number>();
530-
const indexByAccountId = new Map<string, number>();
531-
const indexByEmail = new Map<string, number>();
532-
for (let i = 0; i < accounts.length; i += 1) {
533-
const account = accounts[i];
534-
if (!account) continue;
535-
if (account.refreshToken) {
536-
indexByRefreshToken.set(account.refreshToken, i);
537-
}
538-
if (account.accountId) {
539-
indexByAccountId.set(account.accountId, i);
540-
}
541-
const emailKey = normalizeEmailKey(account.email);
542-
if (emailKey) {
543-
indexByEmail.set(emailKey, i);
544-
}
545-
}
546-
547529
for (const result of results) {
548530
const accountId = result.accountIdOverride ?? extractAccountId(result.access);
549531
const accountIdSource =
@@ -553,19 +535,15 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
553535
: undefined;
554536
const accountLabel = result.accountLabel;
555537
const accountEmail = sanitizeEmail(extractAccountEmail(result.access, result.idToken));
556-
const existingByEmail =
557-
accountEmail && indexByEmail.has(accountEmail)
558-
? indexByEmail.get(accountEmail)
559-
: undefined;
560-
const existingById =
561-
accountId && indexByAccountId.has(accountId)
562-
? indexByAccountId.get(accountId)
563-
: undefined;
564-
const existingByToken = indexByRefreshToken.get(result.refresh);
565-
const existingIndex = existingById ?? existingByEmail ?? existingByToken;
538+
const existingIndex = findMatchingAccountIndex(accounts, {
539+
accountId,
540+
email: accountEmail,
541+
refreshToken: result.refresh,
542+
}, {
543+
allowUniqueAccountIdFallbackWithoutEmail: true,
544+
});
566545

567546
if (existingIndex === undefined) {
568-
const newIndex = accounts.length;
569547
accounts.push({
570548
accountId,
571549
accountIdSource,
@@ -577,21 +555,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
577555
addedAt: now,
578556
lastUsed: now,
579557
});
580-
indexByRefreshToken.set(result.refresh, newIndex);
581-
if (accountId) {
582-
indexByAccountId.set(accountId, newIndex);
583-
}
584-
if (accountEmail) {
585-
indexByEmail.set(accountEmail, newIndex);
586-
}
587558
continue;
588559
}
589560

590561
const existing = accounts[existingIndex];
591562
if (!existing) continue;
592563

593-
const oldToken = existing.refreshToken;
594-
const oldEmail = existing.email;
595564
const nextEmail = accountEmail ?? sanitizeEmail(existing.email);
596565
const nextAccountId = accountId ?? existing.accountId;
597566
const nextAccountIdSource =
@@ -608,21 +577,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
608577
expiresAt: result.expires,
609578
lastUsed: now,
610579
};
611-
if (oldToken !== result.refresh) {
612-
indexByRefreshToken.delete(oldToken);
613-
indexByRefreshToken.set(result.refresh, existingIndex);
614-
}
615-
if (accountId) {
616-
indexByAccountId.set(accountId, existingIndex);
617-
}
618-
const oldEmailKey = normalizeEmailKey(oldEmail);
619-
const nextEmailKey = normalizeEmailKey(nextEmail);
620-
if (oldEmailKey && oldEmailKey !== nextEmailKey) {
621-
indexByEmail.delete(oldEmailKey);
622-
}
623-
if (nextEmailKey) {
624-
indexByEmail.set(nextEmailKey, existingIndex);
625-
}
626580
}
627581

628582
if (accounts.length === 0) return;
@@ -1407,6 +1361,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
14071361
const accountCount = accountManager.getAccountCount();
14081362
const attempted = new Set<number>();
14091363
let restartAccountTraversalWithFallback = false;
1364+
let retryNextAccountBeforeFallback = false;
14101365
let usedPreferredSessionAccount = false;
14111366
const capabilityBoostByAccount: Record<number, number> = {};
14121367
type AccountSnapshotCandidate = {
@@ -1547,11 +1502,6 @@ while (attempted.size < Math.max(1, accountCount)) {
15471502
account.accountIdSource,
15481503
tokenAccountId,
15491504
);
1550-
const entitlementAccountKey = resolveEntitlementAccountKey({
1551-
accountId: hadAccountId ? account.accountId : undefined,
1552-
email: account.email,
1553-
index: account.index,
1554-
});
15551505
if (!accountId) {
15561506
accountManager.markAccountCoolingDown(
15571507
account,
@@ -1561,12 +1511,19 @@ while (attempted.size < Math.max(1, accountCount)) {
15611511
accountManager.saveToDiskDebounced();
15621512
continue;
15631513
}
1514+
const resolvedEmail =
1515+
extractAccountEmail(accountAuth.access) ?? account.email;
1516+
const entitlementAccountKey = resolveEntitlementAccountKey({
1517+
accountId: account.accountId ?? accountId,
1518+
email: resolvedEmail,
1519+
refreshToken: account.refreshToken,
1520+
index: account.index,
1521+
});
15641522
account.accountId = accountId;
15651523
if (!hadAccountId && tokenAccountId && accountId === tokenAccountId) {
15661524
account.accountIdSource = account.accountIdSource ?? "token";
15671525
}
1568-
account.email =
1569-
extractAccountEmail(accountAuth.access) ?? account.email;
1526+
account.email = resolvedEmail;
15701527
const entitlementBlock = entitlementCache.isBlocked(
15711528
entitlementAccountKey,
15721529
model ?? modelFamily,
@@ -1817,6 +1774,7 @@ while (attempted.size < Math.max(1, accountCount)) {
18171774
fallbackReason: "unsupported-model-entitlement",
18181775
},
18191776
);
1777+
retryNextAccountBeforeFallback = true;
18201778
break;
18211779
}
18221780

@@ -2338,7 +2296,10 @@ while (attempted.size < Math.max(1, accountCount)) {
23382296
if (successAccountForResponse.index !== account.index) {
23392297
accountManager.markSwitched(successAccountForResponse, "rotation", modelFamily);
23402298
}
2341-
const successAccountKey = resolveEntitlementAccountKey(successAccountForResponse);
2299+
const successAccountKey =
2300+
successAccountForResponse.index === account.index
2301+
? entitlementAccountKey
2302+
: resolveEntitlementAccountKey(successAccountForResponse);
23422303
accountManager.recordSuccess(successAccountForResponse, modelFamily, model);
23432304
capabilityPolicyStore.recordSuccess(
23442305
successAccountKey,
@@ -2357,6 +2318,11 @@ while (attempted.size < Math.max(1, accountCount)) {
23572318
}
23582319
return successResponse;
23592320
}
2321+
if (retryNextAccountBeforeFallback) {
2322+
retryNextAccountBeforeFallback = false;
2323+
continue;
2324+
}
2325+
23602326
if (restartAccountTraversalWithFallback) {
23612327
break;
23622328
}
@@ -3263,12 +3229,25 @@ while (attempted.size < Math.max(1, accountCount)) {
32633229
const label = resolved.accountLabel ?? email ?? accountId ?? "Unknown account";
32643230
logInfo(`Authenticated as: ${label}`);
32653231

3266-
const isDuplicate = accounts.some(
3267-
(account) =>
3268-
(accountId &&
3269-
(account.accountIdOverride ?? extractAccountId(account.access)) === accountId) ||
3270-
(email && extractAccountEmail(account.access, account.idToken) === email),
3271-
);
3232+
const isDuplicate =
3233+
findMatchingAccountIndex(
3234+
accounts.map((account) => ({
3235+
accountId:
3236+
account.accountIdOverride ?? extractAccountId(account.access),
3237+
email: sanitizeEmail(
3238+
extractAccountEmail(account.access, account.idToken),
3239+
),
3240+
refreshToken: account.refresh,
3241+
})),
3242+
{
3243+
accountId,
3244+
email: sanitizeEmail(email),
3245+
refreshToken: resolved.refresh,
3246+
},
3247+
{
3248+
allowUniqueAccountIdFallbackWithoutEmail: true,
3249+
},
3250+
) !== undefined;
32723251

32733252
if (isDuplicate) {
32743253
logWarn(`WARNING: duplicate account login detected (${label}). Existing entry will be updated.`);
@@ -4163,5 +4142,3 @@ while (attempted.size < Math.max(1, accountCount)) {
41634142
export const OpenAIAuthPlugin = OpenAIOAuthPlugin;
41644143

41654144
export default OpenAIOAuthPlugin;
4166-
4167-

0 commit comments

Comments
 (0)