You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Usage tab hides a Codex workspace seat that shares an email with a personal account #9058
One email can hold a personal ChatGPT account and a seat in a ChatGPT workspace. I sign each one in to Codex from its own CODEX_HOME. Settings → Usage → Codex shows only one card for that email, the personal one. The workspace seat's card is missing while both logins work.
The workspace card shows up only when its usage read fails, and then it reads "Sign-in expired". So a workspace seat is visible only while it is broken.
A hidden home also can't be made the default or removed. Both mutations check the selection against the same list and answer BAD_REQUEST ("No codex login found at …").
Expected: one card per ChatGPT account. The personal account and the workspace seat each get a card with their own plan and weekly window.
Steps to reproduce
Sign in with a personal ChatGPT account: codex login (default ~/.codex).
Sign in to a workspace seat on the same email from another home: CODEX_HOME=~/.codex-work codex login, and choose the workspace.
Open Settings → Usage. Only one Codex card shows for that email.
Cause
fetchCodexAccounts() in packages/host-service/src/trpc/router/usage/codex.ts dedupes the cards by account.email ?? account.accountKey. The default home sorts first, so the workspace seat is dropped whenever both reads succeed. A failed read has no email, so its key falls back to its auth.json path and it is not deduped. That is why the seat appears only when it is broken.
The fix is to key the dedupe by email plus the ChatGPT account id. The same file already reads that id from tokens.account_id to send as chatgpt-account-id. A PR follows.
I've confirmed the dedupe bug in codex.ts and checked the related mutations, so I'm writing up the triage comment now.
Summary
Two Codex logins can share one email, for example a personal ChatGPT account and a ChatGPT workspace seat, each signed in from its own CODEX_HOME. When both usage reads succeed, Settings → Usage shows only one card for that email. The workspace seat's card appears only when its usage read fails, and then it shows "Sign-in expired". While the seat is hidden, it also can't be made the default or removed: both mutations look for the selection in the same deduped list and fail with BAD_REQUEST. Reading the code confirms the report and the cause it gives.
Affected code
packages/host-service/src/trpc/router/usage/codex.ts:115-123: fetchCodexAccounts() dedupes cards with account.email ?? account.accountKey and keeps the first one. discoverCodexHomes() puts the default home first (codex.ts:107-109), so the personal ~/.codex card wins and the workspace card is dropped.
codex.ts:147 and codex.ts:185-191: a failed read returns email: null, so the key falls back to accountKey, which is the auth.json path (codex.ts:204). Each home's path is different, so a failed card is never deduped. That is why the seat shows up only while it is broken.
packages/host-service/src/trpc/router/usage/usage.ts:208-219 (set default) and usage.ts:258-262 (remove): both check account.selection === input.selection against getQuota(false), which is the deduped list. A hidden home's selection is never found, so the mutation throws No codex login found at ….
Root cause
The dedupe treats the email as the account identity. ChatGPT can put several accounts under one email (personal plus workspace or team seats), and tokens.account_id is what tells them apart. codex.ts already reads that id (codex.ts:241) and sends it as chatgpt-account-id (codex.ts:152), but never uses it for the dedupe. Other code in the repo already keys Codex identity on the account id: session-account/session-account.ts:60-72 hashes [account_id, sub, email].
Reproduction
Manual:
Run codex login and sign in with a personal account (writes ~/.codex/auth.json).
Run CODEX_HOME=~/.codex-work codex login and choose a workspace on the same email.
Open Settings → Usage. Only one Codex card shows.
bun:test sketch, with no network:
Create two temporary Codex homes. Give each an auth.json with a different tokens.account_id.
Stub discoverCodexHomes (or move the dedupe into a pure helper and test that) and stub fetch so both requests return { email: "same@x.com", plan_type, rate_limit: {...} }.
Assert that fetchCodexAccounts() returns 2 accounts. It returns 1 today.
Add a control case: two homes with the same account_id and the same email should still collapse to 1 card.
Fix direction
Use email plus tokens.account_id as the dedupe key. If either value is missing, fall back to accountKey as today, which also covers API-key homes.
account_id is not on UsageAccount today. Either return it from fetchCodexAccountForHome for the key only, or build the key there and filter on it before the account goes into the list. Don't add it to the client-facing type unless it's needed, because released clients call this API (see packages/trpc/AGENTS.md).
Optional hardening: check the selection in usage.ts:208 and :258 against the discovered homes rather than the deduped quota list. A home that is hidden for any reason could then still be made the default or removed.
Activity
github-actions commentedon Oct 29, 2026
I've confirmed the dedupe bug in
codex.tsand checked the related mutations, so I'm writing up the triage comment now.Summary
Two Codex logins can share one email, for example a personal ChatGPT account and a ChatGPT workspace seat, each signed in from its own
CODEX_HOME. When both usage reads succeed, Settings → Usage shows only one card for that email. The workspace seat's card appears only when its usage read fails, and then it shows "Sign-in expired". While the seat is hidden, it also can't be made the default or removed: both mutations look for the selection in the same deduped list and fail withBAD_REQUEST. Reading the code confirms the report and the cause it gives.Affected code
packages/host-service/src/trpc/router/usage/codex.ts:115-123:fetchCodexAccounts()dedupes cards withaccount.email ?? account.accountKeyand keeps the first one.discoverCodexHomes()puts the default home first (codex.ts:107-109), so the personal~/.codexcard wins and the workspace card is dropped.codex.ts:147andcodex.ts:185-191: a failed read returnsemail: null, so the key falls back toaccountKey, which is theauth.jsonpath (codex.ts:204). Each home's path is different, so a failed card is never deduped. That is why the seat shows up only while it is broken.packages/host-service/src/trpc/router/usage/usage.ts:208-219(set default) andusage.ts:258-262(remove): both checkaccount.selection === input.selectionagainstgetQuota(false), which is the deduped list. A hidden home's selection is never found, so the mutation throwsNo codex login found at ….Root cause
The dedupe treats the email as the account identity. ChatGPT can put several accounts under one email (personal plus workspace or team seats), and
tokens.account_idis what tells them apart.codex.tsalready reads that id (codex.ts:241) and sends it aschatgpt-account-id(codex.ts:152), but never uses it for the dedupe. Other code in the repo already keys Codex identity on the account id:session-account/session-account.ts:60-72hashes[account_id, sub, email].Reproduction
Manual:
codex loginand sign in with a personal account (writes~/.codex/auth.json).CODEX_HOME=~/.codex-work codex loginand choose a workspace on the same email.bun:testsketch, with no network:auth.jsonwith a differenttokens.account_id.discoverCodexHomes(or move the dedupe into a pure helper and test that) and stubfetchso both requests return{ email: "same@x.com", plan_type, rate_limit: {...} }.fetchCodexAccounts()returns 2 accounts. It returns 1 today.account_idand the same email should still collapse to 1 card.Fix direction
tokens.account_idas the dedupe key. If either value is missing, fall back toaccountKeyas today, which also covers API-key homes.account_idis not onUsageAccounttoday. Either return it fromfetchCodexAccountForHomefor the key only, or build the key there and filter on it before the account goes into the list. Don't add it to the client-facing type unless it's needed, because released clients call this API (seepackages/trpc/AGENTS.md).usage.ts:208and:258against the discovered homes rather than the deduped quota list. A home that is hidden for any reason could then still be made the default or removed.