Skip to content

Individual model auth: per-user Claude/ChatGPT accounts drive each turn - #707

Merged
ReganBell merged 44 commits into
yc-software:mainfrom
time-attack:subscription-auth
Aug 30, 2026
Merged

Individual model auth: per-user Claude/ChatGPT accounts drive each turn#707
ReganBell merged 44 commits into
yc-software:mainfrom
time-attack:subscription-auth

Conversation

@time-attack

@time-attack time-attack commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Updates and extends the subscription-auth work in #690 (this machine cannot push to that PR's head branch, so this PR carries the full branch from a fork; #690 can be closed in its favor, or a maintainer can fast-forward its branch to this head).

What this adds

An org-wide "Individual authorization for AI usage" toggle (admin → Governance). When on, every user connects their own AI account before chatting, and each of their turns runs on that account — never the org's shared credentials:

User connects Turn runs on Credential path
Claude subscription (PKCE sign-in) claude harness CLAUDE_CODE_OAUTH_TOKEN injected per turn
ChatGPT subscription (device-code sign-in) codex harness per-turn auth.json written into the jail; refreshed tokens persisted back to the user's store
Pasted Anthropic/OpenAI API key pi harness key resolved per turn via turn.providerKeys

Pieces

  • Store: user-model-credential-store — encrypted (existing connector-secret crypto), keyed (user, provider), over the durable artifact map (user_model_credentials).
  • OAuth: subscription-oauth — Claude PKCE (claude.ai authorize + console token exchange) and ChatGPT device-code (auth.openai.com), refresh for both, 15s timeouts on every call.
  • Routing: individual-auth-routing (pure, unit-tested) picks provider/harness/model from the user's credential; orchestrator refreshes OAuth before the turn and threads claudeOauthToken / codexAuth (+ refresh write-back callback) on HarnessTurnInput.
  • Harnesses: claude harness overrides the child env token per turn; codex harness writes a per-user sanitized auth.json under the existing turn auth lock and persists refreshed tokens to the per-user store instead of the shared file.
  • API: /v1/user-model-auth/* (status, api-key, disconnect, claude start/complete, chatgpt start/poll), all user-scoped-enforced; web-ui server proxies under /api/user-model-auth/* and /me carries individualModelAuth + modelAuthConnected.
  • Web UI: first-login connect gate + self-serve manage panel (sidebar footer ✨): per-provider rows with real brand marks, subscription sign-in or API key per provider, click-to-copy device code, disconnect to switch; composer's model/harness picker hidden when the account decides the runtime.
  • Pre-flight fix: the deployment-level "provider isn't configured" refusal is skipped for individual-auth web turns, and client-sent harness/model are ignored — the connected account decides.

Safety

  • Org/shared credential paths are unchanged when the toggle is off (default off).
  • Per-user turns resolve keys exclusively from the user's store — buildModelRuntime receives only turn.providerKeys, so an unconnected user cannot ride the org key.
  • Secrets encrypted at rest; API keys validated against the provider before storing; audit events on connect/disconnect.

Testing

  • test/user-model-auth-injection.test.ts: store round-trip, routing matrix (apikey→pi, anthropic-oauth→claude, openai-oauth→codex, requested-provider precedence, no-cred→null), codex auth.json validity.
  • Affected suites green: codex/claude harness, harness-adapter, model-credential-route (82/82), web-ui 565/565, admin suite, typecheck + eslint clean across core and plugins.
  • QA'd live on a dev instance: admin toggle round-trip, gate, both OAuth starts against the real providers, api-key validation, per-turn routing verified in core logs ([individual-auth] user=… harness=… auth=…).

Demo

Exercised end-to-end in a local dev instance (portal → web-ui → core). Screenshots of the connect gate/manage panel to follow in a comment — the panel is the ✨ AI account button in the sidebar footer with individual auth on.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

open-swe and others added 30 commits August 2, 2026 21:12
…oarding

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.
Co-Authored-By: QM <qm@ycombinator.com>
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
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.
…olate 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.
@time-attack

Copy link
Copy Markdown
Collaborator Author

A high-effort fresh-context review (8 finder lenses + adversarial verification) ran against this diff and confirmed 10 findings; all are resolved in 80f74af:

Auth enforcement (the serious ones)

  • Individual auth now fails closed: a human turn with no connected account is refused with a connect prompt — previously it silently ran on the org's shared credentials whenever the org default harness was claude/codex (and Slack has no gate). Automation/cron/ambient turns intentionally bypass individual auth and stay on org credentials, so enabling the toggle no longer breaks or personally-bills scheduled work.
  • The per-user harness override is pinned through the router, so it is no longer rejected by the approved-harness list (which previously hard-failed every turn for connected users in default-configured orgs).
  • Claude subscription turns strip the org's ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN from the child env — before, Claude Code preferred the org key and silently billed the org while logging auth=claude-oauth.
  • The codex jail auth.json is restored to org state after every per-user turn (it previously persisted, so later org turns in API-key orgs ran on the user's personal ChatGPT account), per-user and org turns now serialize on the same lock, and the token write-back is guarded by lock ownership + account-lineage verification so concurrent turns cannot cross-contaminate stored credentials.
  • Disconnect is a hard delete and refreshed tokens re-check connection state before persisting, so a disconnect cannot be resurrected by an in-flight turn and revoked secrets don't linger.

Gates & UX correctness

  • /me fails closed (503) when auth status is unavailable instead of letting unconnected users bypass the gate; its three core calls now run in parallel.
  • The ChatGPT device poll stops at code expiry with a retry message instead of polling forever.

Reuse/cleanup

  • Provider-key validation reuses the admin validator (keeping providerBaseUrl overrides — user keys now validate correctly in proxied deployments); PKCE and ChatGPT-JWT helpers reuse the existing connectors/codex helpers; status is a single connections shape read without decrypting secrets; errMessage in route catches; removed the duplicate flush-key spread in config-store and dead store methods.

Verified after the fixes: core affected suites 82/82, web-ui 565/565, typecheck+lint clean, and a live dev-instance turn on a real Claude subscription routed harness=claude auth=claude-oauth end to end.

Known deferrals (structure, not correctness): consolidating the three turn-auth fields into one discriminated union on HarnessTurnInput, extracting the codex lock-acquire loop shared by the org and per-user branches, and /:provider-style OAuth route naming.

…AuthStore

Merges origin/subscription-auth (keychain custody for subscription harness
auth) and re-expresses the individual-auth per-user paths on its custody
model: children receive derived ephemeral material only, core is the sole
refresher, and the write-back machinery this branch previously carried is
gone.

- Per-user Codex turns validate the user's tokens with
  codexOAuthAuthFromValue and materialize refresh-token-free child auth via
  prepareCodexHome; the jail auth is restored to org state after the turn.
- Codex turns that touch OAuth or per-user auth serialize through a single
  in-process slot once individual auth has been used, so concurrent turns
  from different identities cannot run on each other's accounts; API-key
  orgs that never use individual auth keep full concurrency.
- Per-user Claude turns layer the user's CLAUDE_CODE_OAUTH_TOKEN over the
  keychain authEnv material, stripping org API keys from the child env.
- onCodexAuthRefresh is gone: the orchestrator already refreshes and
  persists per-user tokens before the turn, matching the custody model.
@time-attack

Copy link
Copy Markdown
Collaborator Author

Merged @ReganBell's keychain-custody commit (c1dd286 from #690) into this branch and re-expressed the per-user paths on its custody model — this PR now carries both bodies of work as one:

  • Per-user Codex turns ride CodexAuthStore's primitives: the user's tokens are validated with codexOAuthAuthFromValue and materialized as refresh-token-free child auth via prepareCodexHome; the jail auth is restored to org state after the turn. The write-back/lock/lineage machinery this branch previously carried for per-user Codex is deleted — the orchestrator already refreshes and persists per-user tokens before the turn, which is exactly the custody model.
  • Identity isolation without the old file locks: once individual auth has been used, Codex turns that touch OAuth or per-user auth serialize through one in-process slot, so concurrent turns from different identities can't run on each other's accounts. API-key orgs that never use individual auth keep full concurrency (matching c1dd286's behavior).
  • Per-user Claude turns layer the user's token over the keychain authEnv material, stripping org API keys from the child env.

Verified after the merge: typecheck + lint clean, harness/auth suites 76/76 (one known-flaky codex cancellation test passes in isolation and on rerun), and a live dev-instance turn on a real Claude subscription routed harness=claude auth=claude-oauth end to end.

Open custody question for @ReganBell: per-user subscription tokens currently live in the new user_model_credentials store (encrypted via the connector key). If you'd rather they be keychain credentials so there's a single custody home, that's a contained follow-up — the orchestrator's refresh-before-turn shape stays the same either way.

With c1dd286 now contained here, #690 can be closed in favor of this PR.

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
@ReganBell

Copy link
Copy Markdown
Collaborator

Pushed fb54c46 (point 1 from review): per-user AI-account custody is now unified onto the keychain instead of the parallel user_model_credentials store. Same UserModelCredentialStore interface, so routing/orchestrator/API/UI are untouched — but each (user, provider) login is now an ordinary keychain credential (service model-anthropic/model-openai, origin individual-model-auth): keychain encryption, ownership checks, admin visibility, and "remove my credentials" cover AI logins for free. Added Keychain.readOwnSecret (owner-only decrypt, no grant machinery). Token expiry stays inside the payload so an expired access token still surfaces its refresh token to the pre-turn refresh. Tests extended to prove unified custody; typecheck/lint/prettier clean; keychain + codex + model-credential suites green. Remaining review points (shared-jail serialization, refresh single-flight/CAS, refresh token crossing the harness boundary) still open.

qm-yc and others added 10 commits August 28, 2026 16:24
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.
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
Conflicts: src/wiring.ts (kept main's hydrateModelCatalog while preserving
the runtimePinned short-circuit), shell.css (kept both appended blocks).
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
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).
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.
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
@ReganBell
ReganBell merged commit 23e5373 into yc-software:main Aug 30, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants