codex¶
Modules: home/shell/codex.nix,
pkgs/codex.nix
OpenAI's terminal agent. Two decisions: who owns config.toml, and where the binary comes from.
Why programs.codex.settings is left empty¶
Home Manager 26.05 ships a programs.codex module, and its settings option is the obvious
thing to reach for: it renders an attrset into CODEX_HOME/config.toml. It is also the wrong
thing here, because that file lands as a symlink into /nix/store and Codex WRITES to
config.toml at runtime. The binary names the persistence paths itself:
failed to persist model selection
failed to persist theme selection
failed to persist approvals reviewer update
failed to persist feature flags
So /model, /theme, codex mcp add and every approval the TUI remembers are writes into that
file. Pointed at the store, they do not degrade, they FAIL, measured on 0.147.0 against exactly
the file settings would have generated:
failed to persist config at /nix/store/xicjbzz7cvhwdkdk86876jqxrwn1xl1q-codex-config
Read-only file system (os error 30) at path "/nix/store/.tmps8PdUu"
Failing loudly is the good case and it is still the wrong one: the option would trade every runtime setting for the two lines it declares. That is rule 14, one owner per artifact.
The module's own guard is what makes leaving it empty work, and it is worth knowing: the file is
declared under lib.mkIf (mergedSettings != { }), so with settings at its default the module
generates NOTHING and the path stays free for the link below.
The contract instead: a mirror versioned in the repo¶
mkOutOfStoreSymlink to home/shell/codex/config.toml,
the same contract as Claude Code's settings.json and VS Code's. Nix owns the
LINK, Codex owns the CONTENT, and what Codex changes lands as a git diff instead of drifting
where nobody can see it (rule 16).
This needed measuring and not assuming, because it USED to be broken: openai/codex#6646
reports the CLI replacing a symlinked config.toml with a regular file, and upstream first
answered that it was "by design". It was fixed before the issue closed on 19/01/2026. Verified
here on 0.147.0 with a codex mcp add through a symlink: the link kept its inode, stayed a
symlink, and the new [mcp_servers] table appeared in the file at the OTHER end. If a future
release regresses, the symptom is ~/.codex/config.toml no longer being a symlink.
Comments survive too, which they do NOT in Claude Code's JSON: the header of this config was
still there after that write. Codex vendors toml_edit 0.24, a format-preserving TOML editor, so
a write edits the value in place instead of re-serializing the document.
~/.codex also collects things that are NOT config and are not declared: auth.json, session
transcripts, and the helper binaries Codex installs for its PATH aliases. All of it is state, so
it belongs to restic (rule 6), and paths = [ "/home/v1cferr" ] already covers it with no entry
of its own.
The login is the subscription, not a key¶
forced_login_method = "chatgpt" pins the sign-in to the ChatGPT account, so the API-key path is
refused rather than offered. That is rule 12 read from the other side: an API key would be a
credential living in a shell or an env var, with nothing here to protect it. The subscription's
token goes to ~/.codex/auth.json, written by Codex itself.
The key really is parsed and not politely ignored, which is worth proving because Codex accepts
unknown top-level keys in silence: an invented bogus_key_test loaded clean, while
forced_login_method = "bogus" failed with unknown variant, expected chatgpt or api. Feeding a
bad value on purpose is the only way to tell a live config line from a decorative one.
Logging in is codex login, once, interactively: it opens the browser against localhost:1455,
so it cannot happen at build time and does not need to.
Auto mode is pinned, and where the two keys sit is load-bearing¶
sandbox_mode = "workspace-write" plus approval_policy = "never": Codex runs commands on its
own, and the SANDBOX is what bounds that, not a prompt. Reaching outside the working directory or
onto the network fails instead of asking. Pinned rather than left implicit because the measured
default already WAS that sandbox with on-request, and a default is upstream's to change.
Both keys must sit ABOVE the first table header, which cost a wrong measurement on
19/08/2026. Appended at the END of the file they landed after [tui], so TOML read them as
tui.sandbox_mode and tui.approval_policy, Codex ignored two keys it did not know THERE, and
codex doctor kept reporting approval OnRequest with the file looking correct. This is the
silent-unknown-key behavior from the section above, met from the other direction: the value was
right and the SCOPE was wrong. codex doctor is what settles it, since it prints the effective
policy rather than the file:
That matters more than usual here, because Codex WRITES to this file: anything appended by hand
after the app has added a [projects.…] or [tui] table is in that table, not at the root.
Why the OFFICIAL binary and not nixpkgs¶
Upstream releases most days, and the third layer of the version strategy exists for exactly that.
On 19/08/2026 the count was: stable 26.05 on 0.146.0, nixpkgs-unstable on 0.147.0, upstream on
0.148.0. The middle layer was the first answer here and lasted a few hours, until Codex itself
printed the update banner for a release nixpkgs did not have yet. For an agent CLI the gap is
missing model support, not missing polish, so the layer that ends the question is upstream.
fetchurl on the GitHub release asset, with the version and hash rewritten by
codex-bump on every update. NOT a source build: nixpkgs
compiles Codex from Rust, so overriding its src would mean recomputing a vendor hash and
recompiling a 251 MiB binary on a project that tags almost daily. The published artifact is the
same thing without the wait.
The bump script is installed next to the package, and that line is load-bearing: the alias
calls it by name, so while it was only a flake package the chain died at
codex-bump: command not found and never reached nix flake update. Caught on 24/08/2026, five
days after the package landed, which is five days of an update that bumped nothing.
Take the -package- asset, not the bare codex- one, and this is the trap that cost a broken
session on 19/08/2026. The bare tarball is the entrypoint ALONE, one 251 MiB file, and it looks
complete. It is not: Codex spawns a SECOND binary to run commands, codex-code-mode-host, and
looks for it NEXT TO its own executable. Without it every command dies with
failed to spawn code-mode host <store-path>/bin/codex-code-mode-host: No such file or directory
Code mode will fail closed; enable `features.code_mode_host` and install `codex-code-mode-host`
which reads like a Codex bug and is a packaging bug. nixpkgs ships the extra binaries and that is
the detail this repo skipped when it left. -package- is the OFFICIAL layout, declared in a
codex-package.json at its root (entrypoint, pathDir, resourcesDir), so there is one asset,
one hash and nothing to assemble by hand.
Only bin/ is installed. What is deliberately left behind:
| Dropped | Why |
|---|---|
codex-path/rg |
nixpkgs' ripgrep goes on the PATH instead, and it gets security updates |
codex-resources/bwrap |
same, and Codex takes a system bwrap from the PATH by its own message |
codex-resources/zsh |
the ONE dynamically linked file in the tarball, so it could not run here |
Dropping the zsh is verified and not a hope: with it gone Codex ran its command through
/run/current-system/sw/bin/zsh -lc, the system shell.
The rest is short because upstream ships STATIC musl. Measured before writing the derivation,
straight out of the tarball: no interpreter, codex-cli 0.148.0, clean codex doctor.
Two things still have to be added, and they are the two nixpkgs also adds:
| PATH entry | What breaks without it |
|---|---|
ripgrep |
the search, which reports Install ripgrep or repair the bundled Codex package |
bubblewrap |
the sandbox; Codex says it takes either a PATH bwrap or the bundled one |
--inherit-argv0 on the wrapper, and NOT a plain shell wrapper, because Codex re-executes itself
as its own sandbox helper. Verified with env -i, where rg can only come from the wrapper:
codex doctor reported ripgrep 15.1.0 and restricted fs + restricted network.
dontStrip is deliberate: it is a released artifact, and stripping it makes the binary stop
matching what upstream published for no gain.
The in-app updater is not the path here. The banner Codex prints ("Update available, run to
install") and its codex update subcommand both want to overwrite the binary, which is in the
read-only store. What updates Codex on this machine is update, the alias, like everything else.
A third-party flake was passed over. There are several that track Codex upstream and would
have cost one input instead of two files (sadjow/codex-cli-nix even rebuilds hourly). For a tool
that holds the ChatGPT session and executes shell commands, the fetch goes straight to the
publisher with a hash this repo pins, and the trusted set does not grow by one stranger. The
pkgs/ file is 50 lines and the bump script is the one that was going to exist anyway.