Individual model auth: per-user Claude/ChatGPT accounts drive each turn (#707)
* feat: support Codex ChatGPT OAuth auth * test: use trusted Codex OAuth fixtures * fix: persist rotated Codex OAuth tokens * style: format OAuth regression test * fix: preserve OAuth state during runtime replacement * fix: release stale OAuth runtime locks * test: cover same-process stale OAuth locks * fix: bound Codex setup requests * fix: bound OAuth runtime recovery * fix: cancel timed out Codex requests * style: simplify request signal selection * fix: harden Codex turn cancellation * fix: harden Codex turn cancellation * fix: harden OAuth isolation and cleanup * fix: fail closed on OAuth cleanup errors * test: stabilize Codex OAuth cancellation coverage * fix: bind Codex OAuth token updates to account * fix: reject unverified Codex OAuth token rotation * fix: verify rotated Codex OAuth JWTs * fix: clean up Codex OAuth state on close failure * fix: allow Codex OAuth lock recovery after release errors * fix: cancel timed out durable Codex records * fix: cancel PostgreSQL LLM records on timeout * fix: safely cancel queued Postgres records * fix: count harness-carried model auth in surface config and admin onboarding Under HARNESS=claude with only CLAUDE_CODE_OAUTH_TOKEN (or ANTHROPIC_AUTH_TOKEN), turns work but the deployment reported itself unconfigured: surface-config's modelProviderConfigured only consulted the credential store, and the admin onboarding badge said 'Needs a key'. harnessCarriedModelAuth(config) names the provider a harness authenticates on its own (claude -> anthropic via OAuth/auth token, codex -> openai via CODEX_ACCESS_TOKEN). It is OR'd into modelProviderConfigured and exposed to the admin as a sibling harnessAuth field on GET /v1/admin/model-providers. The credential-store statuses stay untouched: anthropic still reports absent, because those keys feed pi-transport calls and deleting or adding them is independent of harness OAuth. * fix: restore lazy custom boot-default resolution in serverDeps (merge regression) * refactor: simplify Codex OAuth lifecycle Co-Authored-By: QM <qm@ycombinator.com> * Keychain custody for subscription harness auth Invert PR 690's auth flow: the subscription login is a per-user keychain credential, core is the single custodian and refresher, and harnesses receive derived ephemeral material at spawn (Codex: minimal auth.json without the refresh token; Claude: injected env token). - CodexAuthStore abstraction: keychainCodexAuthStore (production, CODEX_AUTH_CREDENTIAL) and fileCodexAuthStore (local dev, CODEX_AUTH_FILE), both with central refresh and single-flight rotation - claude harness authEnv hook + keychainHarnessAuthEnv (CLAUDE_AUTH_CREDENTIAL) - child auth.json never carries the refresh token; no sync-back path, so the lock-file persistence machinery and JWKS re-verification are gone - production ban now applies only to the file path; keychain path is the supported production route * Individual model auth: per-user Claude/ChatGPT accounts drive each turn When the new org-wide "Individual authorization for AI usage" toggle is on, every user connects their own AI account before chatting, and their turns run on that account instead of the org's shared credentials: - Claude subscription sign-in (PKCE against claude.ai) routes the user's turns to the claude harness with their CLAUDE_CODE_OAUTH_TOKEN injected per turn. - ChatGPT subscription sign-in (device-code flow against auth.openai.com) routes to the codex harness with a per-turn auth.json written into the jail; refreshed tokens persist back to the per-user store. - A pasted Anthropic/OpenAI API key routes to the pi harness with that key resolved per turn (org keys are never used for individual-auth turns). Backing pieces: an encrypted per-user credential store (user, provider) over the durable artifact map; core routes for status/connect/disconnect with OAuth start/poll/complete; token refresh before turns with timeouts on every provider call; the harness router driven by a pure, tested routing function; a first-login connect gate and a self-serve manage panel in the web UI (subscription or API key per provider, click-to-copy device code, disconnect to switch); the composer's model/harness picker hidden when the account decides the runtime; and the deployment-level provider pre-flight skipped for individual-auth turns so per-user credentials aren't refused before resolution. * Resolve individual-auth review findings: fail closed, pin runtime, isolate credentials Correctness fixes from the fresh-context review of the individual-auth work: - Fail closed: a human turn under individual auth with no connected account is refused with a connect prompt instead of silently running on the org's shared claude/codex credentials; automation/cron/ambient turns bypass individual auth and stay on org credentials. - Pin the per-user runtime through the harness router so the forced claude/codex/pi override is not rejected by the approved-harness list. - Strip ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN from the claude child env on subscription turns so the user's OAuth token is the only credential. - Restore the codex jail auth.json to org state after every per-user turn, serialize per-user and org turns on the same lock, and guard the per-user token write-back with lock ownership plus account-lineage verification so concurrent turns cannot cross-contaminate stored credentials. - Hard-delete disconnected credentials and re-check connection state before persisting refreshed tokens, so a disconnect cannot be resurrected by an in-flight turn and revoked secrets do not linger in Postgres. - /me fails closed (503) when the auth status is unavailable instead of letting unconnected users bypass the gate, and fetches its three core calls in parallel; the ChatGPT device poll stops at code expiry with a clear retry message. - Reuse the admin provider-key validator (keeping providerBaseUrl overrides), the connectors PKCE helpers, and the codex JWT helpers instead of local copies; report status as a single connections shape read without decrypting secrets; errMessage in route catches; drop the duplicate flush-key spread in config-store. * Unify per-user AI-account custody onto the keychain Replace the parallel user_model_credentials store with a facade over the org keychain: each (user, provider) AI login is an ordinary keychain credential owned by that user (service model-anthropic / model-openai, origin individual-model-auth). Keychain encryption, ownership checks, admin visibility, and credential removal now cover AI logins for free — no second custody system, no second key derivation. - keychain: add readOwnSecret (owner-only decrypt of an env credential; no grant machinery) - store: same UserModelCredentialStore interface, keychain-backed; token expiry stays inside the payload so an expired access token still surfaces its refresh token to the pre-turn refresh - wiring: store now wraps credentialStore; user_model_credentials artifact map dropped (no production data existed) - tests assert unified custody: AI logins appear in listByOwner without secrets, strangers cannot decrypt, keychain.remove disconnects * Per-user Codex turns run on dedicated ephemeral app-servers Removes the shared-jail auth swap and the global auth-slot mutex that serialized every per-user (and, once seen, every org) Codex turn through one process. A per-user turn now spawns its own app-server with its own jail and its own derived auth.json (~0.5s, ~9MB measured), closed at turn end; the org runtime is untouched and never blocked. - extract buildServer() so shared and ephemeral runtimes use identical notification/request handlers (states already key on their server) - spawn semaphore (default 8) bounds concurrent process launches - close() also tears down any in-flight ephemeral servers - cross-account leakage is now structurally impossible: no shared mutable auth.json, no swap/restore, no restore-failure swallow - new test: concurrent alice/bob/org turns each see only their own account, no jail ever holds a refresh token, org credential on disk is untouched This matches how other Codex embedders behave (official SDK spawns a process per turn; Buzz pools processes of a single identity) — nobody multiplexes accounts through one process. * Subscription logins ride the keychain's connector-token refresh Fixes review points 3 and 4 with the existing machinery instead of a parallel implementation: - OAuthToken/CredentialRefresh gain optional idToken/accountId (encrypted id token; other connectors unaffected); putConnectorToken stores them, refresh preserves them across rotations - refreshAndStore now compare-and-sets before persisting a rotation, so a losing concurrent flight can no longer clobber a newer refresh token - new Keychain.connectorDerivedAuth: fresh access+id token+account id, refreshed single-flight when stale; the refresh token never leaves the keychain record - wiring registers auth.openai.com and claude.ai on the refresh dispatch, reusing subscription-oauth's refresh functions - user store facade: OAuth logins are connector tokens (API keys stay user-owned credentials); one connection per provider; new derivedOAuth - orchestrator drops its own 60s-heuristic refresh + write-back entirely - CodexTurnAuth loses refreshToken: the harness boundary only ever sees derived material (childCodexAuthFromDerived validates the account claim) - new test: two concurrent derivedOAuth calls on a stale token refresh exactly once and both see the rotated token * Fix truncated CSS rule from merge resolution * ChatGPT device login rides the Codex binary's native login RPCs Replaces the hand-rolled device-code flow (undocumented auth.openai.com endpoints, hardcoded client id, manual PKCE relay) with the vendored app-server's supported surface: account/login/start type chatgptDeviceCode + account/login/completed. A throwaway app-server runs against a temp CODEX_HOME; on approval we harvest the auth.json it wrote (access/refresh/id token, account claim validated) into the keychain and tear the process down. Version-matched by construction — the login flow ships with the same binary that consumes the tokens. - new src/model/codex-device-login.ts (start/poll/close, 15-min TTL, one-shot harvest, sweep of expired logins) - user-model-auth routes swap onto it; poll no longer needs userCode - dead flow pruned from subscription-oauth.ts (Claude PKCE and both refresh functions stay) - tests: fake codex binary drives approve + deny paths * Fix CI: pin user-model-auth routes to source auth; format 10 files The seven /v1/user-model-auth routes were registered auth:'either' but never added to the agent-api catalog, tripping the parity and route-auth-conformance suites. They are user-facing (the web UI calls them source-signed with portal identity + principal binding via user-scoped-routes); the agent has no business managing a person's AI accounts, so 'source' is the correct contract. Also runs the repo prettier over 10 files that had drifted (Lint check). * Fix CI: prune exports knip flags as unused lint's knip step failed on 13 unused exports, all dead weight from the 7ae6a31 refactors: unexport perUserClaudeEnv and the user-store types, drop the codex-auth barrel's unused re-exports, and delete isCodexOAuthJwt/codexOAuthIdToken/readJsonFile/codexOAuthAccessToken, which now have no callers. eslint, knip, prettier, typecheck all green locally. * Fix CI: resolve oxlint no-useless-spread / no-useless-fallback-in-spread warnings * pi-on-ChatGPT: serve a ChatGPT subscription through the pi harness When the org runs the pi harness, a person's ChatGPT OAuth login no longer forces their turns onto the codex harness: pi serves them via pi-ai's built-in openai-codex provider (Codex Responses backend), authenticated with the keychain-derived access token — the account claim rides inside the JWT, so no refresh token and no extra headers cross the harness boundary. - pi-models: "codex/<id>" namespace resolves against pi-ai's openai-codex provider; un-prefixed ids keep resolving to the metered openai provider, so serving mode can never silently flip - routing: preferredHarness parameter; openai OAuth + pi org routes to pi with the namespaced model (requested openai models are honored) - orchestrator: passes org runtime selection (or deployment fallback) as the preferred harness; pi-on-ChatGPT turns get providerKeys[openai-codex] = derived access token, runtimePinned - non-pi orgs and anthropic logins behave exactly as before * Unnest the oauth routing ternary (no-nested-ternary) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: haramiya <haramiya21@gmail.com> Co-authored-by: Regan Bell <regan@ycombinator.com> Co-authored-by: Joshua France <francejoshuar1@gmail.com> Co-authored-by: QM <qm@ycombinator.com>
S
Sina Matian committed
23e537334f363e12fe04f5cf82ad8dd8d681d404
Parent: a0dd5b0
Committed by GitHub <noreply@github.com>
on 8/30/2026, 4:27:57 PM