feat(accounts): ChatGPT credentials and refresh in the key store
P18 of the native-harness rollout (task "Bringing Codex or Claude Code Max plans into Luke"). It stacks on #4180 (P04).
This adds the chatgpt_oauth kind to account_provider_key. The desktop completes OpenAI's open-source Sign in with ChatGPT for Luke's hosted runtime (the self-hosted VM recipe) and hands the credentials to the service. From then on the service owns refreshes and revocation. Nothing reads the credentials yet beyond refresh; P21 is the first consumer. There is no UI; that is P19.
What changed
- Storage: P04's one sealed blob (
sealed_key, with AAD already bound to the kind) holds the access, refresh and ID tokens, the issuedclient_id, the account id (the ID token's validated subject) and the scopes. One additive nullable column,access_expires_at, records when the access token lapses, so a refresh can be judged due without opening the seal. Rewrap needs no change because it moves the data key only. - Host id, a deviation from the brief, agreed with the orchestrator:
ext_agent_host_idlives in a new table,account_chatgpt_host, keyed by user and cascading with the account. It does not live on the credential row. OpenAI's guides ask for the host id to be persisted before the first sign-in and kept across sign-outs, and P04's row is NOT NULL-sealed and deleted on disconnect. The id is minted once (insert-on-conflict-do-nothing, so concurrent first reads agree) and served onGET /api/account/chatgpt/host. - Save:
PUT /api/account/chatgpt/credentialstakes{clientId, accountId, accessToken, refreshToken, idToken, expiresInSeconds, scopes}.- It shares the provider keys' 10-per-hour brake, seals the credentials, and saves them
unverified. Nothing is sent to OpenAI. - It is refused as
invalidwhen the grant lackschatgpt.tokens.use.director when it namesdynamic_agent_client. - Since P04c (#4217) it stands beside an OpenAI API key.
- It answers with the provider keys' answer, where the sign-in appears under
subscriptionswith hint "ChatGPT". - Pasting a
chatgpt_oauthvalue through the existingPUT /provider-keysis refused as invalid.
- It shares the provider keys' 10-per-hour brake, seals the credentials, and saves them
- Refresh:
refreshChatGptAccess(userId, ring)inpackages/service/src/accounts/chatgpt-credentials.ts.- It runs in one transaction under
SELECT … FOR UPDATE(withProviderKeyLocked), with OpenAI's token endpoint called inside the lock. - It hands out the stored access token while it has more than 10 minutes left. Otherwise it renews: issued
client_id,resource, noscope. - On success it replaces the access token, expiry, scopes and refresh token together, plus the ID token when one is returned.
- A second concurrent caller waits on the lock, then reads the renewed token without calling OpenAI.
- A terminal refresh error (
invalid_grant,refresh_token_reused, …invalid_client, per OpenAI's errors guide) marks the rowinvalidand keeps it. A 5xx, a network failure or a malformed answer changes nothing.
- It runs in one transaction under
- Revoke on delete:
DELETE /api/account/provider-keys?provider=openai&kind=chatgpt_oauthlocks the row first. It readsrevocation_endpointfrom OpenAI's discovery document and POSTstoken,token_type_hint=refresh_tokenandclient_id. On network failures and 5xx it retries twice with backoff. The row is deleted either way. When OpenAI didn't confirm the revocation, the answer carries a new optionalrevocationUnconfirmed: true, as the sessions guide asks. - ID token verified on save (review follow-up): a save checks the ID token against OpenAI's JWKS, read through discovery, cached for an hour, and read again for an unknown
kid. It checks the RS256 signature, the issuer, that the audience containsclientId, the expiry (with 5 minutes of leeway), and thatsubequalsaccountId. A failure is refused as the newunverified_sign_in; keys that can't be read answerunreachable. The nonce check stays on the desktop (P19). - Also from review:
- The stored expiry is the earlier of
expires_inand the access token'sexpclaim. - A renewal that loses the plan scope marks the row
invalid. - A revocation endpoint off auth.openai.com is never sent the token.
- The stored expiry is the earlier of
- Never logged or returned: no token, code, or OpenAI refusal text is logged or answered. A refusal is read only for its OAuth error code.
Tests
provider-key-store.test.ts:- A sign-in is kept as its own kind with its expiry, and resealed under its lock only from the seal it was read with.
- Another account can neither read nor reseal it.
- The host id is minted once, belongs to its own account, survives concurrent first reads, and outlives the sign-in.
- New
chatgpt-credentials.test.ts, against a fake OpenAI authorization server that rotates refresh tokens and refuses a reused one:- A fresh token is served without a call, and a due one is renewed and stored.
- Two concurrent refreshes serialize. OpenAI sees the refresh token once and both callers get the same new access token. I checked that this test fails when the lock is removed: the second caller then gets
refresh_token_reused. - A terminal error marks the row invalid and is never retried; a 5xx changes nothing.
- Accounts are isolated.
- Revocation sends the right form and then deletes the row; a failing revocation is retried, then deleted and reported unconfirmed (driven on the TestClock).
provider-keys-procedures.test.ts, for the routes:- A save is answered as a hint only. No token appears in the answer, the logs, or any stored column.
- The refusals listed above.
- The host id is stable and per-account.
- DELETE revokes, then answers
revocationUnconfirmedwhen OpenAI refuses.
- The wire JSON-schema goldens were regenerated under
LUKE_UPDATE_FIXTURES=1. The only differences are the new kind literal, the optionalrevocationUnconfirmed, and the two new schemas.
Checks
pnpm check:affecteddid not complete cleanly in the sandbox:- It first caught a knip finding, which is fixed.
- Its combined typecheck was OOM-killed (137), as on P04.
- The
featuressuite hit 5 s timeouts in untouched tests (workspace-client, a few golden cases) after an 18-minute run under memory pressure.
- Run separately, these all passed:
- lint, oxlint, knip and discarded-effect
scripts/repository-checks.sh,check:migrationsandcheck:contractions- typechecks for database, features, service, host, panel and web (web with a larger heap)
- the 15 touched test files (525 tests)
- CI gates the rest.
The migration is additive: a new table and a nullable column, hardened with IF NOT EXISTS and a drop-then-add foreign key.
The developer has waived docs/WORKFLOW.md's Preview runs for this rollout until the cohort. This PR merges on green once it has been restacked onto main, with no Preview run. PRIVACY.md is unchanged because nothing exposes the sign-in to users yet; P19 adds the disclosure along with the Settings UI.
Note: P16's probe (#4161), which this follows for the token exchange and refresh, is still open; I read it from its branch.
Made with Luke