2026-07-28 Codex Hook Trust and the Unkeyed Model Cache
What I set out to do
Close out the loose end from 2026-07-27 Cross-Agent Detection and Waiting State in Tmux: Codex would not run the tmux state hooks without a one-time /hooks approval in the TUI, and I had only proved them out with --dangerously-bypass-hook-trust. The question was whether that trust could be declared in Nix instead.
What I actually did
It can. Codex records trust as hooks.state."<key>".trusted_hash in config.toml, and the hash is derived from the hook definition alone: canonical JSON over codex’s own normalization, keys sorted, matcher omitted when null, timeout normalized to the 600s default. builtins.hashString "sha256" (builtins.toJSON …) reproduces it byte for byte. Declaring it means trust now lives in the same commit as the command it approves, instead of silently lapsing every time a store path changes.
The control that made this cheap: codex app-server answers hooks/list with each hook’s currentHash, trustStatus and enabled, and costs no model call. I checked all four hashes against codex before wiring anything in, and left the check behind as a test — an untrusted hook is loaded, listed and inert, so without an assertion an upstream normalization change would turn every hook off with no error anywhere.
Then a second question: why does codex -p litellm still show a static handful of models instead of the OpenRouter catalog?
What was striking
The hook probe misled me first. I watched @agent_state stay empty through a whole codex exec run and concluded the hooks were not firing — but the log said hook: SessionStart Completed. The tag was being set; the statusline reaper cleared it within two seconds because codex had already exited and left an agentless pane. I was measuring the reaper, not the hook. Same shape as yesterday’s send-keys race: the probe, not the system.
The model picker turned out to be a real upstream bug, and the code says so itself — a TODO(celia-oai) noting that provider identity is not part of cache eligibility. models_cache.json is one unkeyed file per CODEX_HOME with a 300s TTL, so whichever provider fetched last owns it. Four steps proved it: cold cache plus LiteLLM gave 742 models; the base openai provider then gave 4; switching back to LiteLLM gave 6, with fetched_at unchanged — meaning no fetch to LiteLLM happened at all. It bleeds in both directions, and refresh_if_new_etag renews the TTL on an etag match, so a long-lived session on one provider can keep the other one starved indefinitely.
A nice tell fell out of the pagination: a later page failing with cursor 600 exceeds total models 4 is not a bad cursor, it is the background refresh swapping the catalog mid-read.
Related
2026-07-27 Codex over OpenRouter - Four Stacked Bugs, Agent Hook Wiring Fails Silently in Three Different Ways, Agent Waiting State Splits Into Blocked and Idle