diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index a60a7f21685..040802bc890 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -247,6 +247,14 @@ Codex pool selection applies to the next request after clearing existing affinit { accounts: AccountRow[], notes: string[] } ``` +Codex JSON 행에는 등록된 `id`와 같은 `slotId`, 그리고 `native-main` 또는 `pool`인 +`slotKind`가 포함됩니다. 프록시가 상위 계정과 사용자 식별값을 모두 확인하면 +`uai1_` 뒤에 소문자 16진수 64자리가 붙는 `upstreamAccountKey`도 제공합니다. +같은 `openai` 제공자에서 키가 같으면 한 상위 사용자의 슬롯이며, 각 슬롯과 한도 상태는 +그대로 유지해야 합니다. 키는 이 설치 환경에 한정되고 관리자 토큰이 바뀌면 달라질 수 +있습니다. 키가 없을 때는 가려진 이메일이나 한도가 비슷하다는 이유로 묶지 않습니다. +상위 계정 ID·사용자 식별값·인증 정보 원문은 목록에 포함되지 않습니다. + ### `ocx account current [--json]` 활성 계정이나 키를 보여줍니다. 수동 고정이 없는 Codex 풀은 우선순위를 고려한 자동 선택을 보고합니다. 가장 우선순위가 높은 적격 tier를 고르고, 그 tier 안에서 할당량 라우팅 기준으로 가장 적게 사용한 항목을 선택합니다. 활성 자격 증명이 없는 다른 계열은 그 상태를 보고하고도 종료 코드 0으로 끝납니다. `--json`은 다음을 반환합니다: diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 1ac81f11ec9..4f588c19ac8 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -684,6 +684,14 @@ returns: { accounts: AccountRow[], notes: string[] } ``` +Codex JSON rows include `slotId` (the registered `id`) and `slotKind` (`native-main` or +`pool`). When the proxy can verify both the upstream account and user identity, it also +includes `upstreamAccountKey` (`uai1_` followed by 64 lowercase hexadecimal characters). +Equal keys within the `openai` provider identify slots for the same upstream user; keep +each slot and its quota state separately. The key is local to this installation and can +change when its administrator token rotates. If it is absent, do not infer a match from +masked email or quota values. Raw upstream IDs, user claims and credentials are not listed. + An empty provider listing names the next command in human output and JSON `notes`: `ocx account login ` for OAuth/Codex, or `ocx account add-key ` with a human-controlled piped stdin source for API keys. An empty global listing diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index 6ea43d96d9c..7a009dcaefd 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -23,6 +23,9 @@ export interface AccountRow { provider: string; type: AccountType; id: string; + slotId?: string; + slotKind?: "native-main" | "pool"; + upstreamAccountKey?: string; label?: string; email?: string; /** @@ -329,6 +332,9 @@ export interface ProviderQuotaReportDto { interface CodexAccountDto { id: string; + slotId?: string; + slotKind?: "native-main" | "pool"; + upstreamAccountKey?: string; alias?: string; email?: string; plan?: string; @@ -401,6 +407,11 @@ export async function fetchCodexRows( provider: "openai", type: "codex" as const, id: a.id, + slotId: a.id, + slotKind: a.id === "__main__" ? "native-main" as const : "pool" as const, + ...(a.slotId === a.id && typeof a.upstreamAccountKey === "string" + && /^uai1_[0-9a-f]{64}$/.test(a.upstreamAccountKey) + ? { upstreamAccountKey: a.upstreamAccountKey } : {}), label: a.alias ?? a.plan ?? a.email, email: a.email, plan: a.plan, diff --git a/src/codex/auth-api/account-identity.ts b/src/codex/auth-api/account-identity.ts new file mode 100644 index 00000000000..53d8f296357 --- /dev/null +++ b/src/codex/auth-api/account-identity.ts @@ -0,0 +1,14 @@ +import { createHmac } from "node:crypto"; + +/** A local, versioned join key for two slots backed by the same upstream user. */ +export function codexUpstreamAccountKey( + adminToken: string | null, + accountId: string | null | undefined, + userId: string | null | undefined, +): string | undefined { + if (!adminToken || !accountId || !userId || accountId.length > 512 || userId.length > 512) return undefined; + return `uai1_${createHmac("sha256", adminToken) + .update("opencodex-upstream-account-v1\0") + .update(JSON.stringify([accountId, userId])) + .digest("hex")}`; +} diff --git a/src/codex/auth-api/account-list.ts b/src/codex/auth-api/account-list.ts index c3046363f1f..4576e4efdc9 100644 --- a/src/codex/auth-api/account-list.ts +++ b/src/codex/auth-api/account-list.ts @@ -34,6 +34,10 @@ import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_QUOTA_REFRESH_CONC import type { PoolQuotaResult } from "./pool-quota-probe"; import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config"; import { isPassiveRead } from "../../lib/passive-read"; +import { configuredAdminToken } from "../../lib/admin-secrets"; +import { nativeUserIdClaims } from "../reserve-availability"; +import { extractAccountIdClaims } from "../../oauth/chatgpt"; +import { codexUpstreamAccountKey } from "./account-identity"; import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate, getObservedMainCodexCollisionIdentity } from "../collision-identity"; export function quotaForPlan | StoredAccountQuota | null>( @@ -109,6 +113,7 @@ export function poolAccountDto( paused: boolean, priority: number, maskEmails: boolean, + upstreamAccountKey?: string, ): CodexAuthAccountDto { const plan = codexPlanValue(account.plan); const quota = quotaForPlan(quotaResult.quota, plan); @@ -136,6 +141,9 @@ export function poolAccountDto( const duplicateMain = configuredCodexMainDuplicate(config, account.id); return { id: account.id, + slotId: account.id, + slotKind: "pool", + ...(upstreamAccountKey ? { upstreamAccountKey } : {}), email: projectEmail(account.email, maskEmails) ?? account.email, ...(account.alias !== undefined ? { alias: account.alias } : {}), ...(plan !== undefined ? { plan } : {}), @@ -166,6 +174,11 @@ export function poolAccountDto( export interface CodexAuthAccountDto { id: string; + /** Registered slot identity; equal to id, including __main__ for native Codex. */ + slotId: string; + slotKind: "native-main" | "pool"; + /** Opaque local join key, present only with account and user claims. */ + upstreamAccountKey?: string; alias?: string; email: string; plan?: string | null; @@ -285,6 +298,7 @@ export async function listCodexAuthAccountsSnapshot( pruneCodexCredits([MAIN_CODEX_ACCOUNT_ID, ...poolAccounts.map(account => account.id)]); // One redaction decision for the whole snapshot, read once from the operator's config (#3859). const maskEmails = emailMaskingEnabled(runtimeConfig); + const identitySecret = configuredAdminToken(); // A passive read (a Remote Link Child's, #6959) reports what this machine already observed: no // token refresh, no WHAM probe, and so none of the plan or credential writes they make. const passive = isPassiveRead(); @@ -292,6 +306,7 @@ export async function listCodexAuthAccountsSnapshot( ? { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: false, hasCredential: false, identityGeneration: captureMainAccountIdentityGeneration() } : await fetchMainAccountInfoAttempt(forceRefresh, 1, undefined, false, forceRefresh, false, runtimeConfig); + const observedMainIdentity = getObservedMainCodexCollisionIdentity(); const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => { const cred = getCodexAccountCredential(account.id); let quotaResult: PoolQuotaResult; @@ -363,6 +378,10 @@ export async function listCodexAuthAccountsSnapshot( const dtoAccount = generationLive && quotaResult.freshPlan ? { ...currentAccount, plan: quotaResult.freshPlan } : currentAccount; + const user = nativeUserIdClaims(currentCredential.accessToken); + const accountClaim = extractAccountIdClaims(currentCredential.accessToken); + const verifiedAccountId = !accountClaim.conflict && accountClaim.accountId === currentCredential.chatgptAccountId + ? accountClaim.accountId : undefined; return [poolAccountDto( runtimeConfig, dtoAccount, @@ -371,6 +390,8 @@ export async function listCodexAuthAccountsSnapshot( isCodexAccountPaused(runtimeConfig, accountId), getCodexAccountPriority(runtimeConfig, accountId), maskEmails, + codexUpstreamAccountKey(identitySecret, verifiedAccountId, + user.conflict ? undefined : user.userId), )]; }); const projectSnapshot = (mainRefreshRefused = false): CodexAuthAccountsSnapshot => { @@ -402,8 +423,14 @@ export async function listCodexAuthAccountsSnapshot( ? mainResult.terminalAuthFailure === true && liveQuotaRefresh?.status === "http_error" ? "unauthorized" : "refresh_failed" : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined; + const mainIdentity = mainSnapshotLive && getObservedMainCodexCollisionIdentity() === observedMainIdentity + ? observedMainIdentity : undefined; + const mainAccountKey = codexUpstreamAccountKey(identitySecret, mainIdentity?.accountId, mainIdentity?.userId); const main: CodexAuthAccountDto = { id: MAIN_CODEX_ACCOUNT_ID, + slotId: MAIN_CODEX_ACCOUNT_ID, + slotKind: "native-main", + ...(mainAccountKey ? { upstreamAccountKey: mainAccountKey } : {}), email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login", plan: mainInfo.plan, ...(liveQuotaRefresh ? { quotaRefresh: liveQuotaRefresh } : {}), diff --git a/src/codex/collision-identity.ts b/src/codex/collision-identity.ts index 69124e0df9e..e32bb3ed65a 100644 --- a/src/codex/collision-identity.ts +++ b/src/codex/collision-identity.ts @@ -1,12 +1,14 @@ -import { extractAccountIdClaims, extractEmail } from "../oauth/chatgpt"; +import { decodeJwtPayload, extractAccountIdClaims, extractEmail } from "../oauth/chatgpt"; import type { CodexAccount, OcxConfig } from "../types"; import { getCodexAccountCredential } from "./account-store"; import type { CodexTokens } from "./auth-collision"; import { captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "./main-account-cache"; import { codexPlanKey, extractChatgptPlanType } from "./plan"; +import { nativeUserIdClaims } from "./reserve-availability"; export interface CodexCollisionIdentity { accountId: string; + userId?: string; email?: string | null; plan?: unknown; } @@ -34,19 +36,40 @@ function homeScope(): string { let observed: { identity: CodexCollisionIdentity; homeScope: string; generation: number } | undefined; +function suppressObservedUserId(): void { + if (!observed || observed.homeScope !== homeScope() + || !isMainAccountIdentityGenerationLive(observed.generation)) return; + observed = { ...observed, identity: { ...observed.identity, userId: undefined } }; +} + /** Only existing owned lifecycle observations may publish; never call with a request bearer. */ export function observeMainCodexCollisionIdentity(tokens?: CodexTokens): void { if (!tokens) { observed = undefined; return; } if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string" - || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) return; + || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) { + suppressObservedUserId(); + return; + } const claims = [extractAccountIdClaims(tokens.id_token), extractAccountIdClaims(tokens.access_token)]; - if (claims.some(claim => claim.conflict)) return; + if (claims.some(claim => claim.conflict)) { suppressObservedUserId(); return; } const accountId = tokens.account_id || claims.find(claim => claim.accountId)?.accountId; - if (!accountId || claims.some(claim => claim.accountId && claim.accountId !== accountId)) return; + if (!accountId || claims.some(claim => claim.accountId && claim.accountId !== accountId)) { + suppressObservedUserId(); + return; + } const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)] .flatMap(email => normalizedEmail(email) ? [normalizedEmail(email)!] : [])); - if (emails.size > 1) return; + if (emails.size > 1) { suppressObservedUserId(); return; } + const idPayload: unknown = tokens.id_token === undefined ? undefined : decodeJwtPayload(tokens.id_token); + const idTokenMalformed = tokens.id_token !== undefined + && (idPayload === null || typeof idPayload !== "object" || Array.isArray(idPayload)); + const accessUser = nativeUserIdClaims(tokens.access_token); + const idUser = tokens.id_token ? nativeUserIdClaims(tokens.id_token) : undefined; + const userId = !idTokenMalformed && !accessUser.conflict && !idUser?.conflict + && (!idUser?.userId || idUser.userId === accessUser.userId) + ? accessUser.userId : undefined; observed = { identity: { accountId, email: [...emails][0], + ...(userId ? { userId } : {}), plan: extractChatgptPlanType(tokens.id_token, tokens.access_token) }, homeScope: homeScope(), generation: captureMainAccountIdentityGeneration() }; } diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 52f1d0b5fe0..1d73ecf0c10 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -37,6 +37,7 @@ preserving a stale one would block every later migration. ## Model and wire identity +Account-list rows retain each registered `slotId` and `slotKind`; optional `upstreamAccountKey` is a versioned HMAC of the ChatGPT account ID and user claim under the local administrator token. Native identity comes from the owned observation without passive credential reads; missing or conflicting claims omit the key, and token rotation changes it. No raw identity or credential is serialized. The optional [native reasoning retention policy](../transports/native-reasoning-retention.md#runtime-boundary) can permit reasoning ciphertext forwarding across a known model or account change on the same canonical ChatGPT destination. It does not change account selection, entitlement, cache isolation, continuation-id ownership or uploaded-file retention; cross-account acceptance is not guaranteed. Native Spark membership and its model-specific request/tool exceptions are removed; the shared diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 96c72a95266..9d1e10c42b6 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -762,6 +762,24 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(new Set(parsed.accounts.map(row => row.type))).toEqual(new Set(["codex", "oauth", "api-key"])); }); + test("official --all --json output carries the upstream join contract", async () => { + const key = `uai1_${"a".repeat(64)}`; + codexAccounts = [ + { id: "__main__", slotId: "__main__", slotKind: "native-main", upstreamAccountKey: key, + email: "main@example.test", plan: "pro" }, + { id: "chatgpt_1", slotId: "chatgpt_1", slotKind: "pool", upstreamAccountKey: key, + email: "pool@example.test", plan: "pro" }, + ]; + const result = await run(["list", "--all", "--json"]); + const rows = (JSON.parse(result.stdout) as { accounts: Array> }).accounts + .filter(row => row.provider === "openai"); + expect(result.code).toBe(0); + expect(rows.map(row => [row.slotId, row.slotKind, row.upstreamAccountKey])).toEqual([ + ["__main__", "native-main", key], + ["chatgpt_1", "pool", key], + ]); + }); + test("3: empty providers are skipped by default and shown with --all", async () => { const normal = await run(["list"]); const withAll = await run(["list", "--all"]); diff --git a/tests/cli/cli-dto-fidelity.test.ts b/tests/cli/cli-dto-fidelity.test.ts index bae0bff7d36..468df021518 100644 --- a/tests/cli/cli-dto-fidelity.test.ts +++ b/tests/cli/cli-dto-fidelity.test.ts @@ -193,6 +193,35 @@ describe("#2703 the projection does not strip the 5h window", () => { }); }); +describe("Codex account identity projection", () => { + test("keeps validated join keys and slot identities while accepting older servers", async () => { + const key = `uai1_${"a".repeat(64)}`; + const fetchImpl = (async (url: string | URL | Request) => Response.json( + String(url).includes("/api/codex-auth/active") + ? { activeCodexAccountId: "__main__" } + : { accounts: [ + { id: "__main__", slotId: "__main__", slotKind: "native-main", upstreamAccountKey: key }, + { id: "pool-a", slotId: "pool-a", slotKind: "pool", upstreamAccountKey: key }, + { id: "pool-b", slotId: "other-slot", upstreamAccountKey: key }, + { id: "pool-legacy" }, + { id: "pool-object", slotId: "pool-object", upstreamAccountKey: { toString: null, valueOf: null } }, + { id: "pool-array", slotId: "pool-array", upstreamAccountKey: [key] }, + ] }, + )) as unknown as typeof fetch; + const { fetchCodexRows } = await import("../../src/cli/account-api"); + const result = await fetchCodexRows({ baseUrl: "http://127.0.0.1:10100", fetchImpl }, + "http://127.0.0.1:10100"); + expect(result.rows.map(row => [row.slotId, row.slotKind, row.upstreamAccountKey])).toEqual([ + ["__main__", "native-main", key], + ["pool-a", "pool", key], + ["pool-b", "pool", undefined], + ["pool-legacy", "pool", undefined], + ["pool-object", "pool", undefined], + ["pool-array", "pool", undefined], + ]); + }); +}); + describe("#2705 access key usage columns", () => { async function listOutput(payload: Record): Promise { const { handleAccessCommand } = await import("../../src/cli/access"); diff --git a/tests/codex-integration/codex-stored-main-duplicate.test.ts b/tests/codex-integration/codex-stored-main-duplicate.test.ts index 4082e770537..daba4f87eaf 100644 --- a/tests/codex-integration/codex-stored-main-duplicate.test.ts +++ b/tests/codex-integration/codex-stored-main-duplicate.test.ts @@ -14,6 +14,7 @@ import { clearThreadAccountMap, clearCodexUpstreamHealth } from "../../src/codex import { clearAccountQuota } from "../../src/codex/quota"; import { clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; import { setMainAccountPlan } from "../../src/codex/main-account"; +import { observeMainCodexCollisionIdentity } from "../../src/codex/collision-identity"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -23,16 +24,18 @@ let previousOcxHome: string | undefined; let config: OcxConfig; const poolId = "stored-main-duplicate"; const email = "member@example.test"; -function jwt(plan: string, member?: string): string { +function jwt(plan: string, member?: string, userId?: string, accountId = "shared-account"): string { return `header.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, - "https://api.openai.com/auth": { chatgpt_plan_type: plan }, + chatgpt_account_id: accountId, + "https://api.openai.com/auth": { chatgpt_plan_type: plan, ...(userId ? { chatgpt_user_id: userId } : {}) }, ...(member ? { "https://api.openai.com/profile": { email: member } } : {}), })).toString("base64url")}.fake`; } -function native(accountId = "shared-account", plan = "plus", member: string | undefined = email, observe = true): void { +function native(accountId = "shared-account", plan = "plus", member: string | undefined = email, + observe = true, userId?: string): void { writeFileSync(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ - tokens: { access_token: jwt(plan, member), account_id: accountId }, + tokens: { access_token: jwt(plan, member, userId, accountId), account_id: accountId }, })); if (observe) reconcileMainCodexAccountRuntimeState(); } @@ -83,6 +86,74 @@ afterEach(() => { }); describe("stored Pool duplicate of native main", () => { + test("official rows join the same upstream user without exposing identity claims", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + native("shared-account", "plus", email, true, "same-user"); + saveCodexAccountCredential(poolId, { accessToken: jwt("plus", email, "same-user"), + refreshToken: "fake-refresh", chatgptAccountId: "shared-account", expiresAt: Date.now() + 3600_000 }); + const rows = await runPassiveRead(() => listCodexAuthAccounts(config)); + const main = rows.find(row => row.id === "__main__")!; + const pool = rows.find(row => row.id === poolId)!; + expect(main).toMatchObject({ slotId: "__main__", slotKind: "native-main" }); + expect(pool).toMatchObject({ slotId: poolId, slotKind: "pool" }); + expect(main.upstreamAccountKey).toMatch(/^uai1_[0-9a-f]{64}$/); + expect(pool.upstreamAccountKey).toBe(main.upstreamAccountKey); + expect(JSON.stringify(rows)).not.toContain("same-user"); + expect(JSON.stringify(rows)).not.toContain("shared-account"); + + saveCodexAccountCredential(poolId, { accessToken: jwt("plus", email, "other-user"), + refreshToken: "fake-refresh", chatgptAccountId: "shared-account", expiresAt: Date.now() + 3600_000 }); + const changed = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(changed.find(row => row.id === poolId)?.upstreamAccountKey).not.toBe(main.upstreamAccountKey); + expect(changed.find(row => row.id === "__main__")?.upstreamAccountKey).toBe(main.upstreamAccountKey); + }); + + test("a missing user claim or local secret preserves separate registered slots", async () => { + native(); + const noSecret = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(noSecret.map(row => row.slotKind)).toEqual(["native-main", "pool"]); + expect(noSecret.every(row => row.upstreamAccountKey === undefined)).toBe(true); + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + const noClaim = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(noClaim.every(row => row.upstreamAccountKey === undefined)).toBe(true); + }); + test("conflicting native user claims and pool account claims omit join keys", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + writeFileSync(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ tokens: { + access_token: jwt("plus", email, "access-user"), + id_token: jwt("plus", email, "different-user"), + account_id: "shared-account", + } })); + reconcileMainCodexAccountRuntimeState(); + saveCodexAccountCredential(poolId, { accessToken: jwt("plus", email, "access-user", "other-account"), + refreshToken: "fake-refresh", chatgptAccountId: "shared-account", expiresAt: Date.now() + 3600_000 }); + const rows = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(rows).toHaveLength(2); + expect(rows.every(row => row.upstreamAccountKey === undefined)).toBe(true); + expect(rows.map(row => row.slotId)).toEqual(["__main__", poolId]); + }); + test("conflicting replacement native claims remove a previously observed join key", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + native("shared-account", "plus", email, true, "same-user"); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toMatch(/^uai1_/); + observeMainCodexCollisionIdentity({ + access_token: jwt("plus", email, "same-user", "other-account"), + account_id: "shared-account", + }); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toBeUndefined(); + }); + test("malformed present native ID token suppresses a previously observed join key", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + native("shared-account", "plus", email, true, "same-user"); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toMatch(/^uai1_/); + observeMainCodexCollisionIdentity({ + access_token: jwt("plus", email, "same-user"), + id_token: "malformed", + account_id: "shared-account", + }); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toBeUndefined(); + expect(codexAccountUnusableReason(config, poolId)).toBe("duplicate_main"); + }); test("existing duplicate is ineligible without deleting its credential or config row", () => { native(); expect(codexAccountUnusableReason(config, poolId)).toBe("duplicate_main");