diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 848026ca5..a99b6ef34 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed a pre-org OAuth tombstone (`email:`) surviving a later org-scoped login of the same account (`email:|org:`) for `openai-codex` and `anthropic`, leaving a permanent red row in `omp usage` that re-login could not clear. `#purgeSupersededDisabledRows` now reuses the `matchesReplacementCredential` semantics of the active-replacement path instead of exact identity-key equality, so an org-scoped login claims and hard-deletes its pre-org legacy tombstone ([#7876](https://github.com/can1357/oh-my-pi/issues/7876)). + ## [17.2.10] - 2026-08-06 ### Breaking Changes diff --git a/packages/ai/src/auth/sqlite-credential-store.ts b/packages/ai/src/auth/sqlite-credential-store.ts index 0ae14f851..4380a2312 100644 --- a/packages/ai/src/auth/sqlite-credential-store.ts +++ b/packages/ai/src/auth/sqlite-credential-store.ts @@ -1374,11 +1374,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore { try { let hasActiveApiKey = false; const activeIdentityKeys = new Set(); + const activeOAuthCredentials: AuthCredential[] = []; for (const row of activeRows) { if (row.credential.type === "api_key") { hasActiveApiKey = true; continue; } + activeOAuthCredentials.push(row.credential); const identityKey = resolveCredentialIdentityKey(provider, row.credential); if (identityKey) activeIdentityKeys.add(identityKey); } @@ -1393,7 +1395,22 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore { const identityKey = resolveRowCredentialIdentityKey(provider, row); if (identityKey && activeIdentityKeys.has(identityKey)) { this.#hardDeleteStmt.run(row.id); + continue; } + // Exact key equality misses a tombstone whose key predates a format + // the active row now uses (pre-org `` vs `|org:`). An active + // credential that WOULD have replaced this row had it still been + // active supersedes its tombstone too, so mirror the replacement + // matcher rather than restating a weaker rule. The one-way upgrade + // and shared-workspace guards in matchesReplacementCredential carry + // over, so this never over-deletes another member's or subscription's + // row. + const disabledCredential = deserializeCredential(row); + if (disabledCredential === null) continue; + const superseded = activeOAuthCredentials.some(active => + matchesReplacementCredential(provider, disabledCredential, identityKey, active), + ); + if (superseded) this.#hardDeleteStmt.run(row.id); } } catch { // Best-effort cleanup; don't let it break the main operation diff --git a/packages/ai/test/auth-storage-codex-workspace-identity.test.ts b/packages/ai/test/auth-storage-codex-workspace-identity.test.ts index 1f4b38e73..671086806 100644 --- a/packages/ai/test/auth-storage-codex-workspace-identity.test.ts +++ b/packages/ai/test/auth-storage-codex-workspace-identity.test.ts @@ -151,6 +151,50 @@ describe("openai-codex workspace-scoped credential identity", () => { ]); }); + it("purges a disabled legacy email-keyed row on the first workspace-scoped login with the same email", async () => { + if (!store) throw new Error("test setup failed"); + + // Pre-org login → bare email key, then upstream invalidates the refresh + // token and the row is auto-disabled (a tombstone). + store.upsertAuthCredentialForProvider( + "openai-codex", + codexCredential({ suffix: "legacy", accountId: PERSONAL_WS }), + ); + const legacyId = store.listAuthCredentials("openai-codex")[0].id; + store.deleteAuthCredential(legacyId, "oauth refresh failed: OAuthError: 401 refresh_token_invalidated"); + + // Same human logs in again, now workspace-scoped: the org-scoped login + // claims and hard-deletes the pre-org tombstone instead of stranding it. + store.upsertAuthCredentialForProvider( + "openai-codex", + codexCredential({ suffix: "team", accountId: TEAM_WS, orgId: TEAM_WS, orgName: "team" }), + ); + expect(readIdentityRows(dbPath)).toEqual([ + { identity_key: `email:${EMAIL}|org:${TEAM_WS}`, disabled_cause: null }, + ]); + expect(await store.listDisabledCredentials("openai-codex")).toHaveLength(0); + }); + + it("keeps a disabled row of a different member of the same workspace after a workspace-scoped login", async () => { + if (!store) throw new Error("test setup failed"); + + // Alice's org-scoped row is disabled (tombstone). Bob, a different member + // of the SAME workspace, logs in. The shared-workspace guard must keep + // Alice's tombstone — it is not Bob's subscription. + store.upsertAuthCredentialForProvider( + "openai-codex", + codexCredential({ suffix: "alice", accountId: TEAM_WS, orgId: TEAM_WS, email: "alice@example.com" }), + ); + const aliceId = store.listAuthCredentials("openai-codex")[0].id; + store.deleteAuthCredential(aliceId, "oauth refresh failed: OAuthError: 401 refresh_token_invalidated"); + + store.upsertAuthCredentialForProvider( + "openai-codex", + codexCredential({ suffix: "bob", accountId: TEAM_WS, orgId: TEAM_WS, email: "bob@example.com" }), + ); + expect(await store.listDisabledCredentials("openai-codex")).toHaveLength(1); + }); + it("never clobbers workspace-scoped rows with a workspace-less credential", () => { if (!store) throw new Error("test setup failed");