CODEX_HOME diverges between the codex-account wrapper and the host-side keyring guard, which can silently degrade ChatGPT auth to a plaintext token.
Follow-up from the review of #438, filed rather than fixed there to keep that PR to one logical change.
The divergence
The guest wrapper resolves the config Codex will actually read:
# scripts/guest/codex-account.sh
CODEX_CONFIG="${CODEX_HOME:-$HOME/.codex}/config.toml"
The two host-side halves added in #438 hardcode ~/.codex:
// src/backend.rs — ensure_codex_keyring_configured
"grep -Eq '^[[:space:]]*cli_auth_credentials_store[[:space:]]*=[[:space:]]*\"keyring\"' ~/.codex/config.toml"
// src/backend.rs — remove_guest_codex_auth_json
.literal("rm -f ~/.codex/auth.json")
With CODEX_HOME set for the guest session and pointing at a config that lacks the key, the host guard passes against the bootstrap-written ~/.codex/config.toml while the wrapper reads the other file, finds no keyring key, and execs plain Codex. coop codex -- login then writes a plaintext refresh token to $CODEX_HOME/auth.json, which remove_guest_codex_auth_json never cleans. If that path is under /workspace, the token also rides the workspace pull back to the host.
This falsifies a guarantee docs/trust-model.md currently asserts:
coop codex refuses to launch when the guest config does not actually select the keyring store
Reachability
CODEX_HOME is a valid EnvVarName (src/guest_env_state.rs), so guest_env, codex.env_forward, and coop start --env all forward it, and both backends append AcceptEnv * (scripts/guest/guest-config.sh, src/lima.rs). It reaches the interactive coop codex session via open_ssh_session → prepare_session_from_target → prepare_env_forwarding → SendEnv. #438's own integration test uses exactly this lever:
coop_exec env CODEX_HOME=/tmp/coop-keyring-probe /usr/local/bin/codex-account --version
Why the obvious fix does not work
Changing the host grep to ${CODEX_HOME:-$HOME/.codex} does not close this. Guest env is delivered per-session via SendEnv, not persisted to the guest filesystem, and exec_ok — which ensure_codex_keyring_configured uses — builds a bare ssh invocation with no session env:
pub fn exec_ok(&self, command: RemoteCommand) -> bool {
let mut args = self.ssh_opts();
args.push(self.addr());
args.push(command.into_string());
Command::new("ssh").args(&args) // no .envs(...), no SendEnv
while ssh::run_interactive does .envs(session.env.as_envs()) plus -o SendEnv=. The guard would keep expanding to ~/.codex while the interactive session expands to $CODEX_HOME.
Suggested fix
Note that coop does not support CODEX_HOME anywhere: all Codex staging hardcodes .codex (copy_staged_to_guest(target, &staged, ".codex", "Codex"), the plugin-state round-trip). Setting it already breaks MCP servers, plugins, and model routing on main. So the goal is not to plumb CODEX_HOME through one path — full support would be its own change — but to stop an unsupported configuration from degrading into a plaintext credential.
Fail closed in the wrapper's passthrough branch instead:
if ! keyring_mode; then
# coop stages into $HOME/.codex and cannot honor an arbitrary CODEX_HOME.
# If coop's own config selects the keyring store but the config Codex will
# actually read does not, `codex login` would write a plaintext auth.json —
# the one thing this mode exists to prevent. Refuse instead of degrading.
if [ "$CODEX_CONFIG" != "$HOME/.codex/config.toml" ] \
&& keyring_mode_in "$HOME/.codex/config.toml"; then
die "CODEX_HOME is set, but coop manages ~/.codex and it selects the keyring credential store; unset CODEX_HOME for Codex ChatGPT account auth"
fi
exec "$CODEX_BIN" "$@"
fi
This needs no host change (sidestepping the exec_ok env problem), fires only in the dangerous case, and is a provable no-op when CODEX_HOME is unset since both paths are then the same file. It also leaves #438's integration test passing: that test points CODEX_HOME at a scratch dir containing the key, so keyring_mode is true and the branch is never reached.
Worth factoring the regex into a keyring_mode_in <path> helper at the same time — it is currently hand-duplicated between the wrapper and ensure_codex_keyring_configured, with nothing pinning the two together.
Also worth doing
- Add a
src/guest.rs assertion for the new guard. Nothing currently tests the wrapper's gate or its env-filter loop.
- Either implement this or soften the
docs/trust-model.md sentence quoted above, so the documented guarantee matches the code.
CODEX_HOMEdiverges between thecodex-accountwrapper and the host-side keyring guard, which can silently degrade ChatGPT auth to a plaintext token.Follow-up from the review of #438, filed rather than fixed there to keep that PR to one logical change.
The divergence
The guest wrapper resolves the config Codex will actually read:
The two host-side halves added in #438 hardcode
~/.codex:With
CODEX_HOMEset for the guest session and pointing at a config that lacks the key, the host guard passes against the bootstrap-written~/.codex/config.tomlwhile the wrapper reads the other file, finds no keyring key, andexecs plain Codex.coop codex -- loginthen writes a plaintext refresh token to$CODEX_HOME/auth.json, whichremove_guest_codex_auth_jsonnever cleans. If that path is under/workspace, the token also rides the workspace pull back to the host.This falsifies a guarantee
docs/trust-model.mdcurrently asserts:Reachability
CODEX_HOMEis a validEnvVarName(src/guest_env_state.rs), soguest_env,codex.env_forward, andcoop start --envall forward it, and both backends appendAcceptEnv *(scripts/guest/guest-config.sh,src/lima.rs). It reaches the interactivecoop codexsession viaopen_ssh_session→prepare_session_from_target→prepare_env_forwarding→SendEnv. #438's own integration test uses exactly this lever:Why the obvious fix does not work
Changing the host grep to
${CODEX_HOME:-$HOME/.codex}does not close this. Guest env is delivered per-session viaSendEnv, not persisted to the guest filesystem, andexec_ok— whichensure_codex_keyring_configureduses — builds a baresshinvocation with no session env:while
ssh::run_interactivedoes.envs(session.env.as_envs())plus-o SendEnv=. The guard would keep expanding to~/.codexwhile the interactive session expands to$CODEX_HOME.Suggested fix
Note that coop does not support
CODEX_HOMEanywhere: all Codex staging hardcodes.codex(copy_staged_to_guest(target, &staged, ".codex", "Codex"), the plugin-state round-trip). Setting it already breaks MCP servers, plugins, and model routing onmain. So the goal is not to plumbCODEX_HOMEthrough one path — full support would be its own change — but to stop an unsupported configuration from degrading into a plaintext credential.Fail closed in the wrapper's passthrough branch instead:
This needs no host change (sidestepping the
exec_okenv problem), fires only in the dangerous case, and is a provable no-op whenCODEX_HOMEis unset since both paths are then the same file. It also leaves #438's integration test passing: that test pointsCODEX_HOMEat a scratch dir containing the key, sokeyring_modeis true and the branch is never reached.Worth factoring the regex into a
keyring_mode_in <path>helper at the same time — it is currently hand-duplicated between the wrapper andensure_codex_keyring_configured, with nothing pinning the two together.Also worth doing
src/guest.rsassertion for the new guard. Nothing currently tests the wrapper's gate or its env-filter loop.docs/trust-model.mdsentence quoted above, so the documented guarantee matches the code.