fix(auth): purged pre-org oauth tombstone on org-scoped re-login

#purgeSupersededDisabledRows matched disabled rows against active ones by
exact identity-key string equality, but the active-replacement path it
mirrors (matchesReplacementCredential) claims pre-org legacy rows
(`<b>` vs `<b>|org:<o>`). So a later org-scoped login of the same account
never purged the pre-org tombstone, which then rendered forever as a red
row in `omp usage` with no CLI/TUI escape. This is the OAuth half of the
class of bug #2943 fixed for api_key rows in the same function.

Reuse matchesReplacementCredential in the purge so an org-scoped login
claims and hard-deletes its pre-org tombstone, inheriting the one-way
upgrade and shared-workspace guards unchanged.

Fixes #7876
This commit is contained in:
roboomp
2026-08-07 04:16:37 +00:00
parent 3a8591a8af
commit 648d858bb1
3 changed files with 65 additions and 0 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed a pre-org OAuth tombstone (`email:<e>`) surviving a later org-scoped login of the same account (`email:<e>|org:<ws>`) 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
@@ -1374,11 +1374,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
try {
let hasActiveApiKey = false;
const activeIdentityKeys = new Set<string>();
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 `<b>` vs `<b>|org:<o>`). 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
@@ -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");