Skip to content

Commit ff06bd5

Browse files
ndycodeneilclaude
authored
fix(storage): token-source duplicate must not disable the real org account on merge (#171) (#180)
* fix(storage): token-source duplicate must not disable the real org account on merge (#171) Closes the last #171 recovery gap, found by testing the REAL persisted path: when storage dedup collapses a disabled accountIdSource:"token" duplicate (a plugin re-login artifact) into the matching org-source account by email, the survivor inherited enabled:false. Combined with codex-doctor/codex-health skipping disabled accounts, a single-real-account pool could end up dark and UNRECOVERABLE — the user had no automatic path back. Root cause: lib/storage/identity.ts mergeAccountRecords had no `enabled` resolution — the flag fell through the `{...older, ...newest}` spread, and the disabled token-dup could be the newest record. Fix: add resolveMergedEnabled(target, source). When exactly one side is org-like (organizationId present OR accountIdSource==="org") and the other is a token-source duplicate, the org account's own enabled state governs. Every other shape stays fail-closed (either disabled => disabled), preserving the user-disable intent and the same-identity merge semantics from #64/#85. Added `enabled` to the permissive AccountLike type. Verified (architect-reviewed): identity.ts is the ONLY home for this cross-source bug — login-runner's mergeStoredAccountPair only merges exact-identity collisions (different key namespaces, can't hit the org<->token shape) and is backstopped by this same fixed dedup on every persist. Tests: - test/storage.test.ts: token-dup does not disable org account (fresh token carried); fail-closed preserved for user-disabled same-identity; two distinct real org accounts both stay enabled (no #64/#85 regression). - test/chaos/doctor-recovery-171-stress.test.ts: single-account + disabled dup recovers end-to-end through real saveAccounts/loadAccounts + recovery helpers. - Mutation teeth-check: reverting resolveMergedEnabled fails the merge test. Full suite green (2542 passed, 1 skipped), tsc + eslint + build clean, npm audit 0 vulnerabilities. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: route #171 composite managers through liveManagers (PR #180 review) Greptile P2: the new single-account composite describe created AccountManager instances directly and disposed them inline, so a mid-test assertion failure could leak a shutdown listener. Now uses the module-level makeManager() (which registers in liveManagers) and drains liveManagers in this describe's afterEach, matching the leak-safe pattern in the rest of the file. Full suite green (2542 passed, 1 skipped), tsc + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: neil <neil@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d14e956 commit ff06bd5

3 files changed

Lines changed: 188 additions & 0 deletions

File tree

lib/storage/identity.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type AccountLike = {
2323
refreshToken: string;
2424
addedAt?: number;
2525
lastUsed?: number;
26+
enabled?: boolean;
2627
};
2728

2829
const normalizeWorkspaceIdentityPart = (value: unknown): string | undefined =>
@@ -136,6 +137,37 @@ function pickNewestAccountIndex<T extends AccountLike>(
136137
return newest === candidate ? candidateIndex : existingIndex;
137138
}
138139

140+
function isOrgLikeAccount<T extends AccountLike>(account: T): boolean {
141+
return (
142+
!!normalizeWorkspaceIdentityPart(account.organizationId) ||
143+
account.accountIdSource === "org"
144+
);
145+
}
146+
147+
/**
148+
* Resolve the merged `enabled` flag.
149+
*
150+
* Default is fail-closed: if either side is explicitly disabled, the merge is
151+
* disabled. The ONE exception (issue #171): when exactly one side is an
152+
* org-backed real account and the other is a token-source duplicate (a plugin
153+
* re-login artifact), the org account's own `enabled` state governs. A disabled
154+
* token duplicate must never silently disable the real account it collapses
155+
* into — otherwise the canonical account becomes unroutable and unrecoverable.
156+
*/
157+
function resolveMergedEnabled<T extends AccountLike>(target: T, source: T): boolean | undefined {
158+
const targetOrgLike = isOrgLikeAccount(target);
159+
const sourceOrgLike = isOrgLikeAccount(source);
160+
const targetTokenDup = !targetOrgLike && target.accountIdSource === "token";
161+
const sourceTokenDup = !sourceOrgLike && source.accountIdSource === "token";
162+
163+
if (targetOrgLike && sourceTokenDup) return target.enabled;
164+
if (sourceOrgLike && targetTokenDup) return source.enabled;
165+
166+
// Fail-closed for every other shape (same-identity, two real accounts, etc.).
167+
if (target.enabled === false || source.enabled === false) return false;
168+
return target.enabled ?? source.enabled;
169+
}
170+
139171
function mergeAccountRecords<T extends AccountLike>(target: T, source: T): T {
140172
const newest = selectNewestAccount(target, source);
141173
const older = newest === target ? source : target;
@@ -147,6 +179,7 @@ function mergeAccountRecords<T extends AccountLike>(target: T, source: T): T {
147179
accountIdSource: target.accountIdSource ?? source.accountIdSource,
148180
accountLabel: target.accountLabel ?? source.accountLabel,
149181
email: target.email ?? source.email,
182+
enabled: resolveMergedEnabled(target, source),
150183
};
151184
}
152185

test/chaos/doctor-recovery-171-stress.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ import {
2828
clearRefreshedAccountsStaleState,
2929
findDisabledTokenSourceDuplicates,
3030
} from "../../lib/accounts/stale-state.js";
31+
import {
32+
loadAccounts,
33+
saveAccounts,
34+
setStoragePathDirect,
35+
} from "../../lib/storage.js";
36+
import { promises as fsp } from "node:fs";
37+
import { tmpdir } from "node:os";
38+
import { join } from "node:path";
3139

3240
const FAMILY: ModelFamily = "codex";
3341
const NOW = 1_700_000_000_000;
@@ -334,3 +342,81 @@ describe("chaos/doctor-recovery — real manager + real recovery helpers (issue
334342
});
335343
});
336344

345+
describe("chaos/doctor-recovery — single-account + disabled dup, real save/load (issue #171)", () => {
346+
let dir: string;
347+
348+
afterEach(async () => {
349+
while (liveManagers.length > 0) {
350+
liveManagers.pop()?.disposeShutdownHandler();
351+
}
352+
setStoragePathDirect(null);
353+
if (dir) await fsp.rm(dir, { recursive: true, force: true });
354+
});
355+
356+
it("recovers a lone org account whose disabled token-dup merged in", async () => {
357+
dir = await fsp.mkdtemp(join(tmpdir(), "ralph171-"));
358+
setStoragePathDirect(join(dir, "oc-codex-multi-auth-accounts.json"));
359+
const FUTURE = Date.now() + 3_600_000;
360+
361+
// Reporter shape reduced to a single real account: an org account in
362+
// auth-failure cooldown, plus a disabled token-source duplicate (same
363+
// email) minted by a fresh re-login. Before the merge fix this collapsed
364+
// to ONE disabled account that neither doctor nor health could recover.
365+
await saveAccounts({
366+
version: 3,
367+
activeIndex: 0,
368+
accounts: [
369+
{
370+
accountId: "org-AAA",
371+
organizationId: "org-AAA",
372+
accountIdSource: "org",
373+
email: "user@example.com",
374+
refreshToken: "OLD-refresh",
375+
addedAt: 100,
376+
lastUsed: 200,
377+
coolingDownUntil: FUTURE,
378+
cooldownReason: "auth-failure",
379+
},
380+
{
381+
accountId: "uuid-fresh",
382+
accountIdSource: "token",
383+
email: "user@example.com",
384+
refreshToken: "FRESH-refresh",
385+
addedAt: 999,
386+
lastUsed: 999,
387+
enabled: false,
388+
},
389+
],
390+
});
391+
392+
// Merge on load: ONE enabled org account, dark (cooldown active).
393+
const loaded = await loadAccounts();
394+
expect(loaded?.accounts).toHaveLength(1);
395+
const acct = loaded!.accounts[0]!;
396+
expect(acct.enabled).not.toBe(false);
397+
const before = makeManager(loaded!);
398+
expect(
399+
before
400+
.getSelectionExplainability(FAMILY, "gpt-5.4-mini", Date.now())
401+
.filter((e) => e.eligible).length,
402+
).toBe(0);
403+
404+
// codex-doctor --fix: refresh enabled accounts, clear stale state, persist.
405+
const refreshable = loaded!.accounts.filter((a) => a.enabled !== false);
406+
expect(refreshable).toHaveLength(1);
407+
for (const a of refreshable) a.refreshToken = "NEW-" + a.refreshToken;
408+
clearRefreshedAccountsStaleState(refreshable);
409+
await saveAccounts(loaded!);
410+
411+
// After restart: pool is eligible again.
412+
const after = await loadAccounts();
413+
const mgr = makeManager(after!);
414+
const eligible = mgr
415+
.getSelectionExplainability(FAMILY, "gpt-5.4-mini", Date.now())
416+
.filter((e) => e.eligible).length;
417+
expect(eligible).toBe(1);
418+
const selected = mgr.getCurrentOrNextForFamilyHybrid(FAMILY, "gpt-5.4-mini");
419+
expect(selected?.organizationId).toBe("org-AAA");
420+
expect(mgr.isAccountCoolingDown(selected as NonNullable<Active>)).toBe(false);
421+
});
422+
});

test/storage.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,75 @@ describe("storage", () => {
316316
expect(loaded?.activeIndexByFamily?.codex).toBe(0);
317317
});
318318

319+
// Issue #171: a disabled token-source duplicate (a plugin re-login artifact)
320+
// must NOT disable the real org account it collapses into during dedup.
321+
it("keeps the org account enabled when a disabled token-source duplicate merges in (#171)", async () => {
322+
const now = Date.now();
323+
await saveAccounts({
324+
version: 3,
325+
activeIndex: 0,
326+
accounts: [
327+
{
328+
accountId: "org-AAA",
329+
organizationId: "org-AAA",
330+
accountIdSource: "org",
331+
email: "user@example.com",
332+
refreshToken: "OLD-refresh",
333+
addedAt: 100,
334+
lastUsed: 200,
335+
coolingDownUntil: now + 3_600_000,
336+
cooldownReason: "auth-failure",
337+
},
338+
{
339+
accountId: "uuid-fresh",
340+
accountIdSource: "token",
341+
email: "user@example.com",
342+
refreshToken: "FRESH-refresh",
343+
addedAt: 999,
344+
lastUsed: 999,
345+
enabled: false,
346+
},
347+
],
348+
});
349+
const loaded = await loadAccounts();
350+
expect(loaded?.accounts).toHaveLength(1);
351+
const survivor = loaded!.accounts[0]!;
352+
expect(survivor.organizationId).toBe("org-AAA");
353+
// The real account must remain routable: NOT disabled by the dup.
354+
expect(survivor.enabled).not.toBe(false);
355+
// And it carries the fresh token from the newer (dup) record.
356+
expect(survivor.refreshToken).toBe("FRESH-refresh");
357+
});
358+
359+
it("preserves fail-closed when a same-identity record was user-disabled (#171 guard)", async () => {
360+
await saveAccounts({
361+
version: 3,
362+
activeIndex: 0,
363+
accounts: [
364+
{ accountId: "org-X", organizationId: "org-X", accountIdSource: "org", email: "u@e.com", refreshToken: "RT", addedAt: 100, lastUsed: 100, enabled: false },
365+
{ accountId: "org-X", organizationId: "org-X", accountIdSource: "org", email: "u@e.com", refreshToken: "RT", addedAt: 200, lastUsed: 200, enabled: true },
366+
],
367+
});
368+
const loaded = await loadAccounts();
369+
expect(loaded?.accounts).toHaveLength(1);
370+
// User intent wins: a genuinely disabled real account stays disabled.
371+
expect(loaded!.accounts[0]!.enabled).toBe(false);
372+
});
373+
374+
it("keeps two distinct real org accounts both enabled (no merge regression)", async () => {
375+
await saveAccounts({
376+
version: 3,
377+
activeIndex: 0,
378+
accounts: [
379+
{ accountId: "org-1", organizationId: "org-1", accountIdSource: "org", email: "a@e.com", refreshToken: "R1", addedAt: 100, lastUsed: 100, enabled: true },
380+
{ accountId: "org-2", organizationId: "org-2", accountIdSource: "org", email: "b@e.com", refreshToken: "R2", addedAt: 100, lastUsed: 100, enabled: true },
381+
],
382+
});
383+
const loaded = await loadAccounts();
384+
expect(loaded?.accounts).toHaveLength(2);
385+
expect(loaded!.accounts.every((a) => a.enabled !== false)).toBe(true);
386+
});
387+
319388
it("retains per-account rate-limit and cooldown metadata through save/load round-trip", async () => {
320389
await saveAccounts({
321390
version: 3,

0 commit comments

Comments
 (0)