Skip to content

box: detect and install vendor CLI updates every five minutes, because the startup check never did - #245

Merged
pythonlearner1025 merged 5 commits into
mainfrom
box/agent-cli-update-timer
Sep 6, 2026
Merged

box: detect and install vendor CLI updates every five minutes, because the startup check never did#245
pythonlearner1025 merged 5 commits into
mainfrom
box/agent-cli-update-timer

Conversation

@pythonlearner1025

@pythonlearner1025 pythonlearner1025 commented Sep 6, 2026

Copy link
Copy Markdown
Member

What part this touches

A box runs two vendor agent CLIs: claude and codex. Both install into
/opt/blitz/npm. Uid 1000 owns that prefix. /usr/local/bin holds a PATH shim
for each CLI. The shim execs the real binary in the prefix.

Lody is the chat surface. It spawns /usr/local/bin/codex app-server and reads
the model list from that process. The CLI version therefore decides which models
the composer offers. docs/LODY-MODELS.md describes 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 on
2026-09-03. The server refuses an old client:

ERROR: {"status":400,"error":{"message":"The 'gpt-6-astra' model requires a
newer version of Codex. Please upgrade to the latest app or CLI and try again."}}

We believed both CLIs updated themselves. They do not.

check_for_update_on_startup=true only CHECKS. It writes latest_version,
last_checked_at and dismissed_version. It never runs npm. A keypress in the
interactive TUI starts the install. Lody runs codex app-server, which never
opens that TUI.

Measured 2026-09-05, with the npm cache confound removed:

codex  0.147.0, headless, flag on, 3 runs   -> 0.147.0   (npm latest 0.153.4)
claude 2.1.228, headless, 3 runs 40s apart  -> 2.1.228   (npm latest 2.1.261)

So no box has ever installed a CLI update. docs/LODY-MODELS.md:141 claimed
"Nothing holds a CLI version anymore". Line 174 of the same file said "codex
stays pinned". Both could not be true.

vendor/lody-adapters/codex/package.json declares @openai/codex@^0.151.0.
The 0.147.0 pin sat below that adapter contract.

The fix

A new payload-owned service agent-cli-update detects a new version and
installs it.

  • It waits 30 seconds after boot, then polls every BLITZ_AGENT_CLI_UPDATE_INTERVAL seconds. The default is 300, matching the payload updater.
  • Each tick reads the installed version, then reads the published version with npm view.
  • It runs <cli> update ONLY when the two differ. Matching versions write nothing to the prefix.
  • An unreadable version or a failed probe skips that CLI and logs. It never installs on a guess.
  • An unchanged tick writes nothing at all: no prefix write, no log line, no state file. The log therefore holds only real updates and real failures.
  • s6 drops each tick to blitz with s6-setuidgid.
  • The script refuses root with exit 77 before any write. Root would leave root-owned files in the prefix.
  • One flock covers both CLIs, so two ticks cannot overlap.
  • A failure in codex update does not skip claude update.
  • State lives in /var/lib/blitz/agent-cli-update, which is a mounted volume, so the log survives container recreation.
  • The Dockerfile now installs @openai/codex@latest, matching claude.
  • Three false statements are corrected: LODY-MODELS.md, the agent-shims docblock, 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 view is one small
request. npm install -g runs only on a real version change. That is what makes
a 5-minute cadence affordable.

Updating does not disturb a running session. That is measured, not assumed.
A live codex app-server had its package replaced underneath it, then answered
model/list successfully on the same pid. A new session starts on the new
binary. 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.

@latest costs this layer its reproducibility. Two builds a week apart ship
different 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/npm in place. PATH order protects the shims.
/usr/local/bin sits ahead of the prefix, so no copy shadows them.

We added no timeout around the update commands. npm bounds them already:
fetch-timeout is 300000 ms and fetch-retries is 2. A blackholed registry can
therefore 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: max and ultra
efforts get stripped from gpt-6-astra. acp-selector-options.ts hardcodes
three 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, 11
tests, 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.4 and
2.1.261 (Claude Code)), that a failed probe skips and still exits 0, that
claude 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:

[1] root refused                  rc=77
[2] one tick as uid 1000          codex  0.147.0 -> 0.153.4
                                  claude 2.1.228 -> 2.1.261
[3] prefix keeps no root-owned files
[4] up-to-date tick               package dir mtime UNCHANGED
                                  log gained no lines
                                  no state files written
[5] unreachable registry          exit 0, CLI still works, failure logged
[6] log mode 0644
[7] missing CLI survived

Live session, 5 assertions, 0 failures:

session answered BEFORE the update       ok
package replaced underneath it           0.147.0 -> 0.153.4
SAME pid still answered model/list       ok, real result
session process never died               ok
a NEW session started on the new binary  ok

Without the fix, step 2 leaves both CLIs where they started. That is the failure
this change removes.

Gates:

npm run typecheck   PASS   0 errors
npm run lint:gate   PASS   42 anti-slop, 0 blitz-house, 0 regressions, 8 max-lines
npm test            985 passed, 1 failed -- see below

Suite detail: the four affected suites 25/25, test:scripts 111/111,
packages/box/test/syntax.sh PASS.

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_DIR already provided, absolute-path guards with no producer,
a lock-wait check that re-tested a value its own case had rejected, and a
chmod repeated on every log line. One test pinned a variable NAME in the
script 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 flake
under 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 should
not read it as a regression.

Two macOS notes for anyone re-running locally. test:scripts needs GNU tar,
because BSD tar rejects --sort=name. ~/.npm on a machine with root-owned
cache files breaks every npm path; set npm_config_cache first.

Deploy

The payload carries the service, so boxes already running receive it. Publish
happens on every push to main through the canary payload job. The Dockerfile
edit is a base image change, so canary's image job publishes and pins it.

Verify on a box after the payload lands:

cat /var/lib/blitz/agent-cli-update/log
/opt/blitz/npm/bin/codex --version

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.

pythonlearner1025 and others added 2 commits September 5, 2026 17:30
…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
@pythonlearner1025 pythonlearner1025 changed the title box: install the vendor CLI updates on a timer, because the startup check never did box: detect and install vendor CLI updates every five minutes, because the startup check never did Sep 6, 2026
pythonlearner1025 and others added 3 commits September 5, 2026 22:23
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
@pythonlearner1025
pythonlearner1025 merged commit 053dbdc into main Sep 6, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant