feat(desktop): Continue with ChatGPT

Merged
CH
5d ago
+2237-136

P19 of the native-harness rollout (task "Bringing Codex or Claude Code Max plans into Luke"). It stacks on #4199 (P18).

The desktop now runs OpenAI's open-source Sign in with ChatGPT for Luke's hosted runtime, the same way tools/codex-probe (P16) proved it, and hands the credentials to P18's PUT /api/account/chatgpt/credentials. Settings › Coding agents gains a Codex group with Continue with ChatGPT, OpenAI's first-sign-in notice, the connected state, and Disconnect. The group is shown only on the desktop, and only to accounts in LUKE_NATIVE_AGENT_ACCOUNTS.

The flag: the service answers the cohort on GET /api/account/access (nativeAgents, read from P02's HostedEnvironment.nativeAgentAccounts), and account-access.test.ts covers it.

What changed

  • The sign-in (packages/credentials/src/chatgpt-sign-in.ts), built on the existing loopbackConsent:
    • A first registration uses client_id=dynamic_agent_client, agent_name_hint=Luke, and the account's ext_agent_host_id from GET /api/account/chatgpt/host.
    • It requests the plan scopes plus resource, with PKCE S256, a fresh state and nonce, and a loopback on 127.0.0.1:1455/auth/callback. If port 1455 is taken, it uses any free port.
    • loopbackConsent now hands the redirect's issued client_id to the exchange. That is a small addition to LoopbackExchange.
    • The code is exchanged under the issued client.
    • Before anything is saved, the ID token is checked in the desktop: its RS256 signature against OpenAI's JWKS, iss, aud (the issued client), exp, and the nonce this attempt sent. accountId is the verified sub.
    • Endpoints come from OpenAI's discovery document and must be on https://auth.openai.com.
    • A grant without chatgpt.tokens.use.direct is refused.
  • Where it runs: a new host platform concern, chatGptSignIn (packages/host/src/compose-chatgpt-sign-in.ts).
    • It needs the SignInConsent seam. Only the desktop provides that seam, so the web host leaves the concern out, its methods refuse, and HOST_CAPABILITY.CHATGPT_SIGN_IN shows up on the desktop alone.
    • The gateway, operator, and act-port kinds are chatGpt.signIn and chatGpt.cancelSignIn.
    • providerKeys.remove now forwards kind, so Disconnect calls DELETE …?provider=openai&kind=chatgpt_oauth.
  • The renderer never sees a token. It sends only the press. The act answers the keys (subscriptions entry: status, hint), a refusal sentence, cancelled, or a failure. The browser's landing card waits on the service's answer, so a sign-in the service refuses never reads "Connected".
  • The flag: it reaches the panel on the existing account access read.
    • The service sets nativeAgents to whether LUKE_NATIVE_AGENT_ACCOUNTS names the caller. accountAccessAnswerSchema carries it as optional, so an older service reads as no.
    • AppState.access.nativeAgents carries it, and the panel reads it with useNativeAgentsAccess().
  • Settings › Coding agents › Codex (chatgpt-plan-section.tsx):
    • Not connected: the offer, worded per OpenAI's guidelines, with "Continue with ChatGPT". While the browser is open the button waits, with Cancel beside it.
    • Connected: "Connected · Added …" and Disconnect, which asks first.
    • Ended by OpenAI (invalid, as P18 now marks a renewal that lost the plan scope) or unreadable: says so and offers Continue with ChatGPT again.
    • Disconnect with revocationUnconfirmed: true: "Luke no longer holds your ChatGPT sign-in, but OpenAI didn't confirm it ended. To end it there too, remove Luke in your ChatGPT settings."
    • First sign-in: OpenAI's notice, "You're using your ChatGPT plan … Got it", is shown once per Mac and kept in the renderer's storage, the way the sidebar's folds are.
  • PRIVACY.md: a "Your ChatGPT plan" paragraph covering what the sign-in sends, what is stored, and Disconnect's revocation.

Judgment calls

  • The notice is remembered per Mac, not per account. The service has no "first sign-in" fact to read.
  • A sign-in the service can't verify (P18's unverified_sign_in) is worded "Luke couldn't verify the ChatGPT sign-in. Try again."
  • The button is a plain outlined "Continue with ChatGPT" with no OpenAI logo, because the repository holds no OpenAI mark. The row leads with the Codex mark.

Screenshots (synthetic data, rendered from the panel in a throwaway harness)

Not connected · OpenAI's first-sign-in notice · connected:

Tests

  • packages/credentials/src/chatgpt-sign-in.test.ts: the real loopback, a fake OpenAI authorization server (@luke/credentials/testing), and the test acting as the browser. It covers:
    • the authorize URL;
    • the exchange under the issued client with the PKCE verifier;
    • refusing a grant without the plan scope;
    • refusing an ID token with the wrong nonce, the wrong audience, or an expired exp;
    • a callback with no issued client;
    • the fallback when port 1455 is taken.
  • packages/service/.../provider-keys-procedures.test.ts: a desktop sign-in against the fake authorization server, saved through the service's own route, stores the row sealed for the host id the service named.
  • packages/host/src/compose-chatgpt-sign-in.test.ts:
    • The renderer never receives a token: the service's PUT body carries the tokens, while the gateway answer carries none of the tokens or the code.
    • A service refusal becomes a sentence.
    • Behind a closed account gate, no browser opens.
  • packages/panel/src/settings/chatgpt-plan-section.test.ts:
    • Flag gating: the group is drawn for a cohort account on the desktop, and not drawn outside the cohort or on a client without the capability.
    • The press carries no payload.
    • Connected state, and the notice shown once.
    • Disconnect asks first, removes by kind, and words revocationUnconfirmed.
    • A refused sign-in, a withdrawn one, and an ended one.
  • Act-port, compose-host, and access-client tests are extended. The wire goldens were deleted on main (#4223), so none are added here.

Checks

pnpm check:affected ran in the sandbox. It is not fully green there, for sandbox reasons only:

  • @luke/service#typecheck was OOM-killed (137) in the combined run, as on P18. Run alone with a larger heap, it passes.
  • apps/desktop's app-menu.test.ts can't load the Electron binary in this sandbox. It passes when run alone.

Everything else passed, including lint, oxlint, knip, the api-callers and task-names checks, the typechecks for credentials, features, host, panel, desktop and web, and every touched test. CI gates the rest.

The developer has waived docs/WORKFLOW.md's Preview/Mac runs for this rollout until the cohort. This PR merges on green, with no Preview run. Launching Codex (P21), the payer at Start and the composer label (P25), and the Claude setup-token UI (P13) are out of scope.

Made with Luke