Add Codex ChatGPT account auth - #438
Conversation
hbrodin
left a comment
There was a problem hiding this comment.
Review: Codex ChatGPT account auth
Reviewed 9dd65df...ffb362e (1776 lines, 27 files). Eight review passes, then every finding through two adversarial falsification passes in an isolated worktree — 27 raw findings down to 6 inline plus the nits below. Where I could execute something rather than reason about it, I did: the wrapper was run under a real pty in ubuntu:24.04 containers, and each test claim below was mutation-checked by hand.
The gates all pass on ffb362e: cargo fmt --check, cargo clippy --all-targets --all-features -D warnings, taplo format --check, cargo test (1072 lib tests), and shellcheck on the new script. Worth stating because CI could not verify these here.
One finding blocks the feature. scripts/guest/codex-account.sh:136-141 starts the keyring daemon before unlocking it, and the unlock is a no-op once a daemon owns the bus — so auth = "chatgpt" fails on every invocation, on both a fresh and an existing keyring. It is a two-line reorder. Details and the reproduction are inline. Everything else here is a P2 test gap and four P3s.
I have left this as a comment rather than a formal request for changes — the call on the P1 is yours, but I would not expect the feature to work as merged.
Design note
The shape of this is right, and a couple of decisions are better than the obvious alternatives: gating the wrapper on the guest config rather than on an env var keeps codex-yolo working from inside the guest, and routing login/logout bare rather than special-casing them in the wrapper keeps the argv construction in one place. The keyring store with no auth.json fallback is the correct choice for a mode whose whole point is not writing a plaintext token.
Nits — no action required
-
src/guest.rs:497and:501(codex_account_script_uses_secret_service) — two of the five substring assertions cannot fail./usr/local/bin/codex-account(:497) also appears in the script's owncat >andchmod 755lines, and the bare"dbus-run-session"(:501) also appears in thecommand -vguard and itsdiemessage, so both match regardless of what the wrapper actually does. I confirmed by deleting the realexec dbus-run-sessionline: all three tests stayed green. The other three (gnome-keyring-daemon --unlock,secret-tool store,secret-tool clear) are unique and do have teeth. Anchoring the weak two on surrounding syntax —exec dbus-run-session --andcat >/usr/local/bin/codex-account— would fix it. -
src/backend.rs:1366-1374— theelse { "" }arm at:1372-1374is dead. Both reads ofopenai_suppression_reasonare already guarded by the same condition::1398sits insideif suppress_openai_key, and:1428is inside a closure whoseOPENAI_API_KEYbranch is reachable only because that name is pushed tosuppressedunder the sameif. So the sentinel is never observed. Dropping the outerifis a three-line simplification. (No new type needed —docs/code-style.md:111explicitly says not to demand a newtype for every primitive, and this one is a local string.) -
src/backend.rs:1763— "then recreate or restart the VM" overstates what restart does. Onlycreate_and_startcopies the template; Lima instances get noprovisionblock fromgenerate_start_template(src/lima.rs:1292-1339— the onlyprovision:in the file, at:1397, belongs to the throwaway builder VM), sostart_existingis a barelimactl startand cannot pick up new guest binaries. Suggest dropping "or restart". Relatedly, none ofdocs/codex-integration.md,docs/commands.md, orCHANGELOG.md:16mentions that an existing VM must be destroyed and recreated, not just that the image must be rebuilt — that is the step a user upgrading in place will miss. -
Two comments still describe the pre-diff condition set.
src/backend.rs:2586-2590documents three rewrite conditions while the body now has four (|| auth.uses_chatgpt_account()).src/commands/lifecycle.rs:1613-1615says the key "must not re-enter the guest — the host-side proxy holds it", which now also covers ChatGPT mode, where there is no proxy and no host-side holder.
Checked and dismissed
Recording these so they do not resurface in another review: the stale guest auth.json (pre-existing from #411, copy_staged_to_guest is not in this diff, and inert in keyring mode which has no auth.json fallback); the un-exported keyring env (the real daemon prints nothing when XDG_RUNTIME_DIR is set, so there is no value to export); the seven positional parameters on stage_codex_files (already six at merge base, eight functions in src/ already exceed five, and clippy's default threshold is above both); coop setup --rebuild being redundant alongside --image (redundant is not wrong, and it matches existing docs); the || die after --unlock (it fires on any nonzero and is what keeps set -e from aborting silently); run_interactive's exit-code handling (untouched, and shared with coop claude/shell/exec); and libsecret-tools needing universe — the firecracker-ci squashfs has Components: main universe restricted multiverse, so it resolves fine.
|
Thanks — this was a genuinely useful review, and the P1 was real. Pushed The P1Reproduced before touching anything, in The suggested two-line reorder does not fix it. I tried it as written and it still fails everywhere, because with The nits
One thing you did not flag, which your P3 led me toRecording Verification
Follow-ups I deliberately did not do here
|
09c7944 to
03675ae
Compare
|
Rebased onto Three ways a plaintext token could still reach the guest diskThe mode exists to keep a plaintext ChatGPT credential off the guest disk. All three of these defeated that.
Two bugs in the wrapper, both reproduced in containers
Rewrite churn this mode forces
Smaller
TestsA verified Guest-script assertions for the D-Bus recursion guard (breaking it fork-bombs the guest, and nothing noticed), inherited-session reuse, both probe timeouts and the unlock timeout, and the TTY guard. Integration now enters keyring mode — via Gates: One thing I did not change, for you to decideAgainst the threat model in |
Fix the two gate failures and close the gaps found reviewing the branch.
Gates: the new proxy-conflict test called `unwrap_err()` on a
`Result<SshSession>`, which does not compile because `SshSession` is not
`Debug`, so `cargo test` and `cargo clippy --all-targets` both failed.
Three `clippy::doc_markdown` findings ("ChatGPT", "OpenAI") failed
`cargo clippy -- -D warnings`.
Wrapper coverage: `codex-account` now gates on the guest Codex config
and execs Codex unchanged unless `cli_auth_credentials_store = "keyring"`
is set, so every entry point can route through it. `codex-yolo` does,
on both backends. Bare `codex` from `coop shell` still bypasses the
keyring; that is documented.
Failure scope: a per-VM proxy override pairing an OpenAI upstream with
`auth = "chatgpt"` no longer fails every session on the VM. The check
moves into `ensure_codex_remote_auth_consistent`, called from the Codex
entry points, and `prepare_session_from_target` warns instead.
`coop codex -- login --device-auth` works without `--ask`: `login` and
`logout` launch without the sandbox-bypass flag Codex rejects on them.
Keyring UX: on a fresh guest the prompt creates the keyring rather than
unlocking one, so it says so and confirms the password. The readiness
probe clears its own probe item instead of leaving it behind.
Image verification: `required_guest_binaries` checks dbus-run-session,
gnome-keyring-daemon, and secret-tool. Checking only the wrapper proved
nothing, since the provision script always writes it.
Also adds an integration phase covering the wrapper, the three tools,
and passthrough; documents why the Secret Service packages are installed
in every image; and fixes prose in config.example.toml that read as a
second `auth` key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ChatGPT auth path did not work. `codex-account` started the keyring daemon before unlocking it, and `gnome-keyring-daemon --unlock` only creates and unlocks the login collection when it is the process that starts the daemon; once any daemon owns `org.freedesktop.secrets` the unlock is handed to the graphical gcr-prompter, which cannot render on a headless guest. Every invocation failed, on both a fresh and an existing keyring. Reordering around the probe is not enough either: `secret-tool` D-Bus-activates the daemon itself. The unlock now runs before anything touches the bus, and `start_keyring` / `eval_env_output` are removed. Reproduced and verified in an ubuntu:24.04 container under a real pty for a fresh keyring, an existing keyring, a wrong password, and `api_key` passthrough. Also from review: - Report why the Secret Service is unavailable: the unlock no longer discards the daemon's stderr, and the probe captures secret-tool's stderr into the failure message instead of always blaming the password. - Clear `cli_auth_credentials_store = "keyring"` from the guest config when a VM switches back to `auth = "api_key"`. A new persisted `codex_keyring_materialized` on `ModelState` forces the rewrite, since nothing else would trigger one. It is recorded only after the guest config is written, so a failed bootstrap does not arm a rewrite on every later boot. - Warn when `--no-agents` skips the keyring setup for a VM configured with `auth = "chatgpt"`, where `codex login` would otherwise write a plaintext guest `~/.codex/auth.json`. Gated on whether an earlier boot already wrote the key, so a restart does not warn spuriously. - Correct the claim that Codex rejects `--dangerously-bypass-approvals- and-sandbox` on `login`/`logout`. Verified against codex-cli 0.152.0: it is accepted before the subcommand, which is the position `codex_launch_args` builds, and rejected only after. The subcommands are still launched bare, now for the reason that actually applies. - Settle the `--ask` drift in getting-started on the bare form. - A rebuilt image does not reach an existing VM. Point at `coop restore --image`, which swaps the disk in place, rather than only at destroy and recreate. Tests: cover `ensure_codex_remote_auth_consistent` including the per-VM `proxy.json` override it exists for, the api_key transition, the unlock ordering and stderr capture, the `--no-agents` predicate, and the `is_default` term for the new flag. Each was hand-mutated to confirm it fails when the behavior is removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects found reviewing the rebased branch, each reproduced before being fixed. `codex-account` could not be invoked from inside a session it had already set up. It exports `COOP_CODEX_ACCOUNT_DBUS=1` before re-exec'ing under `dbus-run-session` and never unsets it, so a nested call — an in-guest agent shelling out to `codex-account` or `codex-yolo` — skipped the re-exec and went straight to the unlock, on a bus whose daemon already owned `org.freedesktop.secrets`. That is the gcr-prompter trap the unlock-first ordering exists to avoid. A second marker, `COOP_CODEX_ACCOUNT_UNLOCKED`, now lets a nested call probe and reuse the session instead of prompting again. Reproduced in a container: the nested call went from exit 1 to exit 0. The wrapper eval'd the keyring daemon's stdout verbatim, so any line that was not a shell assignment ran as a command. A GLib diagnostic on stdout killed the wrapper with `eval: unexpected EOF` and exit 2. Only `NAME=value` lines are taken now, and they are exported — as bare assignments they never reached Codex. `--no-agents` read `model.json` eagerly through `?`, so an unreadable file aborted a start that otherwise never opens it. The read is now a closure behind the two cheap terms. Tests: two `CoopConfig::validate` cases for a verified cargo-mutants survivor at src/config.rs:2068, where `&&` -> `||` passed the whole suite while making both `auth = "chatgpt"` alone and a plain `[proxy.openai]` hard config errors; the sweep over `validate` is now clean apart from one pre-existing miss from #212. Guest-script assertions for the D-Bus recursion guard (breaking it fork-bombs the guest and nothing noticed), the inherited-session reuse, the probe timeouts, and the TTY guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three ways a plaintext ChatGPT token could still end up on the guest disk in a mode whose purpose is to prevent exactly that. `coop codex` checked that the guest had the Secret Service *packages* but never that `cli_auth_credentials_store = "keyring"` had actually reached the guest `~/.codex/config.toml`. The wrapper gates on that file, so enabling the mode against an already-running VM — where agent bootstrap has not re-run — meant the wrapper passed through to plain Codex and `codex login` wrote the token in the clear, unwarned. `ensure_codex_keyring_configured` now fails closed and names the fix. Dropping `auth.json` from the staged file set only stopped coop copying a new one; `copy_staged_to_guest` is additive and deletes nothing. A VM that ran in `api_key` mode kept its previously-copied plaintext credential forever. Each bootstrap in ChatGPT mode now removes it, which also covers the file a `--no-agents` login leaves behind. `api_key` mode dropped the keyring key only incidentally, by rebuilding from the host base. A host `config.toml` that itself sets `cli_auth_credentials_store` was copied straight back, leaving the guest demanding a keyring password it no longer needed. It is now removed explicitly. Two further fixes to the rewrite this mode forces on every boot: `codex_keyring_materialized` is now cleared as well as set, so a VM that switched back to `api_key` stops paying for a rewrite once the key is gone; and `[projects.*]` joins marketplaces/plugins in the preserved set, so users no longer re-approve Codex workspace trust after every restart. Smaller: the keyring unlock is bounded by `timeout` like the probe already was, and the lifecycle comment no longer claims the VM still starts when the proxy/auth conflict fires — agent bootstrap is one of the entry points that fails hard, so it does not. Tests: the new messages and both config-rewrite rules, all four mutants hand-checked. Integration now actually enters keyring mode (via CODEX_HOME) instead of only asserting the passthrough branch, and rejects the chatgpt + [proxy.openai] config pre-VM. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
03675ae to
f1826ed
Compare
|
Re-rebased onto #440 rewrote the README down to four sections and removed the feature list this PR had been editing, so both of my README hunks conflicted. I resolved by taking Also worth recording, since it was invisible: the previous force-push produced no CI run at all. Still outstanding: the integration suite has not been run on either backend. |
hbrodin
left a comment
There was a problem hiding this comment.
Approving. Thank you for this — the revision work here was genuinely excellent, and unusually rigorous.
I reviewed the ~900 lines added since my pass at ffb362e (a16cc7c, 6ef374f, f1826ed), which had had no independent review, then put every finding I raised through an adversarial falsification pass. Most of mine did not survive it, so rather than post a long list I want to record what I checked and what actually stands.
Your fixes hold up
All six findings from the first pass are properly resolved, and several went further than I suggested. You were right that my two-line reorder for the P1 did not fix it — the probe D-Bus-activates the daemon itself, so the unlock has to precede anything touching the bus. Making it unconditional and deleting start_keyring/eval_env_output is the correct shape. Turning the --no-agents check into no_agents_skips_codex_keyring with a lazy closure is better than the inline warning I proposed. And you were right to reverse my "inert" dismissal of the stale guest auth.json: read precedence was not the point, on-disk readability was, and that is the delta this mode sells.
I verified the delta by executing the wrapper rather than reading it — extracted from the heredoc, stubbed CODEX_BIN/secret-tool/gnome-keyring-daemon, driven under a real pty. Passthrough gating is correct (a commented-out key and = "file" both stay in passthrough), exit codes propagate through both execs, the TTY and missing-tool guards fire, the nested/inherited-session path does not re-prompt, fresh-vs-existing keyring prompting is right, and the daemon env now genuinely reaches Codex. I also confirmed the eval removal is a real fix by feeding the stub daemon a touch command on stdout — it was not executed.
Also checked and clean: .cargo/mutants.toml correctly needs no update (every new shell-out landed in the already-excluded src/backend.rs, and the one new scoped-module helper is pure and tested); TEMPLATE_VERSION 1→2 and required_guest_binaries 4→8 are the right cross-file sync; codex-yolo is rerouted on both backends with assertions on each; config.example.toml, docs/images-and-profiles.md, and the CHANGELOG are all in step. Leaving the README byte-identical to main post-#440 was the right call.
One follow-up, filed as #441
CODEX_HOME diverges between the wrapper (${CODEX_HOME:-$HOME/.codex}) and the two new host-side halves, which hardcode ~/.codex — so the guard can pass while the wrapper falls through to plain Codex and codex login writes a plaintext token. Filed rather than requested here, to keep this PR to one logical change.
Worth flagging one thing from that investigation: aligning the host grep to ${CODEX_HOME:-$HOME/.codex} does not fix it, because exec_ok builds a bare ssh with no session env while run_interactive uses .envs(...) + SendEnv. The fix belongs in the wrapper. Details and a suggested six-line patch are in the issue.
That issue also notes the docs/trust-model.md sentence asserting coop codex "refuses to launch when the guest config does not actually select the keyring store" is not true under CODEX_HOME — worth either implementing the guard or softening the claim.
Findings I withdrew
Recording these so they do not resurface:
- Wrapper aborting on a daemon diagnostic. I believed the
[A-Za-z_]*=*filter could match a GLib line containing=and kill the wrapper viaset -eafter the password was typed. It cannot with the real daemon: gnome-keyring's stdout is env-lines-only by upstream design (its own test says so), GLib warnings go to stderr, and the actual format leads with(pid):so the glob never fires. Upstream's own PAM module applies a weaker filter to the same stream. Your filter is fine. codex_keyring_materializedstale acrosscoop restore—codex_bootstrap_neededis unconditionally true under chatgpt auth, so a normal start re-materializes the key regardless, andcmd_restoreis not touched by this PR.restorevs destroy wording incodex-integration.md— I misread it; the preceding sentence already establishes that replacing the disk is the point, and the contrast is instance identity.stage_codex_files_drops_keyring_store_when_switching_back_to_api_key— the drop is pinned with teeth by the sibling host-supplied-config test; the tautological assertion is redundant, not a gap.- Coverage of the new
needs_codexterm — I applied a standarddocs/testing.mdexplicitly waives forbackend.rs. ensure_codex_keyring_configuredconflating SSH failure with "not configured" — pre-existing house pattern, same as thecodex_bin/claude_binchecks onmain.COOP_CODEX_ACCOUNT_UNLOCKEDsurviving the re-exec — mechanism is real but unreachable;DBUS=1is always exported first.
Nits, none blocking
src/backend.rs— the comment introducing thesuppressedcollection still opens "In proxy mode…", butsuppress_openai_keynow covers ChatGPT mode too; thesuppression_reasonclosure right below exists because there are two reasons. The two sibling comments were updated, this one was missed.codex_keyring_not_configured_messageis the onlypub fninbackend.rswith no cross-module caller; its direct siblingcodex_missing_guest_cli_messageis private. No impact (mod backend;is private), just consistency.docs/configuration.md— the golden-image Secret Service paragraph sits inside the## proxysection, where "this mode" reads as the proxy.docs/images-and-profiles.mdin this same PR already carries the same explanation, correctly placed.docs/getting-started.md— the inlinecoop restoresentence is missing thestop/starteither side, whichcodex-integration.mdgets right. Prose rather than a fenced recipe, so minor, but a reader who pastes it hitsas_topped-style errors.- In
codex_keyring_not_configured_message_names_the_recovery, thecontains("cli_auth_credentials_store")disjunct is dead — the message never names the key. Naming it would help a user inspecting the file by hand.
One request
Per CONTRIBUTING.md step 3, please run ./tests/run-integration.sh and --remote on the other backend when you have a machine for it. tests/integration.sh is not run in CI, so test_codex_account_auth_support has not executed anywhere yet, and the wrapper is the riskiest surface in this change. Not gating on it — #403 and #249 both merged with the same disclosure — but it is worth doing before the next release.
Finally: your point about the mode's security value is right and I would take you up on it. The keyring password is typed through the untrusted guest on every launch and dbus-run-session mints a fresh bus each time, so it is never amortized. What this buys is encryption at rest on the guest disk plus avoiding API-key billing, not protection from a compromised guest. docs/trust-model.md is already candid about the refresh token; the only line I would soften is "Two guardrails close the gaps that leaves," since only coop codex can fail closed — codex-account/codex-yolo structurally cannot.
Nice work. Merging.
Summary
Validation
Not run here: