Skip to content

fix(codex): the pane re-authenticates itself when the token is dead - #191

Open
defangdevs wants to merge 3 commits into
masterfrom
fix/codex-auto-relogin
Open

fix(codex): the pane re-authenticates itself when the token is dead#191
defangdevs wants to merge 3 commits into
masterfrom
fix/codex-auto-relogin

Conversation

@defangdevs

Copy link
Copy Markdown
Owner

Closes the first item of #187.

The dead end

On a box whose ChatGPT token had been invalidated, the codex Remote Control pane showed this and could not get out of it:

  ✗ Could not mint a pairing code:
Error: remoteControl/pairing/start failed: ... HTTP 401 Unauthorized, cf-ray: ...,
body: {"error":{"message":"Your authentication token has been invalidated. Please try
signing in again.","code":"token_invalidated"},"status":401}
    Press Enter to try again.

codex login status is a local check — it reports how ~/.codex/auth.json was minted, not whether the backend still honours it — so it kept saying "Logged in using ChatGPT", device_login kept correctly declining (it deletes auth.json as it starts, so the guard has to stay), and pairing was the only thing that noticed. Enter re-ran exactly that. The single escape was typing login, a word the pane's footer mentions but nothing explains.

The change

pair() now classifies its failure and onboard() acts on it:

failure before after
token invalidated / 401 3 attempts, raw JSON-RPC blob, "Press Enter" (forever) fails once, prints the server's own message, drops the credentials and re-runs the device flow — no keystroke
fresh sign-in still rejected says so once, points at login and at account access; no logout/device-auth spin
enrollment race, no network 3 attempts, raw error, "Press Enter" unchanged, and never drops working credentials

The automatic retry is behind a flag rather than unconditional (logout is destructive), and the flag clears on a successful pairing, so a token that expires hours into a pane's life still gets one automatic recovery. login stays a typed word because the wrong-account case produces no error string to detect — only the user knows.

Two things worth calling out:

  • No grep. The agent unit's PATH carries coreutils but no gnugrep, so grep: command not found would have read as "not an auth failure" and silently restored the dead end. Matching is shell globs; the reason string is extracted with parameter expansion.
  • relogin() clears was_signed_in when the box ends up signed out (a device flow the user walked away from), so the health loop still notices sign-in whenever it completes. The transition check keeps its short-circuit — a signed-in pane spawns no login status every 5s.

Testing

tests/sessions.nix drives the supervisor wrapper directly against a stub codex (a real server-side rejection is not producible in the sandbox, and daemon version fails in the stub so the health loop ends) and asserts all three rows of that table, including call counts: one failed pair then one that succeeds for the recovering case, exactly one logout for the hopeless case, three pairs and zero logouts for the network case.

Verified locally on this aarch64 box by extracting the generated wrapper and running the same four scenarios (plus logged-out, unchanged) under a PATH containing only coreutils — proof the grep-free matching works:

stale:     pair=2 logout=1 paired=1 http401_on_screen=0 press_enter=0
persist:   logout=1 devauth=1 still_rejects=1
offline:   pair=3 logout=0 press_enter=1
loggedout: devauth=1 paired=1

module-generated-up-to-date, multi-user and module-single-file pass natively; the sessions VM test is x86_64-only, so CI runs it.

Not in scope: the remotable-vs-"local" codex question in #187, which is a distinct change (the CLI and the settings form both hardcode remoteControl: true) and is waiting on a clarification in the issue.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FY7YhQDw21A83rdQtkmtAi

lionello and others added 3 commits August 4, 2026 15:13
A codex Remote Control pane on a box whose ChatGPT token had been
invalidated printed the raw pairing error — HTTP status, cf-ray, JSON body
— and offered "Press Enter to try again", which could not work: `codex
login status` is a LOCAL check, so it kept reporting "Logged in using
ChatGPT", the sign-in guard kept (correctly) declining, and every Enter
reprinted the same 401. Only typing the undocumented `login` word got out.

Pairing is where the rejection surfaces, so classify its failure instead of
retrying blind: on an auth-shaped error (`token_invalidated`, `HTTP 401`,
`invalid_grant`, "unauthorized", "sign in again") pair() returns 2 at once
rather than burning three attempts over six seconds, and onboard() drops
the dead credentials and re-runs the device flow by itself, printing the
server's own message and not the transport guts.

Details worth knowing:

- The automatic sign-in happens once per cycle, guarded by a flag: `logout`
  is destructive, and a 401 a fresh token cannot cure (wrong account,
  revoked access) would otherwise spin the pane through logout/device-auth
  forever. The second rejection says so and stops; the flag clears on a
  successful pairing, so a token that expires hours later still gets one
  automatic recovery.
- Failures that are NOT about auth keep the old behaviour exactly — the
  cold-start enrollment race and a dead network are retried three times,
  reported raw, and never cost the box its working credentials.
- Shell globs, not grep: the agent unit's PATH carries coreutils but no
  gnugrep, and `grep: command not found` would have read as "not an auth
  failure" and silently restored the dead end. Same reason the reason-string
  extraction is parameter expansion.
- `login` stays as a typed word: signing in as the wrong ACCOUNT produces no
  error to detect, and only the user knows about it.
- relogin() now clears was_signed_in when the box ends up signed out (a
  device flow the user walked away from), so the health loop still notices
  sign-in whenever it does complete. The transition check keeps its
  short-circuit, so a signed-in pane spawns no `login status` every 5s.

tests/sessions.nix drives the supervisor wrapper against a stub codex —
a real server-side rejection is not producible in the sandbox — and asserts
the three outcomes: invalidated token recovers with no keystroke and no HTTP
status on screen, a still-rejected fresh sign-in reports once without
looping, and a network failure retries without logging out.

Fixes #187 (first item; the remotable-vs-local question there is separate).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FY7YhQDw21A83rdQtkmtAi
`as_agent(f"{wrapper} '' /tmp/stub-codex ...")` passed the empty host label
the way shell wants it — but a bare pair of single quotes CLOSES a Nix
indented string, so tests/sessions.nix stopped parsing and every VM test in
the run died at evaluation. Double quotes are an equally empty argument and
are inert in Nix. (The first attempt to explain that in a comment used the
same two characters, and broke it again.)

Caught by `nix-instantiate --parse tests/*.nix modules/*.nix flake.nix` plus
`nix eval .#checks.x86_64-linux.sessions.drvPath` — the test derivation now
EVALUATES natively on aarch64 even though running its VM needs x86, which is
the cheap check that was missing here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FY7YhQDw21A83rdQtkmtAi
`re.search(...).group(0)` is a `Match[str] | None`, and the nixos test
driver runs `ty check` (plus `ruff check --select F`) over testScript as part
of BUILDING the driver — so this failed the whole sessions test before any VM
booted, with "Attribute `group` is not defined on `None`". Assign the match,
assert it, then read it.

Both gates now run natively here against the extracted script, with the
driver-supplied names (machine, client, start_all, subtest) stubbed: `ty` and
`ruff` are aarch64-available from the pinned nixpkgs even though the x86
driver is not buildable. Reintroducing the bare `.group(0)` reproduces CI's
diagnostic exactly, and the committed file passes both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FY7YhQDw21A83rdQtkmtAi
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.

2 participants