Skip to content

chat: sign in to Codex (a ChatGPT plan) in the browser, and codeaf connect from the terminal - #1336

Open
AbirAbbas wants to merge 18 commits into
devfrom
feat/codex-login
Open

AbirAbbas wants to merge 18 commits into
devfrom
feat/codex-login

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Santosh's ask (Slack, 2026-09-21): the browser sign-in OpenRouter already has, for Codex too, and a terminal door for signing in.

What changes

  • Codex is a model service. /connect has a Codex row that says browser; enter opens the sign-in the Codex CLI uses (S256 PKCE, the fixed loopback callback on 1455 then 1457), the waiting card keeps the address to copy, and a finished sign-in moves the conversation to codex/gpt-5.5. The models are the account's own list; ids are codex/<slug> everywhere.
  • The wire. The provider client keeps speaking chat completions. A translating transport on provider.Config.HTTPClient turns each turn into a Responses-API request on the ChatGPT backend and its stream back into the chunks the client already decodes: text, reasoning summaries, tool calls, usage. Encrypted reasoning rides the reasoning_details field the session already round-trips. Tokens live in an owner-only codex.json in the profile, registered for redaction, refreshed single-flight with the file re-read first.
  • codeaf connect [service] [--no-browser] [--region intl|cn] and codeaf disconnect <service>. openrouter and codex sign in in the browser (or print the address, with the ssh -L line for a machine without one); the key services take a key on stdin or ask without echo; no argument lists what this profile knows. Under Housekeeping on codeaf --help.
  • No price is ever shown for a codex call. Tokens are counted. A spent plan window ends the turn with codex reached your chatgpt plan's usage limit · it resets on its own; an expired sign-in with codex sign-in has expired · /connect or codeaf connect codex signs in again.
  • The manual, README and GUIDE say all of the above, including that this signs in the way the Codex CLI does and OpenAI's terms for a ChatGPT plan apply.

Out of scope, and said so in the pages: first run (unchanged, OpenRouter only), the device-code road, OpenAI API keys (the custom-service row), Codex's own base instructions, websockets.

How it was verified

  • Every contract item in the spec has a named test; the wire is proven through a real provider.Client, the real config chain and a real session.Agent against httptest issuer and backend servers. No real host is contacted by any test.

  • A fixture drive of the built binary (a fake issuer that redirects like a browser that clicked yes, and a fake backend): the printed authorize address carries exactly the Codex CLI's parameters; the callback lands codex connected · <email> · pro plan; the token file is mode 0600; the code exchange happens and no API-key exchange does; codeaf connect lists the row as connected; a --once turn reaches the backend with the bearer, chatgpt-account-id, originator, OpenAI-Beta, store:false, stream:true, non-empty instructions and no OpenRouter attribution; no dollar figure is printed; no token appears in any output; codeaf disconnect codex removes the file.

  • Constants were read from the installed Codex CLI binary and Codex's own source, and the backend's models listing was called once with the owner's own Codex sign-in to ground the vendored fallback list.

  • The default road. Every step of the first drive used --no-host, the in-process door, and the owner found on the real screen that a plain codeaf chat answered the Codex row with codex did not connect · codex browser sign-in is unavailable here: the ordinary launch takes the engine road, whose screen is assembled by the --host builder plus localDoors, and the Codex seam had been handed to the in-process door alone (the same family as The plain launch keeps this machine's doors: /connect, /harness, always and /model on the engine road #906). localDoors now hands it over, the doors test asserts both sides, and the drive has a phase that runs bare codeaf in a pristine home, goes through /connectCodex with a stub browser on PATH, lands codex connected · <email> · pro plan on the card, answers say hi through the daemon, then runs --once without --no-host. Against the build before the fix that phase fails with the owner's exact sentence; on this head every check passes.

  • The real backend. Two turns on the real ChatGPT backend from a throwaway profile seeded with the owner's Codex CLI access token (a placeholder refresh token, so the CLI's own sign-in was never rotated): a plain reply, and a tool call round trip. No token bytes in any output.

What only the owner can verify: a real click-through on auth.openai.com with a real ChatGPT plan.

Found on the way and filed, not in this PR: #1383 — a Codex model keeps the previous model's context window because the listing's context_window (272k) is dropped.

What the adversarial review found, and what the fix round closed

A read-only review of the first seven commits refuted eight claims, all now fixed with a test that would fail on regression:

  • Refresh across processes. Two codeaf processes near expiry could invalidate each other's rotated refresh token. The refresh now holds the repository's file lock across load, refresh and save, and re-reads first, so whichever process refreshed wins (proven with two re-executed test processes sharing one token file against an issuer that invalidates a refresh token after one use).
  • Only the issuer signs you out. A 429, a 5xx or a network error during refresh no longer clears the token file; only the issuer's own 400/401 marks the sign-in expired.
  • The expiry sentence reaches the person, headless or in the chat, and a token file deleted underneath a running conversation is the same condition. The dispatcher does not spend its retry walk on a dead credential.
  • A failed or cut reply is an error, not a success. response.failed and error events become a classifiable 402 or 502 on both the streaming and whole-response roads; an incomplete reply carries length only for max_output_tokens.
  • The transport's tokens never leave it. Every body, status and error it returns is scrubbed; provider's error ingress and the call log scrub as a second boundary; a fake backend that echoes the bearer in a 400 leaves no token bytes in the turn's error, the transcript, the call log or the debug record.
  • The last catalog road. The media resolver built a catalog without the codex client; every connected-service catalog now comes from one constructor, and a go/ast law refuses any other literal.
  • A headless plan refusal keeps its own words. A --once run used to end every 401/402/403-class refusal with your key was not accepted for this model, for Z.ai as much as for Codex; both roads now end it the way the chat already did: the service, that the account cannot pay, and what the vendor said.
  • Also: absent usage stays absent; a browser that would not start says so and the terminal keeps waiting; the listener test pins 1455 then 1457; first run is rendered and asserted to offer no Codex.

A second read-only review of the fixed branch closed those and added a refresh that could wait forever behind another process sitting in an issuer round trip with no deadline. Now the issuer exchange has a thirty-second deadline, both locks are taken without blocking and given up when the turn is cancelled or after a minute with one plain sentence, and the lock file is owner-only. From the same round: provider's error ingress removes only the credentials this process registered and leaves every other byte alone; an expired sign-in is accounted once through the taxonomy like any terminal failure instead of short-circuiting the loop; the transcript journal and the error event scrub assembled text at write time (the journal only for registered credentials, so a key a person pasted into their own message replays on resume exactly as written); and the catalog law refuses an assignment to Source as well as a literal.

Evidence, the fixture, and both review ledgers are in the handoff folder on the owner's machine; the fixture drive of the final binary passes all thirteen checks. One unrelated load-sensitive session test surfaced in the local gate and is filed as #1339.

🤖 Generated with Claude Code

AbirAbbas added a commit that referenced this pull request Sep 21, 2026
…om the terminal (#1336)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AbirAbbas and others added 13 commits September 21, 2026 17:53
…carry its turns

Codex credentials had no profile store, no browser exchange and no road onto
the wire. internal/codexauth now holds the Codex CLI-compatible S256 sign-in on
the fixed loopback callback (1455, then 1457), the owner-only token file with
every token registered for redaction, the account's own model listing with a
vendored fallback, single-flight refresh that re-reads the file first, and a
translating RoundTripper that turns the provider client's chat-completions
request into a Responses-API turn on chatgpt.com and its stream back into the
chunks the client already decodes, encrypted reasoning riding reasoning_details.
A spent plan window comes back as a 402 with one plain sentence, which is the
status every service's payment refusal already carries.

provider.Config.HTTPClient is no longer a test-only seam, and the funnel law
names the adapter as the provider funnel's own wire, not a second call path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…client

A profile could not resolve or construct a Codex model source. The vendored
rows now carry `codex` after Qwen, a Connected knows the profile home its
rotating credentials live in, ClientConfigFor attaches the codex client to that
one service with the sentinel key and no price, ConnectCodex keeps a finished
sign-in and lists the account's models, and DisconnectService removes the token
file with the row. CODEAF_CODEX_ISSUER and CODEAF_CODEX_BACKEND point the real
binary at a fixture.

What was true: a headless command with no OpenRouter key refused to start even
when a connected service could carry it. What is true now: load refuses only
when no service at all holds a credential.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he terminal

Only the chat surface owned browser opening, so a terminal command had no way
to open a sign-in link. The opener moves to internal/opener with the chat's
test seam kept pointing at it. Linux still answers xdg-open exactly as before;
a WSL box that has no xdg-open at all falls to wslview rather than to a
sentence saying the browser did not open.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e terminal

Connecting a service required opening the chat. `codeaf connect` lists the
model services this profile knows and which are connected; `codeaf connect
openrouter` and `codeaf connect codex` sign in in the browser (or print the
address with --no-browser, with the ssh -L line for a machine that has none);
the key services take a key on stdin or ask for one without echo; `codeaf
disconnect <service>` forgets one. Both are on the help page under
Housekeeping, within eighty columns.

The chat manual's verb gate carries a narrow bridge for the two new verbs
until their pages land in the same pull request.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…arries the account bearer

A generic catalog refresh for a connected codex row would have carried the
persisted `chatgpt` sentinel as a bearer toward the real backend, and the
connection panel had no browser road for the service. The catalog now takes a
service-owned HTTP client, config answers the codex client for that one
service, the transport turns the catalog's `/models` request into the backend's
listing with the account headers and translates the answer back into the shape
the catalog reads, and a finished connection seeds the picker before any
refresh. The Codex row on /connect says `browser`, opens the existing waiting
card with its copy affordance, and moves the conversation to codex/gpt-5.5 the
way any connected service does. The outcome sentences the panel and the
terminal print now have one owner in internal/config.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…er or from the terminal

The manual described only key-based model services and did not name the two
terminal verbs; its verb gate carried a bridge for them. The pages now say how
Codex signs in (the way the Codex CLI does, under OpenAI's terms for a ChatGPT
plan), that its models are the account's own and a new connection lands on
codex/gpt-5.5, that no price is ever shown and tokens are still counted, the
plan-limit and expiry sentences, that first run does not offer it, and what
`codeaf connect` and `codeaf disconnect` do and do not do — regions, the ssh
tunnel line, custom services staying in the chat, and the door rule for a
headless command with no OpenRouter key. The bridge is gone. Three probes in
the asker's own words reach the pages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…om the terminal (#1336)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…minal refusal keeps its own words

Rotating refresh tokens were serialised inside one process only, so two codeaf
processes near expiry could invalidate each other's sign-in; any non-2xx
refresh answer, a 429 or a 5xx included, signed the person out; a failed
Responses event could end as an empty success, and a cut reply ended without a
finish reason; and an echoed bearer in an error body could reach the call log.
The refresh now holds the repository's file lock across load, refresh and save
and re-reads first, so whichever process refreshed wins; only the issuer's own
400/401 marks the sign-in expired; response.failed and error become a
classifiable 402 or 502 on both roads; an incomplete reply carries `length`
only for max_output_tokens; the transport scrubs its own tokens from every
body, status and error it returns, and provider's error ingress and call log
scrub as a second boundary; absent usage stays absent.

The session's ending sentence now prefers a typed refusal's own words before
the generic taxonomy: a plan refusal names the service and what the vendor
said, a spent window says when it resets, and an expired Codex sign-in says how
to sign in again — in a headless run as in the chat, for Z.ai as for Codex. A
headless run used to say `your key was not accepted for this model` for all of
them. An expired sign-in is terminal for the dispatcher's retry walk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…that keeps it the only one

The media resolver built a source-scoped catalog without the codex client, so
a stale cache could send the persisted sentinel to the real backend. Every
connected-service catalog is now built by config.CatalogOptionsFor, which
carries the source, address, key, profile and account-aware client together,
and a go/ast law refuses any catalog.Options literal outside it that names a
source. The media path is exercised against a fake backend.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s waiting

The opener started the browser inside a goroutine, so a start failure was
swallowed and `codeaf connect` could print a link and wait in silence. It now
starts synchronously and waits in the guarded goroutine; the terminal commands
print `could not open your browser · open the link above` — first run's own
spelling, now shared from one constant — and keep waiting on the printed link.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ing the advertised address

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…odex

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…headless refusal wording (#1336)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
santoshkumarradha added a commit that referenced this pull request Sep 21, 2026
…1340)

* Revert "session, cmd, bench: the worker harness is the belt a task runs on (#1335) (#1335)"

This reverts commit 619860c.

* bench, session: keep the belt-naming fix and pin the default (#1340)

Two things the revert of #1335 would otherwise have taken with it.

bench/bashloop names a belt on BOTH arms. Arm A ran with CODEAF_TASK_BELT
unset, which was correct only while unset meant the node belt; the moment a
default moves, an arm that relies on absence becomes a copy of the arm it is
being compared against, and the driver reports a difference of zero as a
measurement. That is true whichever belt is the default, which is why it does
not travel with the revert.

And the default is asserted. It had never been written down: it was carried
only by the absence of a value in other tests, so it could move — and did —
without one test in the tree saying a word about it. The whole answer is
spelled out now, including that the match is exact and that a blank value reads
as an unset one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
AbirAbbas and others added 4 commits September 21, 2026 18:51
A refresh could wait forever behind another process's lock while that process
sat in an issuer round trip with no deadline, and a cancelled turn could not
leave the wait. The issuer exchange now runs under its own thirty-second
deadline derived from the request's context; both the in-process mutex and the
cross-process file lock are taken without blocking, on a short cadence, and
given up when the context ends or after a minute with one plain sentence
(`another codeaf is refreshing the codex sign-in and has not finished`); an
existing lock file is tightened to owner-only mode. A hung issuer and a
cancelled waiter are each proven with the token file byte-identical after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d once, sinks scrub assembled text

Three boundary gaps from the second review. Provider's error ingress ran the
broad shape scrub on every body, so a diagnostic that merely resembled a key
changed bytes and a large media error paid for it: it now removes only the
credentials this process registered, byte-preserving when none are, and only
on bodies up to eight megabytes; the shape scrub stays at the sinks people
share. An expired Codex sign-in short-circuited the turn loop and skipped the
taxonomy row and the wire tally: the taxonomy now carries a terminal-transport
fact that reports once, the shortcut is gone, and one attempt, one journal
line, one row and one tally are asserted. The transcript journal and the error
event scrub their assembled text at write time, so a credential that crossed
the wire in pieces cannot be recreated in a file — the journal removes only
registered credentials, because a key a person pasted into their own message
is theirs to keep and must replay on resume exactly as written.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…literal, proven on a compiled fixture

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the opener comment says what Start does now (#1336)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas marked this pull request as ready for review September 21, 2026 23:15
A plain `codeaf` or `codeaf chat` in a set-up workspace takes the engine
road: the screen is assembled by the --host builder and localDoors puts this
machine's stores back. The Codex sign-in seam was handed to the in-process
door alone, so on the launch a person actually makes the Codex row answered
`codex did not connect · codex browser sign-in is unavailable here`, while
`--no-host` and `codeaf connect codex` both worked. localDoors now hands the
door over the same way it hands the accounts panel, the approvals and the
model writes; --host and --at keep it absent, and the host builder's list of
deliberate absences says why. The doors test asserts both sides.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

1 participant