Skip to content

v6.13.0

Choose a tag to compare

@ndycode ndycode released this 18 Aug 16:24
· 201 commits to main since this release

Added

  • Business workspace seats are now first-class identities. Every OAuth access token issued for a ChatGPT Business workspace carries a per-member chatgpt_account_user_id alongside the workspace-wide chatgpt_account_id. That member id is now extracted, persisted as accountUserId, and participates in account identity, deduplication, model-pool routing, quota accounting and diagnostics. Records written by earlier releases are backfilled from their stored access token wherever it still decodes; a record whose token has expired or is opaque keeps no member id and behaves exactly as it did before. (#230, #231)
  • codex-doctor and codex-health report colliding Business credentials. A new business-member-credential-conflict finding, and a businessMemberConflictSlots field in codex-health --json, flag account slots whose tokens resolve to the same member of the same workspace — records that cannot consume separate quotas and must be re-authenticated independently. Distinct workspace variants of a single OAuth grant, separated by organizationId, are legitimate and are not reported. (#231)

Fixed

  • Logging in as a second member of a Business workspace overwrote the first member's credential instead of adding a separate account. Every affected record ended up carrying the last logged-in user's email, access token and refresh token, so one seat was billed for the whole workspace and the displaced member's single-use refresh token was lost. Business members share one chatgpt_account_id, and account identity keyed on that value alone, so every member of a workspace resolved to the same stored record — the collision was silent because the shared id is a legitimate value, not a missing one. Identity now keys on the workspace and the token's chatgpt_account_user_id, so each seat occupies its own slot, keeps its own refresh token, and is metered against its own quota. Reported by @proamo, who traced it to the host auth fallback; fixed by @lubshad. (#230, #231)
  • A model pool could not target an individual Business seat. codex-pool stored the workspace-wide account id, which matches every member of that workspace, so a pool the operator scoped to one seat silently routed to all of them. Pool entries are now member-scoped seat: keys. Legacy workspace-wide entries deliberately keep matching every seat in their workspace, and are migrated to seat keys on the next codex-pool add/remove — except while project-scoped account storage is active, since modelAccountPools lives in the global config and expanding it against one project's visible seats would rewrite routing that other projects depend on. (#231)
  • A per-account circuit breaker could be inherited by an unrelated account. The breaker key embedded the positional account index, which removeAccount reassigns to survivors. Unlike the health, token and rate-limit trackers — all explicitly remapped after a removal — the breaker map is not, so whichever account shifted into a removed slot inherited that slot's OPEN breaker and was short-circuited out of rotation until the breaker half-opened. The key is now derived from the account's stable workspace identity, which no removal can change. (#231)
  • Three advisories reached consumers through the production dependency tree, and npm run audit:ci failed. Two were hono advisories (ReDoS in the CORS middleware, plus the same advisory reached transitively). @openauthjs/openauth was in the production tree for exactly one function — generatePKCE, called once from createAuthorizationFlow — and declared hono as a peer dependency, which was the only reason hono was a direct dependency and an override at all; nothing imports it. PKCE generation now lives in lib/auth/auth.ts with the wire format preserved exactly: 64 random bytes base64url-encoded to an 86-character verifier (RFC 7636 allows 43-128), challenge = base64url(SHA-256(ASCII(verifier))), both encoders unpadded. The upstream helper also returned method: "S256", which nothing read — the authorize request already hardcodes code_challenge_method=S256. Removing the package takes 11 packages out of the tree; the gate now reports 0. (#229)

Internal

Review follow-ups on the seat-identity work, each a correctness defect in the new code rather than a change of intent (#231):

  • The bare accountUserId: identity key is ranked below organizationId: and accountId:. One OAuth grant can back several workspace variants that all carry the same member id, and findAccountIndexByIdentityKeys returns the first key that matches, so at rank 2 the member key could resolve a single-use refresh-token write onto another workspace's record and leave that workspace holding a consumed token — a permanent auth failure.
  • A memberless legacy record is merged into its workspace's seat record when that workspace has exactly one seat. Because toAccountIdentityKey now returns a seat: key, a legacy twin whose access token no longer decodes kept the older organizationId: key and stopped deduplicating against its own newer entry, surviving as a live rotation slot with a dead refresh token. With two or more seats the record is left alone: there is no way to tell which member it belongs to, and a wrong merge is worse than a duplicate.
  • accountId-only fallback matching is retained when the fallback token carries a member id. The candidate set already excludes every record with a different member id, so matching on accountId cannot bind two seats together; refusing to match at all stranded records that predate member ids and pushed a duplicate slot for a credential that should have been hydrated in place.
  • accountUserId is guarded the way accountId already was, in both updateFromAuth and the Codex CLI cache hydration. A manually- or org-pinned record must not be re-identified by a token minted for a different workspace, which would move its pool key, usage dedupe key and workspace identity key while the accountId pin appeared to hold. The CLI cache is keyed by email, so one person's personal account and their Business seat both resolve there.
  • getModelPoolAccountKey falls back to the bearer token for the member id, matching every other member-id read in the codebase. Without the fallback, a record that bypassed the normalize backfill produced the bare workspace key, which then matched every seat in that workspace instead of the one the operator selected.
  • The usage-quota dedupe key retains organizationId. Replacing workspace identity with seat identity collapsed one member's two workspaces into a single quota row, contradicting both the rule that key had always documented and the per-workspace binding added in #227; the seat id now disambiguates members within a workspace rather than replacing it. The test that pinned the collapse was inverted, with the genuine split and collapse cases pinned alongside it.
  • findConflictingBusinessMemberCredentials no longer requires the grouped records to carry differing emails. #230 reports that every affected record ends up with the last login's email, so the differing-email gate stayed silent on exactly the corruption the scan exists to surface; distinct workspace variants, separated by organizationId, remain excluded.
  • updateModelAccountPool reports the account ids that were actually on disk. previousAccountIds may be expanded from legacy workspace keys purely to compute the next set, which made codex-pool print a "previous" count, and emit a previousConfiguredCount, that the config file never contained.
  • accountUserId is declared on AccountMetadataV3Schema and AccountMetadataV1Schema. Both interfaces persist it but zod strips undeclared keys, so any loader, import validator or migration routed through those schemas would have silently erased every seat identity and collapsed Business members back to the pre-fix behaviour.
  • The test suite must be run against a built tree. test/standalone-cli.test.ts exercises the packaged CLI and requires dist/, so its 12 cases fail in a clean checkout until npm run build has run.

Notes

  • Model pools are not rewritten on upgrade. A legacy workspace-wide pool entry keeps matching every seat in its workspace until the next codex-pool add/remove migrates it, and that migration is skipped entirely while project-scoped account storage is active.
  • Records already corrupted by #230 share one credential across several slots. They are reported by codex-doctor and codex-health but are deliberately not auto-collapsed: merging stored account records risks discarding a single-use refresh token, which would permanently break the account — the same reasoning applied to the duplicate rows left in place by #227. Remove the affected slots and re-login each member separately.