Merge pull request #6095 from will-bogusz/fix/codex-workspace-scoped-credentials
fix(ai): scope OpenAI Codex credential identity by ChatGPT workspace
This commit is contained in:
+1
-1
@@ -31,7 +31,7 @@ When a provider needs an API key, `omp` resolves it in this order (first match w
|
||||
1. **Runtime override** — a key supplied for the current process, e.g. CLI `--api-key`. Never persisted.
|
||||
2. **`models.yml` config key** — an `apiKey` pinned on a custom provider, registered as a config-sourced bearer. This deliberately beats stored OAuth, so a key supplied for a custom `baseUrl`/gateway is honored instead of forwarding an upstream OAuth token the proxy would reject.
|
||||
3. **Stored API key** — an API-key credential saved in the auth store.
|
||||
4. **Stored OAuth credential** — refreshed when needed; multiple accounts are ranked/rotated automatically. For Anthropic, each organization counts as its own account: one email holding both a Team seat and a personal plan can log in once per subscription (pick the workspace on the browser consent page) and rotation treats them as two accounts.
|
||||
4. **Stored OAuth credential** — refreshed when needed; multiple accounts are ranked/rotated automatically. For Anthropic and ChatGPT (Codex), each organization/workspace counts as its own account: one email holding both a Team/Enterprise seat and a personal plan can log in once per subscription (pick the workspace on the browser consent page) and rotation treats them as two accounts.
|
||||
5. **Provider environment variable** — including values loaded from `.env` files (see [the env-var table](#environment-variables-and-env-files)).
|
||||
6. **`models.yml` fallback resolver** — keys for custom providers not otherwise registered.
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenAI Codex credentials limited to one ChatGPT workspace per email: a personal Plus/Pro plan and a Team/Enterprise seat under the same email now coexist in the auth store — with separate rotation and usage pools — instead of the second login silently replacing the first. The workspace (`chatgpt_account_id`) is captured as the credential's org at login with the plan type as its display label, and two members of one workspace keep separate rows ([#2966](https://github.com/can1357/oh-my-pi/issues/2966)).
|
||||
|
||||
## [17.0.5] - 2026-07-18
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -186,7 +186,7 @@ export interface CredentialHealthResult {
|
||||
email?: string;
|
||||
/** OAuth account id if known. */
|
||||
accountId?: string;
|
||||
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
||||
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
|
||||
orgId?: string;
|
||||
orgName?: string;
|
||||
/** `true` when the refresh token lives on a remote broker (sentinel was present). */
|
||||
@@ -735,7 +735,7 @@ export interface OAuthAccess {
|
||||
projectId?: string;
|
||||
enterpriseUrl?: string;
|
||||
apiEndpoint?: string;
|
||||
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
||||
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
|
||||
orgId?: string;
|
||||
orgName?: string;
|
||||
}
|
||||
@@ -760,7 +760,7 @@ export interface OAuthAccessFailure {
|
||||
projectId?: string;
|
||||
enterpriseUrl?: string;
|
||||
apiEndpoint?: string;
|
||||
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
||||
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
|
||||
orgId?: string;
|
||||
orgName?: string;
|
||||
error: string;
|
||||
@@ -776,7 +776,7 @@ export interface OAuthAccountIdentity {
|
||||
accountId?: string;
|
||||
email?: string;
|
||||
projectId?: string;
|
||||
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
||||
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
|
||||
orgId?: string;
|
||||
orgName?: string;
|
||||
}
|
||||
@@ -795,7 +795,7 @@ export interface OAuthAccountSummary {
|
||||
email?: string;
|
||||
projectId?: string;
|
||||
enterpriseUrl?: string;
|
||||
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
||||
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
|
||||
orgId?: string;
|
||||
orgName?: string;
|
||||
}
|
||||
@@ -3283,15 +3283,14 @@ export class AuthStorage {
|
||||
const identifiers: string[] = [];
|
||||
const email = this.#getUsageReportMetadataValue(report, "email");
|
||||
if (email) identifiers.push(`email:${email.toLowerCase()}`);
|
||||
if (report.provider === "anthropic") {
|
||||
// Anthropic: one account email can hold several organizations
|
||||
// (Team seat + personal Max). Reports from different orgs must not
|
||||
// merge — scope every identifier by org when the report carries one.
|
||||
// When the email could not be recovered, fall back to the account
|
||||
// (identical across orgs, hence the org qualifier is what keeps two
|
||||
// subscriptions apart) so no-email reports still merge per org.
|
||||
// Org-less reports (pre-upgrade caches) keep their bare identifiers
|
||||
// and only merge among themselves.
|
||||
if (report.provider === "anthropic" || report.provider === "openai-codex") {
|
||||
// One account email can hold several org-scoped subscriptions
|
||||
// (Anthropic organizations, ChatGPT workspaces). Reports from
|
||||
// different orgs must not merge — scope every identifier by org
|
||||
// when the report carries one; fall back to the account when the
|
||||
// email could not be recovered so no-email reports still merge
|
||||
// per org. Org-less reports (pre-upgrade caches) keep their bare
|
||||
// identifiers and only merge among themselves.
|
||||
if (identifiers.length === 0) {
|
||||
const accountId =
|
||||
this.#getUsageReportMetadataValue(report, "accountId") ?? this.#getUsageReportScopeAccountId(report);
|
||||
@@ -3299,12 +3298,11 @@ export class AuthStorage {
|
||||
}
|
||||
const orgId = this.#getUsageReportMetadataValue(report, "orgId");
|
||||
if (orgId) {
|
||||
if (identifiers.length === 0) return [`anthropic:org:${orgId.toLowerCase()}`];
|
||||
return identifiers.map(identifier => `anthropic:org:${orgId.toLowerCase()}|${identifier.toLowerCase()}`);
|
||||
if (identifiers.length === 0) return [`${report.provider}:org:${orgId.toLowerCase()}`];
|
||||
return identifiers.map(
|
||||
identifier => `${report.provider}:org:${orgId.toLowerCase()}|${identifier.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
return identifiers.map(identifier => `anthropic:${identifier.toLowerCase()}`);
|
||||
}
|
||||
if (report.provider === "openai-codex") {
|
||||
return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
|
||||
}
|
||||
const projectId =
|
||||
@@ -5294,9 +5292,15 @@ export class AuthStorage {
|
||||
if (credential.type !== "oauth") continue;
|
||||
const credentialEmail = credential.email?.trim().toLowerCase();
|
||||
const credentialAccountId = credential.accountId?.trim().toLowerCase();
|
||||
if ((email && credentialEmail === email) || (accountId && credentialAccountId === accountId)) {
|
||||
matches.push(entry.id);
|
||||
}
|
||||
// Every identity dimension present on BOTH sides must agree — the
|
||||
// account id is shared workspace-wide and one email can span
|
||||
// workspaces, so a single-dimension match can cross-link siblings.
|
||||
const emailComparable = Boolean(email && credentialEmail);
|
||||
const accountComparable = Boolean(accountId && credentialAccountId);
|
||||
if (!emailComparable && !accountComparable) continue;
|
||||
if (emailComparable && credentialEmail !== email) continue;
|
||||
if (accountComparable && credentialAccountId !== accountId) continue;
|
||||
matches.push(entry.id);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -5906,16 +5910,15 @@ function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): Store
|
||||
|
||||
function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null {
|
||||
const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:"));
|
||||
if (provider === "anthropic") {
|
||||
// One Anthropic account email can hold several organizations (e.g. a
|
||||
// Team seat plus a personal Max plan), each with its own org-scoped
|
||||
// token and limit pools. Scope identity by org so both subscriptions
|
||||
// can be stored side by side. The qualifier rides on whichever base
|
||||
// identity is available — the account UUID is IDENTICAL across the
|
||||
// orgs of one login account, so an unqualified account/project
|
||||
// fallback would still collapse two subscriptions whenever the email
|
||||
// could not be recovered. Org-less credentials (rows written before
|
||||
// org capture existed) keep their bare key.
|
||||
if (provider === "anthropic" || provider === "openai-codex") {
|
||||
// One account email can hold several organizations/workspaces (e.g. a
|
||||
// Team seat plus a personal plan), each with its own org-scoped token
|
||||
// and limit pools. Scope identity by org so both subscriptions can be
|
||||
// stored side by side. The qualifier rides on whichever base identity
|
||||
// is available, so an unqualified account/project fallback would
|
||||
// still collapse two subscriptions whenever the email could not be
|
||||
// recovered. Org-less credentials (rows written before org capture
|
||||
// existed) keep their bare key.
|
||||
const base =
|
||||
emailIdentifier ??
|
||||
identifiers.find(identifier => identifier.startsWith("account:")) ??
|
||||
@@ -5925,7 +5928,6 @@ function resolveProviderCredentialIdentityKey(provider: string, identifiers: str
|
||||
// No base identity at all: the org alone still distinguishes the row.
|
||||
return orgIdentifier ?? null;
|
||||
}
|
||||
if (provider === "openai-codex" && emailIdentifier) return emailIdentifier;
|
||||
const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:"));
|
||||
if (accountIdentifier) return accountIdentifier;
|
||||
if (emailIdentifier) return emailIdentifier;
|
||||
@@ -5962,9 +5964,9 @@ function matchesReplacementCredential(
|
||||
if (incomingIdentityKey === existingIdentityKey) return true;
|
||||
if (existingIdentityKey === null) return false;
|
||||
// One-way upgrade, applied only when the INCOMING identity key carries the
|
||||
// org qualifier (only anthropic keys do, so other providers never reach the
|
||||
// checks below). An org-scoped login `org:<o>` claims (and re-keys) any
|
||||
// existing row that denotes the same subscription:
|
||||
// org qualifier (only anthropic and openai-codex keys do, so other
|
||||
// providers never reach the checks below). An org-scoped login `org:<o>`
|
||||
// claims (and re-keys) any existing row that denotes the same subscription:
|
||||
// - `org:<o>` — org-only row stored when identity recovery failed, claimed
|
||||
// once a later same-org login recovers a base identity;
|
||||
// - `<b>` for any base identity `<b>` (email/account/project) the incoming
|
||||
@@ -5988,10 +5990,16 @@ function matchesReplacementCredential(
|
||||
existing.type === "oauth" && existingIdentityKey.endsWith(`|${orgIdentifier}`)
|
||||
? extractOAuthCredentialIdentifiers(existing)
|
||||
: null;
|
||||
// A base identifier that merely repeats the org qualifier's id carries no
|
||||
// per-user identity (openai-codex stores the ChatGPT workspace id as both
|
||||
// accountId and orgId, shared by every member) — letting it act as a
|
||||
// claimable base would re-key another member's same-org row.
|
||||
const orgQualifierId = orgIdentifier.slice("org:".length);
|
||||
for (const identifier of incomingIdentifiers) {
|
||||
const isBase =
|
||||
identifier.startsWith("email:") || identifier.startsWith("account:") || identifier.startsWith("project:");
|
||||
if (!isBase) continue;
|
||||
if (identifier.slice(identifier.indexOf(":") + 1) === orgQualifierId) continue;
|
||||
if (existingIdentityKey === identifier) return true;
|
||||
if (existingIdentityKey === `${identifier}|${orgIdentifier}`) return true;
|
||||
if (existingIdentifiers?.includes(identifier)) return true;
|
||||
|
||||
@@ -31,6 +31,7 @@ const DEVICE_MAX_POLLS = 120;
|
||||
type JwtPayload = {
|
||||
[JWT_CLAIM_PATH]?: {
|
||||
chatgpt_account_id?: string;
|
||||
chatgpt_plan_type?: string;
|
||||
};
|
||||
[JWT_PROFILE_CLAIM]?: {
|
||||
email?: string;
|
||||
@@ -50,14 +51,27 @@ export function decodeJwt<T = Record<string, unknown>>(token: string): T | null
|
||||
}
|
||||
}
|
||||
|
||||
function getTokenProfile(accessToken: string): { accountId?: string; email?: string } {
|
||||
/**
|
||||
* Identity slice decoded from the token claims. The ChatGPT workspace
|
||||
* (`chatgpt_account_id`) is the subscription pool the token draws limits
|
||||
* from — one account email can hold several (e.g. a personal Pro plan plus a
|
||||
* Team seat). `chatgpt_plan_type` may only be present on the `id_token`.
|
||||
*/
|
||||
function getTokenProfile(
|
||||
accessToken: string,
|
||||
idToken?: string,
|
||||
): { accountId?: string; email?: string; planType?: string } {
|
||||
const payload = decodeJwt<JwtPayload>(accessToken);
|
||||
const idPayload = idToken ? decodeJwt<JwtPayload>(idToken) : null;
|
||||
const auth = payload?.[JWT_CLAIM_PATH];
|
||||
const idAuth = idPayload?.[JWT_CLAIM_PATH];
|
||||
const accountId = auth?.chatgpt_account_id;
|
||||
const email = payload?.[JWT_PROFILE_CLAIM]?.email?.trim().toLowerCase();
|
||||
const planType = (auth?.chatgpt_plan_type ?? idAuth?.chatgpt_plan_type)?.trim().toLowerCase();
|
||||
return {
|
||||
accountId: typeof accountId === "string" && accountId.length > 0 ? accountId : undefined,
|
||||
email: typeof email === "string" && email.length > 0 ? email : undefined,
|
||||
planType: typeof planType === "string" && planType.length > 0 ? planType : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,6 +199,7 @@ async function exchangeCodeForToken(
|
||||
const tokenData = (await tokenResponse.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
|
||||
@@ -192,7 +207,7 @@ async function exchangeCodeForToken(
|
||||
throw new AIError.OAuthError("Token response missing required fields", { kind: "validation" });
|
||||
}
|
||||
|
||||
const { accountId, email } = getTokenProfile(tokenData.access_token);
|
||||
const { accountId, email, planType } = getTokenProfile(tokenData.access_token, tokenData.id_token);
|
||||
if (!accountId) {
|
||||
throw new AIError.OAuthError("Failed to extract accountId from token", { kind: "validation" });
|
||||
}
|
||||
@@ -203,6 +218,8 @@ async function exchangeCodeForToken(
|
||||
expires: Date.now() + tokenData.expires_in * 1000,
|
||||
accountId,
|
||||
email,
|
||||
orgId: accountId,
|
||||
orgName: planType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -354,6 +371,9 @@ export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAu
|
||||
|
||||
const { accountId, email } = getTokenProfile(tokenData.access_token);
|
||||
|
||||
// Deliberately no org fields on the result: the workspace a credential is
|
||||
// scoped to is fixed at login. Callers merge refresh results over the
|
||||
// stored credential, so omitting org here preserves it verbatim.
|
||||
return {
|
||||
access: tokenData.access_token,
|
||||
refresh: tokenData.refresh_token || refreshToken,
|
||||
|
||||
@@ -12,8 +12,9 @@ export type OAuthCredentials = {
|
||||
apiEndpoint?: string;
|
||||
/**
|
||||
* Organization/workspace the token is scoped to (e.g. an Anthropic org
|
||||
* UUID). Captured once at login; token refreshes never rewrite it. Lets
|
||||
* one account email hold credentials for multiple subscriptions.
|
||||
* UUID or a ChatGPT workspace id). Captured once at login; token refreshes
|
||||
* never rewrite it. Lets one account email hold credentials for multiple
|
||||
* subscriptions.
|
||||
*/
|
||||
orgId?: string;
|
||||
/** Human-readable organization name for display (may embed the email). */
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* OpenAI Codex workspace-scoped credential identity.
|
||||
*
|
||||
* The ChatGPT workspace (`chatgpt_account_id`, captured as `orgId` at login)
|
||||
* is the subscription pool a Codex token draws limits from. One email can
|
||||
* hold a personal Plus/Pro plan plus Team/Enterprise seats — different
|
||||
* workspaces with independent pools — while every member of one workspace
|
||||
* shares the workspace id. Identity therefore composes `email|org:<ws>`:
|
||||
* - same email + same workspace => replace in place (re-login);
|
||||
* - same email + diff workspace => coexist (personal + enterprise seat);
|
||||
* - diff email + same workspace => coexist (two Team members, #197);
|
||||
* - workspace-less legacy rows keep their bare email key and are claimed
|
||||
* in place by the first workspace-scoped login with the same email.
|
||||
*/
|
||||
import { Database } from "bun:sqlite";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
type AuthCredential,
|
||||
type AuthCredentialStore,
|
||||
AuthStorage,
|
||||
SqliteAuthCredentialStore,
|
||||
type StoredAuthCredential,
|
||||
} from "@oh-my-pi/pi-ai/auth-storage";
|
||||
import type { UsageReport } from "@oh-my-pi/pi-ai/usage";
|
||||
import * as codexUsage from "@oh-my-pi/pi-ai/usage/openai-codex";
|
||||
import { removeWithRetries } from "../../utils/src/temp";
|
||||
|
||||
const EMAIL = "shared@example.com";
|
||||
const PERSONAL_WS = "ws-personal-1111";
|
||||
const TEAM_WS = "ws-team-2222";
|
||||
|
||||
function codexCredential(args: {
|
||||
suffix: string;
|
||||
accountId: string;
|
||||
/** Workspace qualifier; omitted for legacy rows written before workspace capture. */
|
||||
orgId?: string;
|
||||
orgName?: string;
|
||||
email?: string;
|
||||
}): AuthCredential {
|
||||
return {
|
||||
type: "oauth",
|
||||
access: `access-${args.suffix}`,
|
||||
refresh: `refresh-${args.suffix}`,
|
||||
expires: Date.now() + 3_600_000,
|
||||
accountId: args.accountId,
|
||||
email: args.email ?? EMAIL,
|
||||
orgId: args.orgId,
|
||||
orgName: args.orgName,
|
||||
};
|
||||
}
|
||||
|
||||
function readIdentityRows(dbPath: string): Array<{ identity_key: string | null; disabled_cause: string | null }> {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
try {
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT identity_key, disabled_cause FROM auth_credentials WHERE provider = 'openai-codex' ORDER BY id ASC",
|
||||
)
|
||||
.all() as Array<{ identity_key: string | null; disabled_cause: string | null }>;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe("openai-codex workspace-scoped credential identity", () => {
|
||||
let tempDir = "";
|
||||
let dbPath = "";
|
||||
let store: SqliteAuthCredentialStore | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-codex-ws-identity-"));
|
||||
dbPath = path.join(tempDir, "agent.db");
|
||||
store = await SqliteAuthCredentialStore.open(dbPath);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
store?.close();
|
||||
store = null;
|
||||
if (tempDir) await removeWithRetries(tempDir);
|
||||
});
|
||||
|
||||
it("stores a personal plan and an enterprise seat of one email side by side and updates same-workspace logins in place", () => {
|
||||
if (!store) throw new Error("test setup failed");
|
||||
|
||||
store.upsertAuthCredentialForProvider(
|
||||
"openai-codex",
|
||||
codexCredential({ suffix: "personal", accountId: PERSONAL_WS, orgId: PERSONAL_WS, orgName: "plus" }),
|
||||
);
|
||||
store.upsertAuthCredentialForProvider(
|
||||
"openai-codex",
|
||||
codexCredential({ suffix: "team", accountId: TEAM_WS, orgId: TEAM_WS, orgName: "enterprise" }),
|
||||
);
|
||||
|
||||
expect(readIdentityRows(dbPath)).toEqual([
|
||||
{ identity_key: `email:${EMAIL}|org:${PERSONAL_WS}`, disabled_cause: null },
|
||||
{ identity_key: `email:${EMAIL}|org:${TEAM_WS}`, disabled_cause: null },
|
||||
]);
|
||||
|
||||
// Same-workspace re-login: replaces the matching row instead of adding a third.
|
||||
const rows = store.upsertAuthCredentialForProvider(
|
||||
"openai-codex",
|
||||
codexCredential({ suffix: "team-renewed", accountId: TEAM_WS, orgId: TEAM_WS, orgName: "enterprise" }),
|
||||
);
|
||||
expect(readIdentityRows(dbPath)).toEqual([
|
||||
{ identity_key: `email:${EMAIL}|org:${PERSONAL_WS}`, disabled_cause: null },
|
||||
{ identity_key: `email:${EMAIL}|org:${TEAM_WS}`, disabled_cause: null },
|
||||
]);
|
||||
const teamRow = rows.find(row => row.credential.type === "oauth" && row.credential.orgId === TEAM_WS);
|
||||
expect(teamRow?.credential.type).toBe("oauth");
|
||||
if (teamRow?.credential.type === "oauth") {
|
||||
expect(teamRow.credential.access).toBe("access-team-renewed");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps two members of one workspace separate even though they share the workspace id", () => {
|
||||
if (!store) throw new Error("test setup failed");
|
||||
|
||||
store.upsertAuthCredentialForProvider(
|
||||
"openai-codex",
|
||||
codexCredential({ suffix: "alice", accountId: TEAM_WS, orgId: TEAM_WS, email: "alice@example.com" }),
|
||||
);
|
||||
store.upsertAuthCredentialForProvider(
|
||||
"openai-codex",
|
||||
codexCredential({ suffix: "bob", accountId: TEAM_WS, orgId: TEAM_WS, email: "bob@example.com" }),
|
||||
);
|
||||
|
||||
expect(readIdentityRows(dbPath)).toEqual([
|
||||
{ identity_key: `email:alice@example.com|org:${TEAM_WS}`, disabled_cause: null },
|
||||
{ identity_key: `email:bob@example.com|org:${TEAM_WS}`, disabled_cause: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("upgrades a legacy email-keyed row on the first workspace-scoped login with the same email", () => {
|
||||
if (!store) throw new Error("test setup failed");
|
||||
|
||||
store.upsertAuthCredentialForProvider(
|
||||
"openai-codex",
|
||||
codexCredential({ suffix: "legacy", accountId: PERSONAL_WS }),
|
||||
);
|
||||
expect(readIdentityRows(dbPath)).toEqual([{ identity_key: `email:${EMAIL}`, disabled_cause: null }]);
|
||||
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("never clobbers workspace-scoped rows with a workspace-less credential", () => {
|
||||
if (!store) throw new Error("test setup failed");
|
||||
|
||||
store.upsertAuthCredentialForProvider(
|
||||
"openai-codex",
|
||||
codexCredential({ suffix: "personal", accountId: PERSONAL_WS, orgId: PERSONAL_WS, orgName: "plus" }),
|
||||
);
|
||||
store.upsertAuthCredentialForProvider(
|
||||
"openai-codex",
|
||||
codexCredential({ suffix: "team", accountId: TEAM_WS, orgId: TEAM_WS, orgName: "enterprise" }),
|
||||
);
|
||||
store.upsertAuthCredentialForProvider(
|
||||
"openai-codex",
|
||||
codexCredential({ suffix: "orgless", accountId: PERSONAL_WS }),
|
||||
);
|
||||
|
||||
expect(readIdentityRows(dbPath)).toEqual([
|
||||
{ identity_key: `email:${EMAIL}|org:${PERSONAL_WS}`, disabled_cause: null },
|
||||
{ identity_key: `email:${EMAIL}|org:${TEAM_WS}`, disabled_cause: null },
|
||||
{ identity_key: `email:${EMAIL}`, disabled_cause: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Usage report dedupe partitioning ───────────────────────────────────────
|
||||
|
||||
interface CacheEntry {
|
||||
value: string;
|
||||
expiresAtSec: number;
|
||||
}
|
||||
|
||||
function makeStore(rows: StoredAuthCredential[]): AuthCredentialStore {
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
return {
|
||||
close() {},
|
||||
listAuthCredentials() {
|
||||
return rows;
|
||||
},
|
||||
updateAuthCredential() {},
|
||||
deleteAuthCredential() {},
|
||||
tryDisableAuthCredentialIfMatches() {
|
||||
return false;
|
||||
},
|
||||
replaceAuthCredentialsForProvider() {
|
||||
return rows;
|
||||
},
|
||||
upsertAuthCredentialForProvider() {
|
||||
return rows;
|
||||
},
|
||||
deleteAuthCredentialsForProvider() {},
|
||||
getCache(key) {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (entry.expiresAtSec * 1000 <= Date.now()) return null;
|
||||
return entry.value;
|
||||
},
|
||||
setCache(key, value, expiresAtSec) {
|
||||
cache.set(key, { value, expiresAtSec });
|
||||
},
|
||||
cleanExpiredCache() {},
|
||||
};
|
||||
}
|
||||
|
||||
function codexRow(
|
||||
id: number,
|
||||
args?: { orgId?: string; orgName?: string; accountId?: string; email?: string },
|
||||
): StoredAuthCredential {
|
||||
return {
|
||||
id,
|
||||
provider: "openai-codex",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: `oat-${id}`,
|
||||
refresh: `refresh-${id}`,
|
||||
expires: Date.now() + 3_600_000,
|
||||
accountId: args?.accountId ?? args?.orgId ?? "ws-legacy",
|
||||
email: args?.email ?? EMAIL,
|
||||
orgId: args?.orgId,
|
||||
orgName: args?.orgName,
|
||||
},
|
||||
disabledCause: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Report carrying ONLY email identity — workspace attribution must come from the credential. */
|
||||
function emailOnlyReport(email: string): UsageReport {
|
||||
return {
|
||||
provider: "openai-codex",
|
||||
fetchedAt: Date.now(),
|
||||
limits: [
|
||||
{
|
||||
id: "openai-codex:primary",
|
||||
label: "5 hours",
|
||||
scope: { provider: "openai-codex", windowId: "5h" },
|
||||
window: { id: "5h", label: "5 hours" },
|
||||
amount: { used: 42, limit: 100, unit: "percent" },
|
||||
status: "ok",
|
||||
},
|
||||
],
|
||||
metadata: { email },
|
||||
};
|
||||
}
|
||||
|
||||
describe("openai-codex usage report dedupe partitions by workspace", () => {
|
||||
let storage: AuthStorage | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
storage?.close();
|
||||
storage = null;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps reports from two workspaces on one email separate and attributes each to its workspace", async () => {
|
||||
storage = new AuthStorage(
|
||||
makeStore([
|
||||
codexRow(1, { orgId: PERSONAL_WS, orgName: "plus" }),
|
||||
codexRow(2, { orgId: TEAM_WS, orgName: "enterprise" }),
|
||||
]),
|
||||
{
|
||||
usageProviderResolver: provider =>
|
||||
provider === "openai-codex" ? codexUsage.openaiCodexUsageProvider : undefined,
|
||||
},
|
||||
);
|
||||
await storage.reload();
|
||||
|
||||
vi.spyOn(codexUsage.openaiCodexUsageProvider, "fetchUsage").mockImplementation(async () =>
|
||||
emailOnlyReport(EMAIL),
|
||||
);
|
||||
|
||||
const reports = ((await storage.fetchUsageReports()) ?? []).filter(r => r.provider === "openai-codex");
|
||||
expect(reports).toHaveLength(2);
|
||||
const orgIds = reports.map(report => report.metadata?.orgId).sort();
|
||||
expect(orgIds).toEqual([PERSONAL_WS, TEAM_WS].sort());
|
||||
const orgNames = reports.map(report => report.metadata?.orgName).sort();
|
||||
expect(orgNames).toEqual(["enterprise", "plus"].sort());
|
||||
});
|
||||
|
||||
it("still merges workspace-less reports with the same email into one row", async () => {
|
||||
storage = new AuthStorage(makeStore([codexRow(1), codexRow(2)]), {
|
||||
usageProviderResolver: provider =>
|
||||
provider === "openai-codex" ? codexUsage.openaiCodexUsageProvider : undefined,
|
||||
});
|
||||
await storage.reload();
|
||||
|
||||
vi.spyOn(codexUsage.openaiCodexUsageProvider, "fetchUsage").mockImplementation(async () =>
|
||||
emailOnlyReport(EMAIL),
|
||||
);
|
||||
|
||||
const reports = ((await storage.fetchUsageReports()) ?? []).filter(r => r.provider === "openai-codex");
|
||||
expect(reports).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user