Skip to content

feat(server): let a host own and refresh its own Codex logins - #83

Merged
andrewcai8 merged 6 commits into
mainfrom
feat/host-owned-codex-logins
Sep 27, 2026
Merged

andrewcai8 merged 6 commits into
mainfrom
feat/host-owned-codex-logins

Conversation

@andrewcai8

@andrewcai8 andrewcai8 commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Why

A Codex ChatGPT login rotates its refresh token on every refresh, and the auth server rejects a reused one, so one login can only be refreshed in one place. Since #75, the host seed and every box get stripped copies of the laptop's logins. The laptop is the only refresher, and each cloud copy dies when its 10-day access token expires.

This gives the host its own login per account, made with CODEX_HOME=~/.t3/host-codex/<instanceId> codex login --device-auth. The host is the only refresher of those logins. Boxes still get stripped copies and never refresh.

What changed

  • Packer. pack-host-state.ts takes --codex-host-logins DIR (default ~/.t3/host-codex) and carries DIR/<instanceId>/auth.json whole, since the host refreshes it. A missing file falls back to the stripped laptop copy and logs one line for that account. HostState.codexLogins records host or copy per account. Once the seed is written, the CLI renames each carried file to auth.json.packed-<seed name> and prints one line per file. Nothing on the laptop can use it afterwards, and a repack can't carry a spent refresh token again. codex-site-login.sh checks for auth.json, so it treats that account as needing a fresh sign-in. The packer refuses a laptop or host login it can't parse, rather than carrying it raw or packing and retiring it. The usage text says how to recover: if the upload fails, upload the same tarball again; if the tarball is lost, rename each auth.json.packed-<seed> back to auth.json and pack again. deploy-provision-manager.mjs passes no directory and carries copies only, because one login can't have two owners.
  • Only hosts refresh ahead. The 48h probe flag, the hourly check and the 24h refresh before a copy all run only where localAgentRuns is false (T3CODE_LOCAL_AGENT_RUNS=false, which the T3 host sets). Laptops and worktree dev servers can share one Codex home, and two refreshing it at once would spend the same token. There, Codex keeps its own schedule. codexLoginRefreshDue(login, within, { now, localAgentRuns }) is the one decision all three call sites use.
  • How a server knows it owns a login. It reads the file. A stripped copy has the unredeemable refresh token, and anything else is this machine's own login.
  • Status.
    • A stripped copy about to expire keeps fix(server): keep cloud copies of Codex logins from refreshing them #75's "expired; sign in on your computer and reseed".
    • A host's own login that is past expiry, because its refresh failed, now reports unauthenticated with "<account>'s Codex login on this host expired; sign in again with ~/.t3/provisioning/codex-site-login.sh and reseed". Routing's refusal uses the same wording.
    • Otherwise the status is Codex's own.
  • Before a box gets a copy. On a host, routing refreshes an owned login with less than 24h left, then rereads it. The 30-minute skip rule is kept.
  • Torn reads fail closed. Codex rewrites auth.json in place with truncate and write (codex-rs/login/src/auth/storage.rs:206-222). freeze retries a Codex login it can't parse (3 reads, 100 ms apart), then refuses with "A Codex login on this manager could not be read, so it was not copied. Try again." stripCodexRefreshToken now returns undefined for text that isn't a JSON object, so nothing hands a raw file on.
  • Serialization. Every refresh goes through the instance's status probe, which makeManagedServerProvider runs one at a time per instance. A refresh cannot overlap that account's usage probe or another refresh.
  • docs/operations/cloud-provisioning.md: the Codex accounts section is rewritten in place.

Codex mechanism (codex rust-v0.157.1, commit 36650394c5, the version the host image pins)

  • account/read takes GetAccountParams.refresh_token, whose doc says "requests a proactive token refresh before returning" (codex-rs/app-server-protocol/src/protocol/v2/account.rs:543-552).
  • get_account calls self.refresh_token_if_requested(params.refresh_token) first (codex-rs/app-server/src/request_processors/account_processor/workspace_routing.rs:157-161). That calls auth_manager.refresh_token() and swallows its error (codex-rs/app-server/src/request_processors/account_processor.rs:1026-1039), which is why the hourly check rereads the file afterwards.
  • AuthManager::refresh_token takes refresh_lock, then does a guarded reload of auth.json. If the file changed since it was cached, it skips the refresh ("another process already refreshed"). If the account id no longer matches, it refuses (codex-rs/login/src/auth/manager.rs:2844-2881). persist_tokens rewrites auth.json and sets last_refresh (manager.rs:1598-1621). Codex's own proactive window is 5 minutes (manager.rs:204, 3004-3024).

Deploy order

These have to ship together, in this order:

  1. Merge this PR and cut a runtime tag from it.
  2. Run the host-login helper so ~/.t3/host-codex/<instanceId>/auth.json exists for all 11 Codex instances.
  3. Pack a new seed with pack-host-state.ts after step 2. Check that no no ~/.t3/host-codex/... fallback lines were printed.
  4. Deploy Authentic-Intelligence/megpt-mono#4538 (the entrypoint keeps host-refreshed logins across a re-seed) together with the new runtime pin and the new seed version.

Without the entrypoint change, a later seed would put back an older refresh token and sign out that account. Once a seed is packed, nothing on the laptop may run Codex against ~/.t3/host-codex/<id>, because the host now owns those logins.

Verification

  • vp test run on codexLoginCopy.test.ts, ProvisionPreparation.test.ts, ProvisioningProviderProfile.test.ts, EnvironmentControl.test.ts, pack-host-state.test.ts and provision-manager-accounts.test.ts: 136 passed. The tests use literal expectations:
    • Refresh decision. On a host: 49h is skipped, 47h refreshes ahead only, 20h and expired refresh on both windows, and a copy or an unreadable expiry never refreshes. On a server that runs agents, an expired owned login is false while the same login on a host is true.
    • Signed out.
      • A copy with 20 minutes left is signed out on both kinds of server.
      • A copy with 2 hours left is signed out on neither.
      • An own login past expiry is signed out on a host but not on a server that runs agents.
      • An own login with 20 minutes left is signed out on neither.
      • The three messages are asserted verbatim.
    • Box copy. With localAgentRuns: false, the result is refreshedAccounts: ["codex_host"], a box copy expiring at 1791468000 (2026-10-08T14:00Z) with t3-copy-cannot-refresh, and the host file at rt_rotated. With localAgentRuns: true, nothing is refreshed, the box copy expires at 1790676000 (2026-09-29T10:00Z), and the host keeps rt_host.
    • Routing refusal. A revoked host login is refreshed once, and the refusal carries the host message.
    • Torn read. A truncated auth.json makes freeze reject with the message above and leaves no manifest.
    • Packer. codexLogins is { codex: "host", codex_uci: "copy" }. The host file is carried byte-for-byte. retireCarriedCodexLogins returns [.../codex/auth.json.packed-2026-09-27.1], and the directory then holds only that file, with the original contents. A truncated laptop login fails with "<home>/.codex/auth.json is not a Codex login". A truncated host login fails with "<home>/host-codex/codex/auth.json is not a Codex login" and stays in place, and letting host logins skip the check fails that test.
  • Mutation check. Removing the localAgentRuns gate fails the three tests that cover it.
  • Packer CLI. I ran it once against a fake HOME. It printed the codex_uci fallback line and retired the host's Codex login to <tmp>/.t3/host-codex/codex/auth.json.packed-seed-v99, and left only that file.
  • Real Codex. Earlier, I ran checkCodexProviderStatus once against the real codex-cli 0.157.1 with a local token stub. Without the flag there was no token call. With it there was exactly one POST /oauth/token carrying rt_old, and auth.json then held rt_new.

🤖 Generated with Claude Code

A Codex ChatGPT login rotates its refresh token on every refresh and rejects
reuse, so only one place can refresh it. Until now the host seed carried
stripped copies of the laptop's logins, which die when each access token
expires.

The packer now carries `~/.t3/host-codex/<instanceId>/auth.json` whole
(`--codex-host-logins`), falling back to a stripped laptop copy with one log
line per account. On any server, a login it can refresh is refreshed by the
instance's status probe (`account/read` with `refreshToken: true`) once less
than 48 hours are left, checked hourly. Provisioning refreshes a login with
less than a day left before copying it, still stripped, into a new box. Both
go through the instance's serialized probe, so a refresh cannot race a usage
probe.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L labels Sep 26, 2026
@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.5 KiB 13.5 KiB +7 B (+0.1%) 15.1 KiB ✅
Codex Thread snapshot wire 7.1 KiB 7.1 KiB +4 B (+0.1%) 7.3 KiB ✅
Codex Live turn WebSocket wire 6.4 KiB 6.4 KiB +3 B (+0.0%) 7.8 KiB ✅
Codex Live turn WebSocket decoded 56.2 KiB 56.2 KiB 0 B (0.0%) 66.4 KiB ✅
Codex Live turn messages 9 9 0 (0.0%) 21 ✅
Claude Total thread wire 13.5 KiB 13.5 KiB −11 B (−0.1%) 15.1 KiB ✅
Claude Thread snapshot wire 7.1 KiB 7.1 KiB +3 B (+0.0%) 7.3 KiB ✅
Claude Live turn WebSocket wire 6.4 KiB 6.4 KiB −14 B (−0.2%) 7.8 KiB ✅
Claude Live turn WebSocket decoded 57.0 KiB 57.0 KiB 0 B (0.0%) 66.4 KiB ✅
Claude Live turn messages 9 9 0 (0.0%) 21 ✅

Baseline: d0def8f · PR result: 2637766 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 114.0 KiB
  • Claude decoded thread snapshot: 114.6 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

andrewcai8 and others added 5 commits September 26, 2026 15:52
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ator

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… a torn login

- Refresh-ahead (the 48h probe flag, the hourly check, the 24h pre-copy
  refresh) runs only where `localAgentRuns` is false. Laptops and dev servers
  can share one Codex home, and two refreshing it at once would spend the same
  token, so Codex keeps its own schedule there.
- Codex rewrites auth.json in place, so provisioning retries a login it cannot
  parse and then refuses it; `stripCodexRefreshToken` no longer passes an
  unparseable file through raw, and the packer refuses one too.
- A host's own login past expiry after a failed refresh reports as signed out,
  and routing refuses it, with "sign in again with
  ~/.t3/provisioning/codex-site-login.sh and reseed". Copies keep the reseed
  wording.
- After writing a seed, the packer renames each carried host login to
  `auth.json.packed-<seed name>` so nothing on the laptop can use it and a
  repack cannot carry its spent refresh token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…change

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…k recovery

The packer now applies the copy's parse check to a host login too, so a torn
~/.t3/host-codex/<id>/auth.json fails the pack instead of being carried and
retired. The usage text says how to recover from a failed upload or a lost
tarball, and the Codex accounts doc states the entrypoint's keep rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@andrewcai8
andrewcai8 merged commit 27e072d into main Sep 27, 2026
17 of 18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant