box: detect and install vendor CLI updates every five minutes, because the startup check never did - #245
Merged
Merged
Conversation
…heck never did The box pinned @openai/codex@0.147.0. GPT-6 Astra needs a newer client, so the server refuses that pin with a 400. We believed both CLIs updated themselves. Measured today: they do not. check_for_update_on_startup only writes a version cache. The install needs a keypress in the TUI, and Lody runs `codex app-server`, which never opens one. A payload-owned agent-cli-update service now runs both explicit update commands as blitz, every six hours, under one flock. It refuses root before any write. codex is unpinned to @latest, matching claude and the adapter's ^0.151.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QskaoYxATWsGKtWWVLHUWh
Six hours left a member on a stale harness for most of a working day. The tick now reads the installed version and the published version, and runs the vendor update only when they differ. Matching versions write nothing to the npm prefix, so a five minute poll stays cheap. An unchanged state logs at most hourly. Measured: replacing the package under a live codex app-server does not disturb it. The same pid still answered model/list, and a new session started on the new binary. So the tick has no defer-while-busy guard, which would let a box with a long-lived app-server never update. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QskaoYxATWsGKtWWVLHUWh
Resolves the broker deletion (#244) against the periodic CLI updater: the claude shim stays exec-only, the payload list keeps the agent-cli-update service and drops the register dependency, and the docs describe the update service as the model delivery path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01So15XYnmh9Hfx9xdNgG1Yu
An anti-slop pass found six constructs with no producer and no caller. The quiet-log state file stored a version and a timestamp per CLI, only to suppress one "up to date" line. An unchanged tick now writes nothing at all, which also removes the reason to rotate the log. Nothing else in the box rootfs rotates a log. BLITZ_AGENT_CLI_UPDATE_DIR called itself a test-only seam, and BLITZ_STATE_DIR already redirected the same path. The absolute-path guards on BLITZ_STATE_DIR had no producer; blitz-rules-boot and blitz-credential-refresh read it raw. The lock-wait check re-tested a zero the case had already rejected, and no production caller set the override. The log mode was set again on every line. One test asserted a variable NAME in the script source, so an identical-behavior rename failed it. It now pins the decision that state stays out of the npm prefix, not the spelling of a variable. The updater goes from 206 lines to 127. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QskaoYxATWsGKtWWVLHUWh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What part this touches
A box runs two vendor agent CLIs:
claudeandcodex. Both install into/opt/blitz/npm. Uid 1000 owns that prefix./usr/local/binholds a PATH shimfor each CLI. The shim execs the real binary in the prefix.
Lody is the chat surface. It spawns
/usr/local/bin/codex app-serverand readsthe model list from that process. The CLI version therefore decides which models
the composer offers.
docs/LODY-MODELS.mddescribes that chain.Two things ship a box. The Dockerfile builds the base image, which only reaches
NEW boxes. The payload ships separately, and it reaches boxes already running.
The gap
The box pinned
@openai/codex@0.147.0. OpenAI shipped GPT-6 Astra on2026-09-03. The server refuses an old client:
We believed both CLIs updated themselves. They do not.
check_for_update_on_startup=trueonly CHECKS. It writeslatest_version,last_checked_atanddismissed_version. It never runs npm. A keypress in theinteractive TUI starts the install. Lody runs
codex app-server, which neveropens that TUI.
Measured 2026-09-05, with the npm cache confound removed:
So no box has ever installed a CLI update.
docs/LODY-MODELS.md:141claimed"Nothing holds a CLI version anymore". Line 174 of the same file said "
codexstays pinned". Both could not be true.
vendor/lody-adapters/codex/package.jsondeclares@openai/codex@^0.151.0.The 0.147.0 pin sat below that adapter contract.
The fix
A new payload-owned service
agent-cli-updatedetects a new version andinstalls it.
BLITZ_AGENT_CLI_UPDATE_INTERVALseconds. The default is 300, matching the payload updater.npm view.<cli> updateONLY when the two differ. Matching versions write nothing to the prefix.blitzwiths6-setuidgid.flockcovers both CLIs, so two ticks cannot overlap.codex updatedoes not skipclaude update./var/lib/blitz/agent-cli-update, which is a mounted volume, so the log survives container recreation.@openai/codex@latest, matching claude.LODY-MODELS.md, theagent-shimsdocblock, and the codex shim comment.The risk trade
A 5-minute poll means a member waits at most 5 minutes for a new model. A
6-hour poll was the first draft and was rejected: it left a member on a stale
harness for most of a working day.
The poll is cheap because it detects before it writes.
npm viewis one smallrequest.
npm install -gruns only on a real version change. That is what makesa 5-minute cadence affordable.
Updating does not disturb a running session. That is measured, not assumed.
A live
codex app-serverhad its package replaced underneath it, then answeredmodel/listsuccessfully on the same pid. A new session starts on the newbinary. So this change deliberately has NO defer-while-busy guard. Gating on
session activity would let a box with a long-lived app-server never update.
@latestcosts this layer its reproducibility. Two builds a week apart shipdifferent CLIs. claude already made that trade. codex now matches it. The
rejected alternative was a hand-bumped version floor. That is exactly the state
which caused this bug.
An update rewrites
/opt/blitz/npmin place. PATH order protects the shims./usr/local/binsits ahead of the prefix, so no copy shadows them.We added no timeout around the update commands. npm bounds them already:
fetch-timeoutis 300000 ms andfetch-retriesis 2. A blackholed registry cantherefore stall one tick. Ticks cannot pile up, because the s6 loop sleeps AFTER
each tick returns rather than on a fixed wall clock.
One gap stays open, named here so nobody assumes otherwise:
maxandultraefforts get stripped from
gpt-6-astra.acp-selector-options.tshardcodesthree model ids. That fix edits vendored Lody and needs a declared seam in
BLITZ-PATCHES.md.Tests
New guest test
packages/box/guest-tests/test/agent-cli-update.test.ts, 11tests, no network. It runs the real script against fake CLIs. It proves no
update runs when versions match, that an update runs when they differ and names
both versions, that both real version strings parse (
codex-cli 0.153.4and2.1.261 (Claude Code)), that a failed probe skips and still exits 0, thatclaude still runs after codex fails, and that one flock serializes two ticks.
Two real Linux container runs used
node:22.20.0-bookworm, the box base image,with real npm and the real vendor CLIs.
Updater behaviour, 18 assertions, 0 failures:
Live session, 5 assertions, 0 failures:
Without the fix, step 2 leaves both CLIs where they started. That is the failure
this change removes.
Gates:
Suite detail: the four affected suites 25/25,
test:scripts111/111,packages/box/test/syntax.shPASS.An anti-slop pass then removed 79 lines from the updater, taking it from 206 to
127. It deleted a quiet-log state file that existed only to suppress one line,
the log rotation that state implied, a seam the script itself called "test-only"
that
BLITZ_STATE_DIRalready provided, absolute-path guards with no producer,a lock-wait check that re-tested a value its own
casehad rejected, and achmodrepeated on every log line. One test pinned a variable NAME in thescript source; it now pins the decision instead. Every measurement above was
re-run against the slimmed script.
The one failure is
webapp/test/lody-terminal-tab-wave3.test.tsx > F7 ... is inert on an address with no session to be missing. It is a pre-existing flakeunder full-suite parallel load, and this branch does not touch the webapp. Run
alone it passes 20/20 on this branch AND on unmodified
main. Reviewers shouldnot read it as a regression.
Two macOS notes for anyone re-running locally.
test:scriptsneeds GNU tar,because BSD tar rejects
--sort=name.~/.npmon a machine with root-ownedcache files breaks every npm path; set
npm_config_cachefirst.Deploy
The payload carries the service, so boxes already running receive it. Publish
happens on every push to
mainthrough the canary payload job. The Dockerfileedit is a base image change, so canary's
imagejob publishes and pins it.Verify on a box after the payload lands:
Rollback: revert this commit. The payload pin returns to the previous version,
and a fresh image returns to a pinned codex. A box that already updated its CLI
in place keeps the newer CLI, because the revert does not downgrade the prefix.