Skip to content

Commit 5c7e936

Browse files
ndycodeneilclaude
authored
fix(storage): consistent case-insensitive email identity + surface absorbed re-logins (#171) (#181)
* fix(storage): consistent case-insensitive email identity + surface absorbed re-logins (#171) Two edge cases surfaced by a deep release-readiness stress test in the #171 account-recovery layers, plus a third same-class divergence found in review. 1. Email case divergence (storage vs detectors): lib/storage/identity.ts compared emails case-sensitively (trim only) while the codex-doctor/ codex-health detectors lowercase. So `User@Example.com` (org) and `user@example.com` (disabled token re-login) escaped storage dedup yet were flagged as removable — the two layers disagreed on identity. Added `sameEmailIdentity` (trim+lowercase) used only at the email comparison site, and lowercased the dedup-by-email map key. accountId/org comparisons stay case-sensitive (#64/#85 preserved). 2. Surfacing blind spot: a user-disabled org account that absorbs a newer ENABLED token re-login correctly stays disabled (fail-closed) but was then skipped by every #171 diagnostic, so the user got no signal a fresh login landed on a disabled slot. New read-only findDisabledAccountsWithFreshCredential (disabled + unexpired access token), surfaced by codex-doctor (finding `disabled-account-fresh-credential`) and codex-health (text + `disabledWithFreshCredentialSlots` JSON), pointing the user to re-enable it. Never re-enables anything. 3. Third divergence (architect review): login-runner buildIdentityIndexes keyed byEmailNoOrg with trim-only while its lookup and getExactIdentityKey lowercase. Aligned to sanitizeEmail so a mixed-case re-login can't miss its existing no-org entry and append a duplicate. Tests: 5 unit tests for findDisabledAccountsWithFreshCredential; storage merge test for mixed-case email collapse; login-runner behavioral test (mixed-case re-login merges to 1 account; distinct emails stay 2). Mutation teeth-checks confirmed for bugs 1 and 2. Full suite green (2549 passed, 1 skipped), tsc + eslint + build clean, npm audit 0 vulnerabilities. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: add enabled-undefined + distinct-emails control cases (PR #181 review) Greptile P2s: - findDisabledAccountsWithFreshCredential: add an enabled:undefined case (no enabled field) asserting [] — teeth on the `enabled !== false` branch. - login-runner: commit the distinct-emails control (user@ + other@ stay 2 accounts) that the PR description claimed, proving the mixed-case merge test is non-vacuous. Both are email-only (no accountId) so they exercise the email-dedup path. Full suite green (2551 passed, 1 skipped), tsc 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 ff06bd5 commit 5c7e936

9 files changed

Lines changed: 230 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- `codex-doctor` now surfaces a finding when a disabled `accountIdSource: "token"` entry shadows an enabled, org-backed account that shares its email (a leftover a fresh re-login can mint instead of updating the org account). It is flagged with a `codex-remove` hint rather than auto-removed, because the only link between the two records is email and email-only merges must not collapse distinct multi-org accounts (#64). (#171)
1313
- `codex-doctor --fix` no longer fails silently when a stored credential is genuinely dead: a failed token refresh now reports `N account(s) need re-login` and points the user at `opencode auth login`, instead of leaving an all-dark pool unrepaired with no surfaced cause. (#171)
1414
- `codex-health` now surfaces the same recovery diagnostics as `codex-doctor` (read-only): it flags accounts blocked only by a stale cooldown/rate-limit (pointing at `codex-doctor --fix`) and disabled token-source duplicates (pointing at `codex-remove`), and includes `staleRecoverableSlots` / `disabledDuplicateSlots` in JSON output. This addresses the part of the issue that named `codex-health` explicitly. (#171)
15+
- A disabled `accountIdSource: "token"` duplicate (a re-login artifact) merging into the real org-source account by email no longer disables the canonical account. Storage dedup now lets the org account's own `enabled` state govern the merge, so a single-account pool can no longer end up dark and unrecoverable; fail-closed is preserved for genuinely user-disabled accounts. (#171)
16+
- Storage dedup now compares account emails case-insensitively, matching the `codex-doctor`/`codex-health` duplicate detectors. Previously `User@Example.com` (org) and `user@example.com` (token re-login) escaped dedup yet were still flagged as removable, so the two layers disagreed on identity. (#171)
17+
- `codex-doctor` and `codex-health` now surface a disabled account that holds a fresh login credential — the fingerprint of a recent re-login that landed on a deliberately-disabled slot — so the user is told to re-enable it if intended instead of getting no signal at all. (#171)
1518
- Caller-cancellation during a retry/backoff wait now surfaces as a proper `AbortError` carrying the caller's `signal.reason`, instead of an opaque `new Error("Aborted")` that dropped the cause. This aligns the retry-wait path with the fetch path and the `isAbortError` convention in `lib/codex-usage.ts`, improving diagnosability of the `Error: Aborted` symptom. (#176)
1619
- Fixed a flaky email-masking property test (#163) that intermittently failed CI: the assertion used a fragile `masked.includes(local)` substring check that false-positived when the local part repeats in the preserved domain (`abc@abc.com`) or contains the mask character itself (`a.*@a.aa``a.***`). It now asserts the exact masking contract (masked local = first ≤2 chars + `***`), with deterministic regression cases. No production code change.
1720

lib/accounts/stale-state.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,48 @@ export function findStaleRecoverableAccounts(
220220
return blocked;
221221
}
222222

223+
/**
224+
* Subset of a stored account used for the absorbed-credential diagnostic.
225+
*/
226+
export interface FreshCredentialScanAccount {
227+
enabled?: boolean;
228+
expiresAt?: number;
229+
accessToken?: string;
230+
}
231+
232+
/**
233+
* Identify accounts that are DISABLED yet carry a fresh (unexpired) access
234+
* token — the fingerprint of a user-disabled account that absorbed a newer
235+
* enabled re-login during dedup (issue #171).
236+
*
237+
* The merge is intentionally fail-closed: a user-disabled record stays disabled
238+
* even after it absorbs a fresh credential. That is correct, but it is silent —
239+
* the absorbing record is org-source AND disabled, so it is skipped by every
240+
* other #171 diagnostic and the user gets no hint that a fresh login they just
241+
* performed landed on a deliberately-disabled slot. This read-only detector
242+
* surfaces that case so tooling can suggest re-enabling the account if intended.
243+
*
244+
* Read-only: mutates nothing, and never re-enables (that stays a user decision).
245+
*
246+
* @returns the 0-based indexes of disabled accounts holding a fresh credential.
247+
*/
248+
export function findDisabledAccountsWithFreshCredential(
249+
accounts: FreshCredentialScanAccount[],
250+
now: number = nowMs(),
251+
): number[] {
252+
const flagged: number[] = [];
253+
for (let i = 0; i < accounts.length; i += 1) {
254+
const account = accounts[i];
255+
if (!account) continue;
256+
if (account.enabled !== false) continue;
257+
const hasToken =
258+
typeof account.accessToken === "string" && account.accessToken.length > 0;
259+
const fresh =
260+
typeof account.expiresAt === "number" && account.expiresAt > now;
261+
if (hasToken && fresh) {
262+
flagged.push(i);
263+
}
264+
}
265+
return flagged;
266+
}
267+

lib/auth/login-runner.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,11 @@ export async function persistAccountPool(
559559
const organizationId = account.organizationId?.trim();
560560
const accountId = account.accountId?.trim();
561561
const refreshToken = account.refreshToken?.trim();
562-
const email = account.email?.trim();
562+
// Lowercase the email index key to match the lookup side
563+
// (sanitizeEmail) and getExactIdentityKey; a trim-only key here let a
564+
// mixed-case re-login miss its existing no-org entry and append a
565+
// duplicate (#171 email-identity consistency).
566+
const email = sanitizeEmail(account.email);
563567

564568
if (refreshToken) {
565569
pushIndex(byRefreshTokenGlobal, refreshToken, i);

lib/storage/identity.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,21 @@ function sameOptionalIdentity(left: string | undefined, right: string | undefine
189189
return !!normalizedLeft && !!normalizedRight && normalizedLeft === normalizedRight;
190190
}
191191

192+
/**
193+
* Email-specific identity comparison. Emails are case-insensitive, so this
194+
* normalizes with `trim().toLowerCase()`. This MUST match the normalization in
195+
* `lib/accounts/stale-state.ts` (findDisabledTokenSourceDuplicates) so the
196+
* storage-merge layer and the codex-doctor/codex-health detectors agree on
197+
* whether two records are the same account (issue #171): a divergence let
198+
* `User@Example.com` (org) and `user@example.com` (disabled token re-login)
199+
* escape dedup yet still get flagged as a removable duplicate.
200+
*/
201+
function sameEmailIdentity(left: string | undefined, right: string | undefined): boolean {
202+
const normalizedLeft = normalizeWorkspaceIdentityPart(left)?.toLowerCase();
203+
const normalizedRight = normalizeWorkspaceIdentityPart(right)?.toLowerCase();
204+
return !!normalizedLeft && !!normalizedRight && normalizedLeft === normalizedRight;
205+
}
206+
192207
function isLegacyRefreshTokenDuplicate<T extends AccountLike>(left: T, right: T): boolean {
193208
const refreshToken = normalizeWorkspaceIdentityPart(left.refreshToken);
194209
const leftOrganizationId = normalizeWorkspaceIdentityPart(left.organizationId);
@@ -202,7 +217,7 @@ function isLegacyRefreshTokenDuplicate<T extends AccountLike>(left: T, right: T)
202217
const rightTokenLike = !rightOrganizationId && right.accountIdSource === "token";
203218
if (
204219
((leftOrgLike && rightTokenLike) || (rightOrgLike && leftTokenLike)) &&
205-
(sameOptionalIdentity(left.email, right.email) ||
220+
(sameEmailIdentity(left.email, right.email) ||
206221
(!!refreshToken && refreshToken === normalizeWorkspaceIdentityPart(right.refreshToken)))
207222
) {
208223
return true;
@@ -337,7 +352,7 @@ export function deduplicateAccountsByEmail<T extends { organizationId?: string;
337352
continue;
338353
}
339354

340-
const email = account.email?.trim();
355+
const email = account.email?.trim().toLowerCase();
341356
if (!email) {
342357
indicesToKeep.add(i);
343358
continue;

lib/tools/codex-doctor.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { queuedRefresh } from "../refresh-queue.js";
1414
import { MODEL_FAMILIES } from "../prompts/codex.js";
1515
import {
1616
clearRefreshedAccountsStaleState,
17+
findDisabledAccountsWithFreshCredential,
1718
findDisabledTokenSourceDuplicates,
1819
} from "../accounts/stale-state.js";
1920
import { clearTuiQuotaSnapshot } from "../tui-quota-cache.js";
@@ -118,6 +119,21 @@ export function createCodexDoctorTool(ctx: ToolContext): ToolDefinition {
118119
.join(", ")}).`,
119120
});
120121
}
122+
// A user-disabled account that absorbed a fresh enabled re-login stays
123+
// disabled (fail-closed) but is otherwise invisible to diagnostics (#171).
124+
const disabledWithFreshCredential = storage
125+
? findDisabledAccountsWithFreshCredential(storage.accounts)
126+
: [];
127+
if (disabledWithFreshCredential.length > 0) {
128+
findings.push({
129+
severity: "warning",
130+
code: "disabled-account-fresh-credential",
131+
summary: `${disabledWithFreshCredential.length} disabled account(s) hold a fresh login credential.`,
132+
action: `A recent re-login landed on a disabled slot; re-enable it in oc-codex-multi-auth-accounts.json if intended (slots: ${disabledWithFreshCredential
133+
.map((index) => index + 1)
134+
.join(", ")}).`,
135+
});
136+
}
121137
let routingVisibility: RoutingVisibilitySnapshot | null = null;
122138
const appliedFixes: string[] = [];
123139
const fixErrors: string[] = [];

lib/tools/codex-health.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
77
import { loadAccounts } from "../storage.js";
88
import { queuedRefresh } from "../refresh-queue.js";
99
import {
10+
findDisabledAccountsWithFreshCredential,
1011
findDisabledTokenSourceDuplicates,
1112
findStaleRecoverableAccounts,
1213
} from "../accounts/stale-state.js";
@@ -156,13 +157,22 @@ export function createCodexHealthTool(ctx: ToolContext): ToolDefinition {
156157
`Duplicates: ${dupSlots.length} disabled duplicate entry(ies) shadow a real account (slots: ${dupSlots.join(", ")}). Remove with \`codex-remove\`.`,
157158
);
158159
}
160+
const absorbedSlots = findDisabledAccountsWithFreshCredential(
161+
storage.accounts,
162+
).map((index) => index + 1);
163+
if (absorbedSlots.length > 0) {
164+
results.push(
165+
`Disabled w/ fresh login: ${absorbedSlots.length} disabled account(s) hold a fresh credential (slots: ${absorbedSlots.join(", ")}) - a recent re-login landed on a disabled slot. Re-enable in oc-codex-multi-auth-accounts.json if intended.`,
166+
);
167+
}
159168
if (outputFormat === "json") {
160169
return renderJsonOutput({
161170
totalAccounts: storage.accounts.length,
162171
healthyCount,
163172
unhealthyCount,
164173
staleRecoverableSlots: staleSlots,
165174
disabledDuplicateSlots: dupSlots,
175+
disabledWithFreshCredentialSlots: absorbedSlots,
166176
accounts: jsonAccounts,
167177
});
168178
}

test/login-runner.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,63 @@ describe("login-runner persistAccountPool", () => {
4949
await fs.rm(testDir, { recursive: true, force: true });
5050
});
5151

52+
// Build a minimal JWT (header.payload.sig) whose payload carries an email
53+
// claim, so extractAccountEmail/decodeJWT resolve a real address.
54+
const jwtWithEmail = (email: string): string => {
55+
const b64 = (o: unknown) =>
56+
Buffer.from(JSON.stringify(o)).toString("base64url");
57+
return `${b64({ alg: "none" })}.${b64({ email })}.sig`;
58+
};
59+
60+
it("treats a mixed-case re-login as the same identity end-to-end (#171 email case)", async () => {
61+
// Seed a stored email-only (no org/accountId) account with MixedCase email.
62+
await persistAccountPool(
63+
[
64+
{
65+
type: "success",
66+
access: jwtWithEmail("User@Example.com"),
67+
refresh: "refresh-old",
68+
expires: Date.now() + 60_000,
69+
},
70+
],
71+
false,
72+
);
73+
const first = await loadAccounts();
74+
expect(first?.accounts).toHaveLength(1);
75+
76+
// Re-login: same person, lowercase email, fresh refresh token.
77+
await persistAccountPool(
78+
[
79+
{
80+
type: "success",
81+
access: jwtWithEmail("user@example.com"),
82+
refresh: "refresh-new",
83+
expires: Date.now() + 60_000,
84+
},
85+
],
86+
false,
87+
);
88+
const second = await loadAccounts();
89+
// Case-insensitive email index => merged into the existing entry, not appended.
90+
expect(second?.accounts).toHaveLength(1);
91+
expect(second?.accounts[0]?.refreshToken).toBe("refresh-new");
92+
});
93+
94+
it("keeps two genuinely distinct emails as separate accounts (control)", async () => {
95+
await persistAccountPool(
96+
[{ type: "success", access: jwtWithEmail("user@example.com"), refresh: "r-a", expires: Date.now() + 60_000 }],
97+
false,
98+
);
99+
await persistAccountPool(
100+
[{ type: "success", access: jwtWithEmail("other@example.com"), refresh: "r-b", expires: Date.now() + 60_000 }],
101+
false,
102+
);
103+
const loaded = await loadAccounts();
104+
// Distinct emails must NOT be merged — proves the merge test is non-vacuous.
105+
expect(loaded?.accounts).toHaveLength(2);
106+
});
107+
108+
52109
it("serializes overlapping login persists without losing accounts", async () => {
53110
const originalRename = fs.rename.bind(fs);
54111
let firstRenameReleased = false;

test/stale-state.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
clearRefreshedAccountStaleState,
44
clearRefreshedAccountsStaleState,
55
findDisabledTokenSourceDuplicates,
6+
findDisabledAccountsWithFreshCredential,
67
findStaleRecoverableAccounts,
78
type StaleStateAccount,
89
} from "../lib/accounts/stale-state.js";
@@ -168,3 +169,42 @@ describe("findStaleRecoverableAccounts", () => {
168169
});
169170
});
170171

172+
describe("findDisabledAccountsWithFreshCredential (issue #171)", () => {
173+
const NOW = 1_700_000_000_000;
174+
const FUTURE = NOW + 3_600_000;
175+
const PAST = NOW - 3_600_000;
176+
177+
it("flags a disabled account that holds a fresh (unexpired) access token", () => {
178+
const accounts = [{ enabled: false, accessToken: "tok", expiresAt: FUTURE }];
179+
expect(findDisabledAccountsWithFreshCredential(accounts, NOW)).toEqual([0]);
180+
});
181+
182+
it("ignores an enabled account (not the blind-spot case)", () => {
183+
const accounts = [{ enabled: true, accessToken: "tok", expiresAt: FUTURE }];
184+
expect(findDisabledAccountsWithFreshCredential(accounts, NOW)).toEqual([]);
185+
});
186+
187+
it("ignores an account with no enabled field (undefined is not disabled)", () => {
188+
const accounts = [{ accessToken: "tok", expiresAt: FUTURE }];
189+
expect(findDisabledAccountsWithFreshCredential(accounts, NOW)).toEqual([]);
190+
});
191+
192+
it("ignores a disabled account whose credential is expired", () => {
193+
const accounts = [{ enabled: false, accessToken: "tok", expiresAt: PAST }];
194+
expect(findDisabledAccountsWithFreshCredential(accounts, NOW)).toEqual([]);
195+
});
196+
197+
it("ignores a disabled account with no access token", () => {
198+
const accounts = [{ enabled: false, expiresAt: FUTURE }];
199+
expect(findDisabledAccountsWithFreshCredential(accounts, NOW)).toEqual([]);
200+
});
201+
202+
it("returns multiple flagged slots in order", () => {
203+
const accounts = [
204+
{ enabled: false, accessToken: "a", expiresAt: FUTURE },
205+
{ enabled: true, accessToken: "b", expiresAt: FUTURE },
206+
{ enabled: false, accessToken: "c", expiresAt: FUTURE },
207+
];
208+
expect(findDisabledAccountsWithFreshCredential(accounts, NOW)).toEqual([0, 2]);
209+
});
210+
});

test/storage.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,43 @@ describe("storage", () => {
318318

319319
// Issue #171: a disabled token-source duplicate (a plugin re-login artifact)
320320
// must NOT disable the real org account it collapses into during dedup.
321+
// Issue #171 (email case divergence): storage dedup must compare emails
322+
// case-insensitively so it agrees with the codex-doctor/codex-health
323+
// detectors (which lowercase). Otherwise User@Example.com (org) and
324+
// user@example.com (token dup) escape dedup but get flagged as removable.
325+
it("collapses an org + disabled token-dup that differ only by email case (#171)", async () => {
326+
const now = Date.now();
327+
await saveAccounts({
328+
version: 3,
329+
activeIndex: 0,
330+
accounts: [
331+
{
332+
accountId: "org-AAA",
333+
organizationId: "org-AAA",
334+
accountIdSource: "org",
335+
email: "User@Example.com",
336+
refreshToken: "OLD-refresh",
337+
addedAt: 100,
338+
lastUsed: 200,
339+
},
340+
{
341+
accountId: "uuid-fresh",
342+
accountIdSource: "token",
343+
email: "user@example.com",
344+
refreshToken: "FRESH-refresh",
345+
addedAt: 999,
346+
lastUsed: 999,
347+
enabled: false,
348+
},
349+
],
350+
});
351+
const loaded = await loadAccounts();
352+
// Case-insensitive email match => the two collapse into one org account.
353+
expect(loaded?.accounts).toHaveLength(1);
354+
expect(loaded!.accounts[0]!.organizationId).toBe("org-AAA");
355+
expect(loaded!.accounts[0]!.enabled).not.toBe(false);
356+
});
357+
321358
it("keeps the org account enabled when a disabled token-source duplicate merges in (#171)", async () => {
322359
const now = Date.now();
323360
await saveAccounts({

0 commit comments

Comments
 (0)