From 84b1289da6a288f58f91c6df0f69271fc8730c56 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sat, 5 Sep 2026 17:30:14 -0700 Subject: [PATCH 1/3] box: install the vendor CLI updates on a timer, because the startup check 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) Claude-Session: https://claude.ai/code/session_01QskaoYxATWsGKtWWVLHUWh --- docs/LODY-MODELS.md | 55 ++-- packages/box/Dockerfile | 32 +- packages/box/RECORD.md | 10 +- .../guest-tests/test/agent-cli-update.test.ts | 286 ++++++++++++++++++ .../box/guest-tests/test/agent-shims.test.ts | 15 +- .../box/rootfs/etc/profile.d/blitz-npm.sh | 12 +- .../dependencies.d/init-state | 1 + .../s6-overlay/s6-rc.d/agent-cli-update/run | 33 ++ .../s6-overlay/s6-rc.d/agent-cli-update/type | 1 + .../s6-rc.d/user/contents.d/agent-cli-update | 1 + packages/box/rootfs/usr/local/bin/claude | 11 +- packages/box/rootfs/usr/local/bin/codex | 22 +- .../usr/local/libexec/blitz-agent-cli-update | 117 +++++++ packages/box/test/smoke.sh | 2 +- packages/box/test/syntax.sh | 5 +- .../scripts/lib/box-payload-files.mjs | 14 +- .../test/box-payload-files.test.mjs | 1 + .../test/publish-box-payload.test.mjs | 1 + .../schema/fixtures/box-payload/README.md | 2 + .../box-payload/valid/full-manifest.json | 25 ++ 20 files changed, 557 insertions(+), 89 deletions(-) create mode 100644 packages/box/guest-tests/test/agent-cli-update.test.ts create mode 100644 packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/dependencies.d/init-state create mode 100755 packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/run create mode 100644 packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/type create mode 100644 packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/agent-cli-update create mode 100755 packages/box/rootfs/usr/local/libexec/blitz-agent-cli-update diff --git a/docs/LODY-MODELS.md b/docs/LODY-MODELS.md index ad1add36..9fe8cd19 100644 --- a/docs/LODY-MODELS.md +++ b/docs/LODY-MODELS.md @@ -9,8 +9,9 @@ model picker". The short answer: **model discovery is already dynamic and nothing in BlitzOS or Lody needs a per-model edit — the only ceiling is the `@anthropic-ai/claude-code` version the box is running.** §4 measures the whole path end to end: a `claude update` on a live box put Fable 5.1 in the composer -with no rebake, no code change and no vendor bump. What is missing is only the -trigger — the automatic updater is off in four places. +with no rebake, no code change and no vendor bump. Vendor background checks do +not install updates in headless runs. The payload-owned `agent-cli-update` +service now supplies the missing trigger. ## 1. The discovery chain (dynamic, end to end) @@ -51,8 +52,8 @@ either. `CLAUDE_MODEL_CONFIG` parses to the same two keys ## 3. What actually blocks Fable 5.1 -`packages/box/Dockerfile:36` pins `@anthropic-ai/claude-code@2.1.228`. -That binary has no `claude-fable-5-1` string at all (it knows +The measured box ran `@anthropic-ai/claude-code@2.1.228`. +That binary had no `claude-fable-5-1` string at all (it knew `claude-fable-5`, `claude-opus-5`, `claude-sonnet-5`, `claude-mythos-5`, `claude-haiku-4-5`), so step 1 above never reports it. @@ -111,8 +112,7 @@ vendored static list was never consulted. Lody launches through `BLITZ_CLAUDE_EXECUTABLE`. The `~/.local/bin` shadow copy the Dockerfile comment warns about is what the **native** installer produces; the npm-global path does not take it. `NPM_CONFIG_PREFIX` being - owned by uid 1000 is what makes the in-place rewrite possible — and the - Dockerfile says that ownership exists so `claude` *can* auto-update. + owned by uid 1000 is what makes the explicit in-place update possible. - **The browser half re-probes unconditionally.** `runStartupAcpCapabilitiesRefresh` has no staleness check, no version compare and no cache: it refreshes every config every time it is called. @@ -123,7 +123,7 @@ vendored static list was never consulted. per runtime mount, i.e. once per load of the Lody surface.** A member who reloads the tab after an update gets the new list. -### What was blocking it (removed 2026-09-01) +### Background checks do not install headless `DISABLE_AUTOUPDATER=1` had been set in four places — the image-wide `ENV` in `packages/box/Dockerfile`, the PATH shim `rootfs/usr/local/bin/claude`, @@ -134,21 +134,25 @@ paths: s6 daemons inherit the image ENV, login shells rebuild from `/etc/profile`, the shim covers any invocation, and the broker constructs the child environment from scratch rather than inheriting it. -The flag gated the **background** update check only — the explicit `claude -update` subcommand ignored it, which is why the run above worked with the flag -live in the environment. +The flag gated the **background** update check only. Removing it restored the +vendor check, not unattended installation. The explicit `claude update` +subcommand ignored the flag, which is why the run above worked while the flag +was still present. -All four are gone, `codex`'s shim now passes -`-c check_for_update_on_startup=true`, and `@anthropic-ai/claude-code` is -installed `@latest` at build time rather than pinned. Nothing holds a CLI -version anymore. +Measured on 2026-09-05, Codex 0.147.0 wrote `latest_version`, +`last_checked_at`, and `dismissed_version`, but did not install an update. +Lody starts `codex app-server`, which never enters the TUI or accepts the +update keypress. Claude Code 2.1.228 also stayed at 2.1.228 across three +headless runs. Explicit commands with `NPM_CONFIG_PREFIX=/opt/blitz/npm` +updated Codex from 0.147.0 to 0.153.4 and Claude Code from 2.1.228 to 2.1.261. ## 5. Path forward -**Done 2026-09-01: the vendor's own auto-update path.** The flag is removed -from all four sites, codex's startup check is on, and the claude build pin is -`@latest`. An s6 oneshot driving `claude update` was considered and rejected as -redundant once the vendor updater is simply left alone. +**Done 2026-09-05: a payload-owned periodic updater.** The +`agent-cli-update` longrun waits briefly after boot and then runs explicit +`codex update` and `claude update` commands every six hours. It runs as blitz, +uses the blitz-owned npm prefix, and logs failures without stopping its loop. +Both packages also use `@latest` when a fresh image is built. The shadow-copy fear the old comments cited is handled independently: `rootfs/etc/profile.d/blitz-npm.sh` force-moves `/usr/local/bin` to the FRONT of @@ -161,18 +165,15 @@ will be wrong. Still outstanding: -1. **Land the image change on `main`.** The automatic canary `image` job +1. **Land the change on `main`.** The automatic canary `image` job described in [BOX-IMAGE.md](BOX-IMAGE.md#automatic-canary-image-publish) publishes and - pins the matching versioned R2 archive. There is no separate manual image - step; running boxes update their CLI in place, while fresh boxes start from - the newly pinned image. + pins the matching versioned R2 archive. The payload job delivers the updater + service to running boxes. Fresh boxes also start with current CLI packages. 2. **`@latest` costs this layer its reproducibility.** Two builds a week apart - ship different CLIs. That is the deliberate trade — the pin never held a - version in practice, because the first self-update moved it — but it means - the box image is no longer bit-reproducible from the Dockerfile alone. - `codex` stays pinned. Lody is independent: its daemon is identified by the - vendored upstream commit and build stamp. + can ship different CLIs. This means the box image is not bit-reproducible + from the Dockerfile alone. Lody is independent: its daemon is identified by + the vendored upstream commit and build stamp. With no pin deciding which models exist, these four `2.1.228` assertions need re-basing on a range or a probe rather than a diff --git a/packages/box/Dockerfile b/packages/box/Dockerfile index ee1452c8..4ae44e4d 100644 --- a/packages/box/Dockerfile +++ b/packages/box/Dockerfile @@ -37,22 +37,21 @@ RUN --mount=type=cache,id=lody-pnpm-node22-pnpm10.20,target=/root/.local/share/p FROM node:22.20.0-bookworm-slim@sha256:b21fe589dfbe5cc39365d0544b9be3f1f33f55f3c86c87a76ff65a02f8f5848e AS vendors -# Agents install into a prefix the runtime user owns, so `claude` can -# auto-update and users can `npm install -g` without root. +# Agents install into a prefix the runtime user owns. The periodic updater and +# users can change global packages without root. # -# `claude` is deliberately UNPINNED. Its version decides which models the Lody -# composer can offer (docs/LODY-MODELS.md), it updates itself on every box, and -# a build-time pin therefore only decided how stale a fresh box was on its first -# boot — it never held a version, because the first update moved it. Taking -# `@latest` at build time costs the reproducibility of this layer and buys a box -# that is current the moment it starts. `codex` stays pinned; Lody is installed -# into its independently switchable prefix in the next stage. +# Both CLIs are deliberately unpinned. The payload-owned agent-cli-update +# service runs their explicit update commands on existing boxes. Taking +# `@latest` here also makes a fresh box current when it starts. +# `vendor/lody-adapters/codex/package.json` declares +# `@openai/codex@^0.151.0`. The old 0.147.0 pin was below that contract. Lody +# uses its independent prefix in the next stage. ENV NPM_CONFIG_PREFIX=/opt/blitz/npm # `ws` carries no binary: it is the only dependency of # /usr/local/libexec/blitz-lody-bridge, which reaches it through NODE_PATH. RUN npm install --global --omit=dev \ @anthropic-ai/claude-code@latest \ - @openai/codex@0.147.0 \ + @openai/codex@latest \ ws@8.21.0 \ && npm cache clean --force @@ -200,14 +199,11 @@ RUN set -eux; \ # shadow them. That shadowing is the single most common way a terminal ends up # signed out while the same box holds a valid credential. # -# THE VENDOR CLIs UPDATE THEMSELVES, and that is the point. A new Anthropic -# model reaches the Lody composer only when the `claude` binary is new enough to -# report it in its ACP `initialize` response — every layer above is passthrough -# (docs/LODY-MODELS.md). Holding the CLI at a build-time version therefore held -# the model list at build time too, and made a box-image rebake the delivery -# mechanism for models. It no longer is: `DISABLE_AUTOUPDATER` is gone from the -# image, the shims, the profile and the broker, and codex's shim now leaves its -# startup update check on. +# THE PERIODIC SERVICE UPDATES BOTH VENDOR CLIs. A new Anthropic model reaches +# the Lody composer only when `claude` is new enough to report it in its ACP +# `initialize` response (docs/LODY-MODELS.md). The vendor background checks do +# not install an update in a headless process. The payload-owned +# agent-cli-update service runs both explicit update commands instead. # # What still protects the shims is the PATH order above, not a pin: an update # rewrites /opt/blitz/npm in place, and /usr/local/bin sits ahead of it, so the diff --git a/packages/box/RECORD.md b/packages/box/RECORD.md index d429b204..9d9c0673 100644 --- a/packages/box/RECORD.md +++ b/packages/box/RECORD.md @@ -16,12 +16,13 @@ reserved for boxes already in the field. Successor plan: - One Linux OCI image. Platforms: `linux/amd64` + `linux/arm64`. Published immutably. Run by digest. Mac runs the same Linux image. This replaces both - native installers (~1,275 duplicate lines), curl|sh, the host tarball, - NodeSource, and self-update. + native installers (~1,275 duplicate lines), curl|sh, the host tarball, and + NodeSource. - Three externally reachable surfaces: key-only sshd · ttyd+tmux · the files HTTP origin, which carries WebDAV, port discovery, and preview proxying. No heartbeat. No exec jobs. No activity. No layout REST. No volume API. -- Claude and Codex run as their pinned official CLIs inside tmux. They read +- Claude and Codex run as official CLIs inside tmux. A payload-owned service + updates both in place. They read the native HOME files on the state volume (`claude login` over ssh, once). - `blitz-cred` (register/token/watch) comes from the open broker module. This repo keeps no second shell implementation. @@ -36,7 +37,8 @@ reserved for boxes already in the field. Successor plan: SKIPPED (2026-08-11). The box runs alone: `docker run` → working box, zero accounts; agent credentials = native HOME files (`claude login` over ssh, once). The CP + broker are an opt-in overlay. -- Image contents, all pinned by digest or version: `node:22-bookworm-slim` base +- Image contents use pinned digests or versions except for the two agent CLIs: + `node:22-bookworm-slim` base (Node stays: the agent CLIs are Node; NodeSource dies), openssh, tmux, git, ttyd (checksummed release), dufs 0.46.0 (checksummed release), Claude Code, `@openai/codex`, static `blitz-cred`, and the diff --git a/packages/box/guest-tests/test/agent-cli-update.test.ts b/packages/box/guest-tests/test/agent-cli-update.test.ts new file mode 100644 index 00000000..49a5214a --- /dev/null +++ b/packages/box/guest-tests/test/agent-cli-update.test.ts @@ -0,0 +1,286 @@ +import { spawn, spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +const updater = fileURLToPath( + new URL("../../rootfs/usr/local/libexec/blitz-agent-cli-update", import.meta.url), +); +const serviceRoot = fileURLToPath( + new URL("../../rootfs/etc/s6-overlay/s6-rc.d/", import.meta.url), +); +const setpriv = "/usr/bin/setpriv"; +const sudo = "/usr/bin/sudo"; +const hostUid = process.getuid?.() ?? 1000; +const dropFromRoot = process.platform === "linux" && hostUid === 0 && existsSync(setpriv); +const sudoRoot = hostUid !== 0 + && existsSync(sudo) + && spawnSync(sudo, ["-n", "true"]).status === 0; +const rootIt = hostUid === 0 || sudoRoot ? it : it.skip; +const temporaryDirectories: string[] = []; +const cliNames: ReadonlyArray<"codex" | "claude"> = ["codex", "claude"]; + +function writeExecutable(filePath: string, source: string): void { + writeFileSync(filePath, source); + chmodSync(filePath, 0o755); +} + +class Harness { + readonly root = mkdtempSync(path.join(tmpdir(), "blitz-agent-cli-update-")); + readonly bin = path.join(this.root, "bin"); + readonly calls = path.join(this.root, "calls"); + readonly stateDir = path.join(this.root, "state"); + readonly updateDir = path.join(this.root, "update"); + + constructor(options: { missing?: "codex" | "claude" } = {}) { + temporaryDirectories.push(this.root); + chmodSync(this.root, 0o777); + writeFileSync(this.calls, "", { mode: 0o666 }); + chmodSync(this.calls, 0o666); + mkdirSync(this.bin); + chmodSync(this.bin, 0o777); + + writeExecutable( + path.join(this.bin, "flock"), + [ + "#!/bin/sh", + "[ \"$1\" = -w ] || exit 64", + "shift 2", + "lock=$1", + "shift", + "command=$1", + "shift", + "lock_dir=\"$lock.held\"", + "while ! mkdir \"$lock_dir\" 2>/dev/null; do sleep 0.02; done", + "trap 'rmdir \"$lock_dir\" 2>/dev/null || true' EXIT HUP INT TERM", + "bash \"$command\" \"$@\"", + "status=$?", + "rmdir \"$lock_dir\" 2>/dev/null || true", + "trap - EXIT HUP INT TERM", + "exit \"$status\"", + "", + ].join("\n"), + ); + writeExecutable( + path.join(this.bin, "npm"), + [ + "#!/bin/sh", + "printf 'npm|%s|%s|%s|%s\\n' \"$*\" \"$HOME\" \"$USER\" \"$NPM_CONFIG_PREFIX\" >>\"$BLITZ_TEST_CALLS\"", + "exit 0", + "", + ].join("\n"), + ); + for (const cli of cliNames) { + if (options.missing === cli) continue; + const upper = cli.toUpperCase(); + writeExecutable( + path.join(this.bin, cli), + [ + "#!/bin/sh", + `printf '${cli}|%s|%s|%s|%s\\n' "$*" "$HOME" "$USER" "$NPM_CONFIG_PREFIX" >>"$BLITZ_TEST_CALLS"`, + `if [ "\${BLITZ_TEST_HOLD_${upper}:-0}" = 1 ]; then`, + ` touch "$BLITZ_TEST_${upper}_READY"`, + ` while [ ! -e "$BLITZ_TEST_${upper}_RELEASE" ]; do sleep 0.02; done`, + "fi", + `if [ "\${BLITZ_TEST_FAIL_${upper}:-0}" = 1 ]; then exit 23; fi`, + `npm ${cli} "$@"`, + "", + ].join("\n"), + ); + } + } + + environment(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + ...process.env, + PATH: `${this.bin}:/usr/bin:/bin`, + BLITZ_STATE_DIR: this.stateDir, + BLITZ_AGENT_CLI_UPDATE_DIR: this.updateDir, + BLITZ_AGENT_CLI_UPDATE_LOCK_WAIT: "5", + BLITZ_TEST_CALLS: this.calls, + ...extra, + }; + } + + callLines(): string[] { + return readFileSync(this.calls, "utf8").trim().split("\n").filter(Boolean); + } +} + +function invocation(asBlitz = true): { command: string; args: string[] } { + if (!asBlitz && sudoRoot) { + return { command: sudo, args: ["-n", "/bin/bash", updater] }; + } + if (asBlitz && dropFromRoot) { + return { + command: setpriv, + args: [ + "--reuid=1000", + "--regid=1000", + "--clear-groups", + "/usr/bin/env", + "bash", + updater, + ], + }; + } + return { command: "bash", args: [updater] }; +} + +function runUpdater(harness: Harness, extra: NodeJS.ProcessEnv = {}, asBlitz = true) { + const run = invocation(asBlitz); + return spawnSync(run.command, run.args, { + encoding: "utf8", + env: harness.environment(extra), + }); +} + +function startUpdater(harness: Harness, extra: NodeJS.ProcessEnv = {}) { + const run = invocation(); + const child = spawn(run.command, run.args, { + env: harness.environment(extra), + stdio: ["ignore", "pipe", "pipe"], + }); + const done = new Promise<{ status: number | null; stderr: string }>((resolve) => { + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { stderr += chunk; }); + child.on("close", (status) => resolve({ status, stderr })); + }); + return { done }; +} + +async function waitForFile(filePath: string): Promise { + const deadline = Date.now() + 3000; + while (!existsSync(filePath)) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${filePath}`); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("agent CLI updater", () => { + it("refuses root before its first filesystem write", () => { + const source = readFileSync(updater, "utf8"); + const rootGuard = source.indexOf('if [ "$current_uid" -eq 0 ]'); + + expect(rootGuard).toBeGreaterThan(0); + expect(source.indexOf("exit 77", rootGuard)).toBeLessThan(source.indexOf("mkdir -p")); + expect(source).toContain("current_uid=$(/usr/bin/id -u)"); + }); + + it("is a registered longrun that drops to blitz and sleeps after every tick", () => { + const runPath = path.join(serviceRoot, "agent-cli-update/run"); + const source = readFileSync(runPath, "utf8"); + const code = source.split("\n") + .filter((line) => !line.trimStart().startsWith("#")) + .join("\n"); + + expect(spawnSync("bash", ["-n", runPath]).status).toBe(0); + expect(spawnSync("bash", ["-n", updater]).status).toBe(0); + expect(readFileSync(path.join(serviceRoot, "agent-cli-update/type"), "utf8").trim()) + .toBe("longrun"); + expect(statSync(path.join(serviceRoot, "agent-cli-update/dependencies.d/init-state")).isFile()) + .toBe(true); + expect(statSync(path.join(serviceRoot, "user/contents.d/agent-cli-update")).isFile()) + .toBe(true); + expect(code).toContain("BLITZ_AGENT_CLI_UPDATE_INTERVAL:-21600"); + expect(code).toContain("BLITZ_AGENT_CLI_UPDATE_BOOT_DELAY:-30"); + expect(code).toContain("s6-setuidgid blitz"); + expect(code).toContain("HOME=\"$state_dir/home\" USER=blitz NPM_CONFIG_PREFIX=/opt/blitz/npm"); + expect(code.indexOf('sleep "$boot_delay"')).toBeLessThan(code.indexOf("while true")); + expect(code.indexOf("blitz-agent-cli-update || true")).toBeLessThan( + code.lastIndexOf('sleep "$interval"'), + ); + }); + + it("runs both explicit update commands with the owned npm environment", () => { + const harness = new Harness(); + const result = runUpdater(harness); + + expect(result.status, result.stderr).toBe(0); + expect(harness.callLines()).toEqual([ + `codex|update|${harness.stateDir}/home|blitz|/opt/blitz/npm`, + `npm|codex update|${harness.stateDir}/home|blitz|/opt/blitz/npm`, + `claude|update|${harness.stateDir}/home|blitz|/opt/blitz/npm`, + `npm|claude update|${harness.stateDir}/home|blitz|/opt/blitz/npm`, + ]); + expect(statSync(harness.updateDir).mode & 0o777).toBe(0o755); + expect(statSync(path.join(harness.updateDir, "log")).mode & 0o777).toBe(0o644); + }); + + it("runs claude after codex fails", () => { + const harness = new Harness(); + const result = runUpdater(harness, { BLITZ_TEST_FAIL_CODEX: "1" }); + + expect(result.status, result.stderr).toBe(0); + expect(harness.callLines().filter((line) => !line.startsWith("npm|"))) + .toEqual([ + `codex|update|${harness.stateDir}/home|blitz|/opt/blitz/npm`, + `claude|update|${harness.stateDir}/home|blitz|/opt/blitz/npm`, + ]); + expect(readFileSync(path.join(harness.updateDir, "log"), "utf8")) + .toContain("codex update failed (exit 23)"); + }); + + it("survives a missing CLI and still runs the other one", () => { + const harness = new Harness({ missing: "codex" }); + const result = runUpdater(harness); + + expect(result.status, result.stderr).toBe(0); + expect(harness.callLines().some((line) => line.startsWith("claude|update|"))).toBe(true); + expect(readFileSync(path.join(harness.updateDir, "log"), "utf8")) + .toContain("codex is missing; skipped"); + }); + + it("serializes concurrent ticks with one flock", async () => { + const harness = new Harness(); + const ready = path.join(harness.root, "codex-ready"); + const release = path.join(harness.root, "codex-release"); + const first = startUpdater(harness, { + BLITZ_TEST_HOLD_CODEX: "1", + BLITZ_TEST_CODEX_READY: ready, + BLITZ_TEST_CODEX_RELEASE: release, + }); + await waitForFile(ready); + const second = startUpdater(harness); + + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(harness.callLines().filter((line) => line.startsWith("codex|"))).toHaveLength(1); + writeFileSync(release, "release\n"); + + const [firstResult, secondResult] = await Promise.all([first.done, second.done]); + expect(firstResult.status, firstResult.stderr).toBe(0); + expect(secondResult.status, secondResult.stderr).toBe(0); + expect(harness.callLines() + .filter((line) => line.startsWith("codex|") || line.startsWith("claude|")) + .map((line) => line.split("|", 1)[0])) + .toEqual(["codex", "claude", "codex", "claude"]); + }); + + rootIt("refuses a real root invocation before it touches the npm prefix [root only]", () => { + const harness = new Harness(); + const result = runUpdater(harness, {}, false); + + expect(result.status).toBe(77); + expect(result.stderr).toContain("refusing to run as root"); + expect(harness.callLines()).toEqual([]); + expect(existsSync(harness.updateDir)).toBe(false); + }); +}); diff --git a/packages/box/guest-tests/test/agent-shims.test.ts b/packages/box/guest-tests/test/agent-shims.test.ts index 41f83386..e81f1af2 100644 --- a/packages/box/guest-tests/test/agent-shims.test.ts +++ b/packages/box/guest-tests/test/agent-shims.test.ts @@ -8,12 +8,11 @@ import { describe, expect, it } from "vitest"; * or `codex` enters here first and the shim decides what the vendor CLI is * allowed to do before execing the vendor binary. * - * BOTH CLIs UPDATE THEMSELVES, and that is intended: claude's version is what - * decides which models the Lody composer can offer (docs/LODY-MODELS.md), so a - * held version held the model list too. An update runs `npm install -g` into - * NPM_CONFIG_PREFIX — /opt/blitz/npm, owned by uid 1000 — which rewrites the - * copy the shim execs IN PLACE. The PATH order is what makes that safe rather - * than shadowing, and it is asserted below. + * The shims do not install updates. Codex's flag only enables its startup + * check, and neither vendor background path installs during a headless run. + * The payload-owned agent-cli-update service runs both explicit update + * commands. They rewrite NPM_CONFIG_PREFIX in place as uid 1000. The PATH + * order keeps the shims ahead of those updated binaries. * * codex has no environment variable for the check: checked against * @openai/codex@0.147.0, `codex doctor --json` reports @@ -30,7 +29,7 @@ const shimPath = (name: string) => const shim = (name: string) => readFileSync(shimPath(name), "utf8"); describe("vendor CLI PATH shims", () => { - it.each(["claude", "codex"])("%s execs the pinned binary, not the name again", (name) => { + it.each(["claude", "codex"])("%s execs the managed binary, not the name again", (name) => { // /usr/local/bin comes first on PATH, so a bare `exec claude` would // re-enter this shim and loop until the box runs out of processes. expect(shim(name)).toContain(`exec /opt/blitz/npm/bin/${name} `); @@ -42,7 +41,7 @@ describe("vendor CLI PATH shims", () => { expect(statSync(shimPath(name)).mode & 0o755).toBe(0o755); }); - it("leaves claude's auto-updater on", () => { + it("leaves claude's background update check on", () => { // The flag used to be exported here and in three other places. It is gone: // holding the CLI version held the model list with it. Assert the absence, // so re-adding it anywhere in this shim is a test failure and not a quiet diff --git a/packages/box/rootfs/etc/profile.d/blitz-npm.sh b/packages/box/rootfs/etc/profile.d/blitz-npm.sh index a605eabb..124a749c 100644 --- a/packages/box/rootfs/etc/profile.d/blitz-npm.sh +++ b/packages/box/rootfs/etc/profile.d/blitz-npm.sh @@ -8,8 +8,8 @@ esac # ...and then put /usr/local/bin back in FRONT of it. The order is # load-bearing, not cosmetic: /usr/local/bin/claude is the shim that mints a -# token and execs the pinned binary, and /opt/blitz/npm/bin/claude is that -# pinned binary. Leaving the npm prefix first means every terminal `claude` +# token and execs the managed binary, and /opt/blitz/npm/bin/claude is that +# vendor binary. Leaving the npm prefix first means every terminal `claude` # skips the shim and runs signed out, which is exactly what a stray # `PATH=/opt/blitz/npm/bin:$PATH` did before this block existed. case ":$PATH:" in @@ -17,9 +17,7 @@ case ":$PATH:" in *) PATH="/usr/local/bin:$PATH" ;; esac -# NOTE: the PATH order above is what keeps a self-updated copy from shadowing -# the shims, and it is the only thing that does. The vendor CLIs update -# themselves on purpose — claude's version decides which models the Lody -# composer can offer (docs/LODY-MODELS.md) — and an update rewrites -# /opt/blitz/npm in place, which /usr/local/bin already sits ahead of. +# The PATH order keeps an updated vendor binary from shadowing the shims. The +# agent-cli-update service rewrites /opt/blitz/npm in place, and +# /usr/local/bin already sits ahead of it. export PATH diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/dependencies.d/init-state b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/dependencies.d/init-state new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/dependencies.d/init-state @@ -0,0 +1 @@ + diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/run b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/run new file mode 100755 index 00000000..54a4d228 --- /dev/null +++ b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/run @@ -0,0 +1,33 @@ +#!/command/with-contenv bash +# Run both vendor update commands as blitz. The initial delay lets boot traffic +# settle. Every outcome reaches the sleep, so a network failure cannot spin s6. +set -uo pipefail + +state_dir=${BLITZ_STATE_DIR:-/var/lib/blitz} +interval=${BLITZ_AGENT_CLI_UPDATE_INTERVAL:-21600} +boot_delay=${BLITZ_AGENT_CLI_UPDATE_BOOT_DELAY:-30} + +valid_seconds() { + local value=$1 minimum=$2 + case "$value" in + '' | *[!0-9]*) return 1 ;; + esac + [ "${#value}" -le 9 ] && [ "$value" -ge "$minimum" ] +} + +if ! valid_seconds "$interval" 1; then + echo 'agent-cli-update: invalid interval; using 21600 seconds' + interval=21600 +fi +if ! valid_seconds "$boot_delay" 0; then + echo 'agent-cli-update: invalid boot delay; using 30 seconds' + boot_delay=30 +fi + +sleep "$boot_delay" +while true; do + /command/s6-setuidgid blitz /usr/bin/env \ + HOME="$state_dir/home" USER=blitz NPM_CONFIG_PREFIX=/opt/blitz/npm \ + /usr/local/libexec/blitz-agent-cli-update || true + sleep "$interval" +done diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/type b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/type new file mode 100644 index 00000000..5883cff0 --- /dev/null +++ b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/type @@ -0,0 +1 @@ +longrun diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/agent-cli-update b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/agent-cli-update new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/agent-cli-update @@ -0,0 +1 @@ + diff --git a/packages/box/rootfs/usr/local/bin/claude b/packages/box/rootfs/usr/local/bin/claude index d6480dd0..7758fb25 100755 --- a/packages/box/rootfs/usr/local/bin/claude +++ b/packages/box/rootfs/usr/local/bin/claude @@ -1,6 +1,6 @@ #!/bin/sh # PATH shim for Claude Code. Every terminal `claude` comes through here, picks -# up a freshly minted token, and execs the pinned binary. +# up a freshly minted token, and execs the managed vendor binary. # # WHY AN ENVIRONMENT VARIABLE, and specifically this one: the broker mints an # OAuth access token (`sk-ant-oat01-…`). `CLAUDE_CODE_OAUTH_TOKEN` is the OAuth @@ -20,11 +20,10 @@ # # An already-set token is left alone, so a caller can override. # -# THE AUTO-UPDATER IS LEFT ON, deliberately. claude's version is what decides -# which models the Lody composer can offer (docs/LODY-MODELS.md), so holding it -# held the model list too. An update rewrites /opt/blitz/npm in place, and -# /usr/local/bin sits ahead of that prefix on PATH, so the updated binary is -# what this shim execs — not something that shadows it. +# The background update check stays enabled. It does not install during a +# headless run. The agent-cli-update service runs `claude update` explicitly. +# That command rewrites /opt/blitz/npm in place. /usr/local/bin stays ahead of +# the prefix, so this shim execs the updated binary. if [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ -x /usr/local/bin/blitz-cred-claude ]; then token="$(/usr/local/bin/blitz-cred-claude 2>/dev/null || true)" diff --git a/packages/box/rootfs/usr/local/bin/codex b/packages/box/rootfs/usr/local/bin/codex index fe8b8ff1..a0c36b78 100755 --- a/packages/box/rootfs/usr/local/bin/codex +++ b/packages/box/rootfs/usr/local/bin/codex @@ -1,13 +1,12 @@ #!/bin/sh -# PATH shim for the Codex CLI. Every terminal `codex` comes through here, turns -# the startup update check ON, and execs the vendor binary. +# PATH shim for the Codex CLI. Every terminal `codex` comes through here, +# enables the startup update check, and execs the vendor binary. # -# WHY IT IS ON. Both vendor CLIs are meant to update themselves on the box: a -# build-time version is a floor, not a pin, and for claude the version is what -# decides which models the Lody composer can offer (docs/LODY-MODELS.md). This -# shim used to pass `false` to hold codex at the image's version; it no longer -# does, and the value is stated explicitly rather than left to the default so -# the intent is legible and a vendor default flip cannot silently reverse it. +# WHY IT IS ON. The check records whether a newer version exists. It never +# installs one in Lody's headless `codex app-server` process. The payload-owned +# agent-cli-update service runs the explicit `codex update` command that does +# install it. This shim states the check value so a vendor default change does +# not silently disable version discovery. # # WHY A CONFIG OVERRIDE AND NOT AN ENVIRONMENT VARIABLE: codex has no # environment variable for this. The setting is a config key, so `-c key=value` @@ -15,10 +14,9 @@ # `codex doctor --json` reports `"check for update on startup": "true"` by # default, and honours either value passed this way. # -# WHAT AN UPDATE DOES: `npm install -g @openai/codex` into NPM_CONFIG_PREFIX, -# which is /opt/blitz/npm — rewritten in place, and /usr/local/bin sits ahead of -# that prefix on PATH, so the updated binary is what this shim execs rather than -# something that shadows it. +# WHAT AN EXPLICIT UPDATE DOES: it rewrites the package in NPM_CONFIG_PREFIX, +# which is /opt/blitz/npm. /usr/local/bin stays ahead of that prefix on PATH, +# so this shim execs the updated binary. # # WHY THE FLAG GOES FIRST: codex accepts `-c` more than once and the last one # wins, so a caller that really wants a different value keeps the last word. diff --git a/packages/box/rootfs/usr/local/libexec/blitz-agent-cli-update b/packages/box/rootfs/usr/local/libexec/blitz-agent-cli-update new file mode 100755 index 00000000..26f7496a --- /dev/null +++ b/packages/box/rootfs/usr/local/libexec/blitz-agent-cli-update @@ -0,0 +1,117 @@ +#!/command/with-contenv bash +# Keep the vendor agent CLIs current without waiting for an interactive TUI. +# The npm prefix belongs to blitz. A root update would leave root-owned files +# there and block every later update, so root is refused before any file write. +set -uo pipefail + +current_uid=$(/usr/bin/id -u) +if [ "$current_uid" -eq 0 ]; then + printf '%s\n' 'blitz-agent-cli-update: refusing to run as root' >&2 + exit 77 +fi + +state_dir=${BLITZ_STATE_DIR:-/var/lib/blitz} +case "$state_dir" in + /*) ;; + *) state_dir=/var/lib/blitz ;; +esac +if [ "$state_dir" = / ]; then + state_dir=/var/lib/blitz +fi + +HOME="$state_dir/home" +USER=blitz +NPM_CONFIG_PREFIX=/opt/blitz/npm +export HOME USER NPM_CONFIG_PREFIX + +update_dir=${BLITZ_AGENT_CLI_UPDATE_DIR:-/opt/blitz/npm/.blitz-agent-cli-update} +# BLITZ_AGENT_CLI_UPDATE_DIR is a test-only seam. Production uses the literal +# path above, which stays writable by blitz after an npm update. +case "$update_dir" in + /*) ;; + *) update_dir=/opt/blitz/npm/.blitz-agent-cli-update ;; +esac +if [ "$update_dir" = / ]; then + update_dir=/opt/blitz/npm/.blitz-agent-cli-update +fi + +if ! mkdir -p "$update_dir" 2>/dev/null || ! chmod 0755 "$update_dir" 2>/dev/null; then + printf 'blitz-agent-cli-update: cannot prepare %s; skipping tick\n' "$update_dir" >&2 + exit 0 +fi + +log_file="$update_dir/log" +lock_file="$update_dir/lock" +if ! touch "$log_file" 2>/dev/null || ! chmod 0644 "$log_file" 2>/dev/null; then + printf 'blitz-agent-cli-update: cannot prepare %s; skipping tick\n' "$log_file" >&2 + exit 0 +fi + +rotate_log() { + local bytes + bytes=$(wc -c < "$log_file" 2>/dev/null || printf '0') + case "$bytes" in + '' | *[!0-9]*) bytes=0 ;; + esac + if [ "$bytes" -ge 1048576 ]; then + rm -f "$log_file.1" 2>/dev/null || true + if mv "$log_file" "$log_file.1" 2>/dev/null; then + chmod 0644 "$log_file.1" 2>/dev/null || true + : > "$log_file" + chmod 0644 "$log_file" 2>/dev/null || true + fi + fi +} + +log() { + local line="blitz-agent-cli-update: $*" + printf '%s\n' "$line" >&2 + rotate_log + printf '%s\n' "$line" >> "$log_file" 2>/dev/null || true + chmod 0644 "$log_file" 2>/dev/null || true +} + +if [ "${1:-}" != '__blitz-agent-cli-update-locked' ]; then + lock_wait=${BLITZ_AGENT_CLI_UPDATE_LOCK_WAIT:-300} + case "$lock_wait" in + '' | *[!0-9]* | 0) lock_wait=300 ;; + esac + if [ "${#lock_wait}" -gt 9 ] || [ "$lock_wait" -eq 0 ]; then + lock_wait=300 + fi + if ! command -v flock >/dev/null 2>&1; then + log 'flock is missing; skipping tick' + exit 0 + fi + flock -w "$lock_wait" "$lock_file" "$0" '__blitz-agent-cli-update-locked' + status=$? + if [ "$status" -eq 0 ]; then + exit 0 + fi + log "lock failed (exit $status); skipping tick" + exit 0 +fi +shift + +chmod 0644 "$lock_file" 2>/dev/null || true +rotate_log + +update_cli() { + local cli=$1 status + if ! command -v "$cli" >/dev/null 2>&1; then + log "$cli is missing; skipped" + return + fi + log "$cli update started" + if "$cli" update >> "$log_file" 2>&1; then + log "$cli update completed" + else + status=$? + log "$cli update failed (exit $status)" + fi +} + +update_cli codex +update_cli claude +log 'tick completed' +exit 0 diff --git a/packages/box/test/smoke.sh b/packages/box/test/smoke.sh index 0a8e5b79..7355a4c5 100755 --- a/packages/box/test/smoke.sh +++ b/packages/box/test/smoke.sh @@ -682,7 +682,7 @@ grep -q 'watch: waiting for broker config' "$test_dir/container.log" || fail "wa docker exec "$container" test ! -e /var/lib/blitz/broker.json || fail "no-CP mode created broker config" echo "PASS no-CP skips" -# Terminal delivery: the shim must WIN the PATH over the pinned binary it execs, +# Terminal delivery: the shim must WIN the PATH over the managed binary it execs, # in a plain login shell as well as in the image environment. A member-installed # copy landing in the writable npm prefix and shadowing it is the single most # common way a terminal ends up signed out while the box holds a credential. diff --git a/packages/box/test/syntax.sh b/packages/box/test/syntax.sh index 753bc1ed..e27387d5 100755 --- a/packages/box/test/syntax.sh +++ b/packages/box/test/syntax.sh @@ -5,8 +5,9 @@ script_dir=$(realpath "$(dirname "$0")") box_dir=$(realpath "$script_dir/..") # Every /usr/local/bin entry is POSIX sh. `claude` and `codex` are PATH shims: -# a syntax error in one does not degrade a tab, it removes the version pin the -# shim exists to hold. What each shim must CONTAIN is pinned in +# a syntax error in one does not degrade a tab, it removes the stable entry +# point that holds its token and update-check policy. Required shim content is +# pinned in # box/guest-tests/test/agent-shims.test.ts, which runs without docker. for shim in blitz claude codex; do sh -n "$box_dir/rootfs/usr/local/bin/$shim" diff --git a/packages/control-plane/scripts/lib/box-payload-files.mjs b/packages/control-plane/scripts/lib/box-payload-files.mjs index dae8ee42..d0c4ec48 100644 --- a/packages/control-plane/scripts/lib/box-payload-files.mjs +++ b/packages/control-plane/scripts/lib/box-payload-files.mjs @@ -16,6 +16,9 @@ export const PAYLOAD_ROOTFS_PATHS = Object.freeze([ "etc/blitz/sshd_config", "etc/gitconfig", "etc/profile.d/blitz-npm.sh", + "etc/s6-overlay/s6-rc.d/agent-cli-update/dependencies.d/init-state", + "etc/s6-overlay/s6-rc.d/agent-cli-update/run", + "etc/s6-overlay/s6-rc.d/agent-cli-update/type", "etc/s6-overlay/s6-rc.d/box-credential/dependencies.d/register", "etc/s6-overlay/s6-rc.d/box-credential/run", "etc/s6-overlay/s6-rc.d/box-credential/type", @@ -66,6 +69,7 @@ export const PAYLOAD_ROOTFS_PATHS = Object.freeze([ "etc/s6-overlay/s6-rc.d/ttyd/dependencies.d/register", "etc/s6-overlay/s6-rc.d/ttyd/run", "etc/s6-overlay/s6-rc.d/ttyd/type", + "etc/s6-overlay/s6-rc.d/user/contents.d/agent-cli-update", "etc/s6-overlay/s6-rc.d/user/contents.d/box-credential", "etc/s6-overlay/s6-rc.d/user/contents.d/cgroups", "etc/s6-overlay/s6-rc.d/user/contents.d/cloudflared", @@ -98,6 +102,7 @@ export const PAYLOAD_ROOTFS_PATHS = Object.freeze([ "usr/local/bin/blitz-rules", "usr/local/bin/claude", "usr/local/bin/codex", + "usr/local/libexec/blitz-agent-cli-update", "usr/local/libexec/blitz-codex-session", "usr/local/libexec/blitz-credential-refresh", "usr/local/libexec/blitz-git-credential", @@ -129,12 +134,13 @@ const NON_SERVICE_PAYLOAD_ROOTFS_PATHS = Object.freeze(PAYLOAD_ROOTFS_PATHS .filter((relativePath) => !relativePath.startsWith("etc/s6-overlay/s6-rc.d/"))); // Most executable dependencies can be read directly from the service source. -// These three are the exceptions worth spelling out. The gateway binary is -// generated rather than present under rootfs; the bridge override documents -// its service boundary explicitly; and blitz-term is resolved by ttyd for each -// new connection, so changing it must NOT bounce ttyd or existing terminals. +// These are the exceptions worth spelling out. The gateway binary is generated +// rather than present under rootfs. The bridge override documents its service +// boundary explicitly. blitz-term and the agent updater are resolved for each +// new invocation, so changing either must not bounce a longrun. export const PAYLOAD_SERVICE_OVERRIDES = Object.freeze({ "rootfs/usr/local/bin/blitz-box-gateway": Object.freeze(["gateway"]), + "rootfs/usr/local/libexec/blitz-agent-cli-update": Object.freeze([]), "rootfs/usr/local/libexec/blitz-lody-bridge": Object.freeze(["lody-bridge"]), "rootfs/usr/local/libexec/blitz-term": Object.freeze([]), }); diff --git a/packages/control-plane/test/box-payload-files.test.mjs b/packages/control-plane/test/box-payload-files.test.mjs index bf2e1088..2ba923ea 100644 --- a/packages/control-plane/test/box-payload-files.test.mjs +++ b/packages/control-plane/test/box-payload-files.test.mjs @@ -103,6 +103,7 @@ test("restart dependencies come from service sources plus the narrow override ta for (const service of Object.keys(restart)) { assert.ok(servicesWithPayloadScripts.includes(service), service); } + assert.equal(restart["agent-cli-update"], undefined); assert.ok(restart.gateway.includes("rootfs/usr/local/bin/blitz-box-gateway")); assert.ok(restart["lody-bridge"].includes("rootfs/usr/local/libexec/blitz-lody-bridge")); assert.ok(restart.sshd.includes("rootfs/etc/blitz/sshd_config")); diff --git a/packages/control-plane/test/publish-box-payload.test.mjs b/packages/control-plane/test/publish-box-payload.test.mjs index d11940c5..aa0a010b 100644 --- a/packages/control-plane/test/publish-box-payload.test.mjs +++ b/packages/control-plane/test/publish-box-payload.test.mjs @@ -196,6 +196,7 @@ test("stages a deterministic payload archive and a self-verifying manifest", asy assert.equal(statSync(extractedPath).mode & 0o777, 0o755, directory); assert.deepEqual(readdirSync(extractedPath), [], directory); } + assert.equal(manifest.restart["agent-cli-update"], undefined); assert.ok(manifest.restart.gateway.includes("rootfs/usr/local/bin/blitz-box-gateway")); assert.ok(manifest.restart.sshd.includes("rootfs/etc/blitz/sshd_config")); assert.equal(manifest.restart["machine-stats"], undefined); diff --git a/packages/schema/fixtures/box-payload/README.md b/packages/schema/fixtures/box-payload/README.md index bbdf3a58..a75619f7 100644 --- a/packages/schema/fixtures/box-payload/README.md +++ b/packages/schema/fixtures/box-payload/README.md @@ -28,6 +28,8 @@ Restart keys name longruns in the manifest's service tree. Each key requires matching `run` and `type` files. The updater verifies that each extracted type reads `longrun`. Protocol 2 has no fixed restart vocabulary. +The full manifest carries the payload-owned `agent-cli-update` longrun. +It pins the service tree, bundle entry, and updater executable together. The service tree lives under `rootfs/etc/s6-overlay/s6-rc.d/`. Protocol 2 manifests require updater protocol 2. diff --git a/packages/schema/fixtures/box-payload/valid/full-manifest.json b/packages/schema/fixtures/box-payload/valid/full-manifest.json index 010489ab..4bbc3587 100644 --- a/packages/schema/fixtures/box-payload/valid/full-manifest.json +++ b/packages/schema/fixtures/box-payload/valid/full-manifest.json @@ -3,6 +3,31 @@ "createdAt": 1788550000000, "minUpdater": 2, "files": [ + { + "path": "rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/dependencies.d/init-state", + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "mode": "0644" + }, + { + "path": "rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/run", + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "mode": "0755" + }, + { + "path": "rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/type", + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "mode": "0644" + }, + { + "path": "rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/agent-cli-update", + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "mode": "0644" + }, + { + "path": "rootfs/usr/local/libexec/blitz-agent-cli-update", + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "mode": "0755" + }, { "path": "rootfs/usr/local/bin/blitz-box-gateway", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", From 1b9383a15840c835fdc7185037b84ce8de25351b Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sat, 5 Sep 2026 19:30:40 -0700 Subject: [PATCH 2/3] box: detect the new CLI version, then update, every five minutes 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) Claude-Session: https://claude.ai/code/session_01QskaoYxATWsGKtWWVLHUWh --- docs/LODY-MODELS.md | 12 +- .../guest-tests/test/agent-cli-update.test.ts | 120 ++++++++++++++++-- .../s6-overlay/s6-rc.d/agent-cli-update/run | 11 +- .../usr/local/libexec/blitz-agent-cli-update | 119 ++++++++++++++--- 4 files changed, 230 insertions(+), 32 deletions(-) diff --git a/docs/LODY-MODELS.md b/docs/LODY-MODELS.md index 9fe8cd19..20bb7aba 100644 --- a/docs/LODY-MODELS.md +++ b/docs/LODY-MODELS.md @@ -149,11 +149,17 @@ updated Codex from 0.147.0 to 0.153.4 and Claude Code from 2.1.228 to 2.1.261. ## 5. Path forward **Done 2026-09-05: a payload-owned periodic updater.** The -`agent-cli-update` longrun waits briefly after boot and then runs explicit -`codex update` and `claude update` commands every six hours. It runs as blitz, -uses the blitz-owned npm prefix, and logs failures without stopping its loop. +`agent-cli-update` longrun waits briefly after boot and checks npm every five +minutes, matching the box payload poll. It runs an explicit `codex update` or +`claude update` only when that CLI's published version differs from its +installed version. It runs as blitz, uses the blitz-owned npm prefix, and logs +failures without stopping its loop. Unchanged states are logged at most hourly. Both packages also use `@latest` when a fresh image is built. +The updater does not defer for active sessions. Measured in place, a running +`codex app-server` kept serving after its package changed. New sessions started +with the new binary. + The shadow-copy fear the old comments cited is handled independently: `rootfs/etc/profile.d/blitz-npm.sh` force-moves `/usr/local/bin` to the FRONT of PATH on every login shell, ahead of `/opt/blitz/npm/bin` (verified: a box login diff --git a/packages/box/guest-tests/test/agent-cli-update.test.ts b/packages/box/guest-tests/test/agent-cli-update.test.ts index 49a5214a..5fb7c36b 100644 --- a/packages/box/guest-tests/test/agent-cli-update.test.ts +++ b/packages/box/guest-tests/test/agent-cli-update.test.ts @@ -77,6 +77,16 @@ class Harness { [ "#!/bin/sh", "printf 'npm|%s|%s|%s|%s\\n' \"$*\" \"$HOME\" \"$USER\" \"$NPM_CONFIG_PREFIX\" >>\"$BLITZ_TEST_CALLS\"", + "case \"$*\" in", + " 'view @openai/codex version')", + " if [ \"${BLITZ_TEST_FAIL_NPM_CODEX:-0}\" = 1 ]; then exit 29; fi", + " printf '%s\\n' \"${BLITZ_TEST_PUBLISHED_CODEX:-0.153.4}\"", + " ;;", + " 'view @anthropic-ai/claude-code version')", + " if [ \"${BLITZ_TEST_FAIL_NPM_CLAUDE:-0}\" = 1 ]; then exit 29; fi", + " printf '%s\\n' \"${BLITZ_TEST_PUBLISHED_CLAUDE:-2.1.261}\"", + " ;;", + "esac", "exit 0", "", ].join("\n"), @@ -93,8 +103,19 @@ class Harness { ` touch "$BLITZ_TEST_${upper}_READY"`, ` while [ ! -e "$BLITZ_TEST_${upper}_RELEASE" ]; do sleep 0.02; done`, "fi", - `if [ "\${BLITZ_TEST_FAIL_${upper}:-0}" = 1 ]; then exit 23; fi`, - `npm ${cli} "$@"`, + "if [ \"$1\" = --version ]; then", + ` if [ "\${BLITZ_TEST_FAIL_${upper}_VERSION:-0}" = 1 ]; then exit 23; fi`, + cli === "codex" + ? " printf '%s\\n' \"${BLITZ_TEST_CODEX_VERSION_OUTPUT:-codex-cli 0.153.4}\"" + : " printf '%s\\n' \"${BLITZ_TEST_CLAUDE_VERSION_OUTPUT:-2.1.261 (Claude Code)}\"", + " exit 0", + "fi", + "if [ \"$1\" = update ]; then", + ` if [ "\${BLITZ_TEST_FAIL_${upper}:-0}" = 1 ]; then exit 23; fi`, + ` npm ${cli} "$@"`, + " exit $?", + "fi", + "exit 64", "", ].join("\n"), ); @@ -183,6 +204,8 @@ describe("agent CLI updater", () => { expect(rootGuard).toBeGreaterThan(0); expect(source.indexOf("exit 77", rootGuard)).toBeLessThan(source.indexOf("mkdir -p")); expect(source).toContain("current_uid=$(/usr/bin/id -u)"); + expect(source).toContain('default_update_dir="$state_dir/agent-cli-update"'); + expect(source).not.toContain("/opt/blitz/npm/.blitz-agent-cli-update"); }); it("is a registered longrun that drops to blitz and sleeps after every tick", () => { @@ -200,7 +223,7 @@ describe("agent CLI updater", () => { .toBe(true); expect(statSync(path.join(serviceRoot, "user/contents.d/agent-cli-update")).isFile()) .toBe(true); - expect(code).toContain("BLITZ_AGENT_CLI_UPDATE_INTERVAL:-21600"); + expect(code).toContain("BLITZ_AGENT_CLI_UPDATE_INTERVAL:-300"); expect(code).toContain("BLITZ_AGENT_CLI_UPDATE_BOOT_DELAY:-30"); expect(code).toContain("s6-setuidgid blitz"); expect(code).toContain("HOME=\"$state_dir/home\" USER=blitz NPM_CONFIG_PREFIX=/opt/blitz/npm"); @@ -210,38 +233,73 @@ describe("agent CLI updater", () => { ); }); - it("runs both explicit update commands with the owned npm environment", () => { + it("does not update when installed and published versions match", () => { const harness = new Harness(); const result = runUpdater(harness); expect(result.status, result.stderr).toBe(0); expect(harness.callLines()).toEqual([ + `codex|--version|${harness.stateDir}/home|blitz|/opt/blitz/npm`, + `npm|view @openai/codex version|${harness.stateDir}/home|blitz|/opt/blitz/npm`, + `claude|--version|${harness.stateDir}/home|blitz|/opt/blitz/npm`, + `npm|view @anthropic-ai/claude-code version|${harness.stateDir}/home|blitz|/opt/blitz/npm`, + ]); + expect(harness.callLines().some((line) => line.includes("|update|"))).toBe(false); + const log = readFileSync(path.join(harness.updateDir, "log"), "utf8"); + expect(log).toContain("codex is up to date at 0.153.4"); + expect(log).toContain("claude is up to date at 2.1.261"); + expect(statSync(harness.updateDir).mode & 0o777).toBe(0o755); + expect(statSync(path.join(harness.updateDir, "log")).mode & 0o777).toBe(0o644); + }); + + it("parses both real formats and updates changed versions", () => { + const harness = new Harness(); + const result = runUpdater(harness, { + BLITZ_TEST_CODEX_VERSION_OUTPUT: "codex-cli 0.153.4", + BLITZ_TEST_CLAUDE_VERSION_OUTPUT: "2.1.261 (Claude Code)", + BLITZ_TEST_PUBLISHED_CODEX: "0.154.0", + BLITZ_TEST_PUBLISHED_CLAUDE: "2.1.262", + }); + + expect(result.status, result.stderr).toBe(0); + expect(harness.callLines()).toEqual([ + `codex|--version|${harness.stateDir}/home|blitz|/opt/blitz/npm`, + `npm|view @openai/codex version|${harness.stateDir}/home|blitz|/opt/blitz/npm`, `codex|update|${harness.stateDir}/home|blitz|/opt/blitz/npm`, `npm|codex update|${harness.stateDir}/home|blitz|/opt/blitz/npm`, + `claude|--version|${harness.stateDir}/home|blitz|/opt/blitz/npm`, + `npm|view @anthropic-ai/claude-code version|${harness.stateDir}/home|blitz|/opt/blitz/npm`, `claude|update|${harness.stateDir}/home|blitz|/opt/blitz/npm`, `npm|claude update|${harness.stateDir}/home|blitz|/opt/blitz/npm`, ]); - expect(statSync(harness.updateDir).mode & 0o777).toBe(0o755); - expect(statSync(path.join(harness.updateDir, "log")).mode & 0o777).toBe(0o644); + const log = readFileSync(path.join(harness.updateDir, "log"), "utf8"); + expect(log).toContain("codex update completed: 0.153.4 -> 0.154.0"); + expect(log).toContain("claude update completed: 2.1.261 -> 2.1.262"); }); it("runs claude after codex fails", () => { const harness = new Harness(); - const result = runUpdater(harness, { BLITZ_TEST_FAIL_CODEX: "1" }); + const result = runUpdater(harness, { + BLITZ_TEST_FAIL_CODEX: "1", + BLITZ_TEST_PUBLISHED_CODEX: "0.154.0", + BLITZ_TEST_PUBLISHED_CLAUDE: "2.1.262", + }); expect(result.status, result.stderr).toBe(0); expect(harness.callLines().filter((line) => !line.startsWith("npm|"))) .toEqual([ + `codex|--version|${harness.stateDir}/home|blitz|/opt/blitz/npm`, `codex|update|${harness.stateDir}/home|blitz|/opt/blitz/npm`, + `claude|--version|${harness.stateDir}/home|blitz|/opt/blitz/npm`, `claude|update|${harness.stateDir}/home|blitz|/opt/blitz/npm`, ]); expect(readFileSync(path.join(harness.updateDir, "log"), "utf8")) - .toContain("codex update failed (exit 23)"); + .toContain("codex update failed: 0.153.4 -> 0.154.0 (exit 23)"); }); it("survives a missing CLI and still runs the other one", () => { const harness = new Harness({ missing: "codex" }); - const result = runUpdater(harness); + const result = runUpdater(harness, { BLITZ_TEST_PUBLISHED_CLAUDE: "2.1.262" }); expect(result.status, result.stderr).toBe(0); expect(harness.callLines().some((line) => line.startsWith("claude|update|"))).toBe(true); @@ -249,6 +307,50 @@ describe("agent CLI updater", () => { .toContain("codex is missing; skipped"); }); + it("skips a failed npm view and still exits zero", () => { + const harness = new Harness(); + const result = runUpdater(harness, { + BLITZ_TEST_FAIL_NPM_CODEX: "1", + BLITZ_TEST_PUBLISHED_CLAUDE: "2.1.262", + }); + + expect(result.status, result.stderr).toBe(0); + expect(harness.callLines().some((line) => line.startsWith("codex|update|"))).toBe(false); + expect(harness.callLines().some((line) => line.startsWith("claude|update|"))).toBe(true); + expect(readFileSync(path.join(harness.updateDir, "log"), "utf8")) + .toContain("codex published version probe failed for @openai/codex (exit 29); skipped"); + }); + + it("skips an unparseable version and still checks the other CLI", () => { + const harness = new Harness(); + const result = runUpdater(harness, { + BLITZ_TEST_CODEX_VERSION_OUTPUT: "codex 0.153.4", + BLITZ_TEST_PUBLISHED_CLAUDE: "2.1.262", + }); + + expect(result.status, result.stderr).toBe(0); + expect(harness.callLines().some((line) => line.startsWith("codex|update|"))).toBe(false); + expect(harness.callLines().some((line) => line.startsWith("claude|update|"))).toBe(true); + expect(readFileSync(path.join(harness.updateDir, "log"), "utf8")) + .toContain("codex installed version parse failed; skipped"); + }); + + it("rate-limits unchanged logs to once per hour", () => { + const harness = new Harness(); + const first = runUpdater(harness); + const second = runUpdater(harness); + + expect(first.status, first.stderr).toBe(0); + expect(second.status, second.stderr).toBe(0); + const unchanged = readFileSync(path.join(harness.updateDir, "log"), "utf8") + .split("\n") + .filter((line) => line.includes("is up to date at")); + expect(unchanged).toEqual([ + "blitz-agent-cli-update: codex is up to date at 0.153.4", + "blitz-agent-cli-update: claude is up to date at 2.1.261", + ]); + }); + it("serializes concurrent ticks with one flock", async () => { const harness = new Harness(); const ready = path.join(harness.root, "codex-ready"); diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/run b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/run index 54a4d228..0d122fad 100755 --- a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/run +++ b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/run @@ -1,10 +1,11 @@ #!/command/with-contenv bash -# Run both vendor update commands as blitz. The initial delay lets boot traffic -# settle. Every outcome reaches the sleep, so a network failure cannot spin s6. +# Check both vendor CLIs as blitz. Update only when npm reports a change. +# Poll every five minutes, matching the box payload updater. +# Every outcome reaches the sleep, so a network failure cannot spin s6. set -uo pipefail state_dir=${BLITZ_STATE_DIR:-/var/lib/blitz} -interval=${BLITZ_AGENT_CLI_UPDATE_INTERVAL:-21600} +interval=${BLITZ_AGENT_CLI_UPDATE_INTERVAL:-300} boot_delay=${BLITZ_AGENT_CLI_UPDATE_BOOT_DELAY:-30} valid_seconds() { @@ -16,8 +17,8 @@ valid_seconds() { } if ! valid_seconds "$interval" 1; then - echo 'agent-cli-update: invalid interval; using 21600 seconds' - interval=21600 + echo 'agent-cli-update: invalid interval; using 300 seconds' + interval=300 fi if ! valid_seconds "$boot_delay" 0; then echo 'agent-cli-update: invalid boot delay; using 30 seconds' diff --git a/packages/box/rootfs/usr/local/libexec/blitz-agent-cli-update b/packages/box/rootfs/usr/local/libexec/blitz-agent-cli-update index 26f7496a..997be2f5 100755 --- a/packages/box/rootfs/usr/local/libexec/blitz-agent-cli-update +++ b/packages/box/rootfs/usr/local/libexec/blitz-agent-cli-update @@ -1,7 +1,7 @@ #!/command/with-contenv bash -# Keep the vendor agent CLIs current without waiting for an interactive TUI. -# The npm prefix belongs to blitz. A root update would leave root-owned files -# there and block every later update, so root is refused before any file write. +# Keep vendor agent CLIs current without waiting for an interactive TUI. +# The npm prefix belongs to blitz. Root updates leave files that block later updates. +# Refuse root before any file write. set -uo pipefail current_uid=$(/usr/bin/id -u) @@ -24,15 +24,16 @@ USER=blitz NPM_CONFIG_PREFIX=/opt/blitz/npm export HOME USER NPM_CONFIG_PREFIX -update_dir=${BLITZ_AGENT_CLI_UPDATE_DIR:-/opt/blitz/npm/.blitz-agent-cli-update} -# BLITZ_AGENT_CLI_UPDATE_DIR is a test-only seam. Production uses the literal -# path above, which stays writable by blitz after an npm update. +default_update_dir="$state_dir/agent-cli-update" +update_dir=${BLITZ_AGENT_CLI_UPDATE_DIR:-$default_update_dir} +# BLITZ_AGENT_CLI_UPDATE_DIR is a test-only seam. +# State stays outside the npm prefix. Matching versions do not write there. case "$update_dir" in /*) ;; - *) update_dir=/opt/blitz/npm/.blitz-agent-cli-update ;; + *) update_dir=$default_update_dir ;; esac if [ "$update_dir" = / ]; then - update_dir=/opt/blitz/npm/.blitz-agent-cli-update + update_dir=$default_update_dir fi if ! mkdir -p "$update_dir" 2>/dev/null || ! chmod 0755 "$update_dir" 2>/dev/null; then @@ -96,22 +97,110 @@ shift chmod 0644 "$lock_file" 2>/dev/null || true rotate_log +version_pattern='[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?' +quiet_log_interval_seconds=3600 + +parse_installed_version() { + local cli=$1 output=$2 + case "$cli" in + codex) + if [[ $output =~ ^codex-cli[[:space:]]+($version_pattern)$ ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + return 0 + fi + ;; + claude) + if [[ $output =~ ^($version_pattern)[[:space:]]+\(Claude[[:space:]]Code\)$ ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + return 0 + fi + ;; + esac + return 1 +} + +parse_published_version() { + local output=$1 + if [[ $output =~ ^($version_pattern)$ ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + return 0 + fi + return 1 +} + +valid_epoch() { + local value=$1 + case "$value" in + '' | *[!0-9]*) return 1 ;; + esac + [ "${#value}" -le 10 ] +} + +log_unchanged() { + local cli=$1 version=$2 quiet_file now quiet_version quiet_at elapsed + quiet_file="$update_dir/quiet-$cli" + now=$(date +%s 2>/dev/null) + if ! valid_epoch "$now"; then + log "$cli is up to date at $version; clock probe failed" + return + fi + quiet_version= + quiet_at= + read -r quiet_version quiet_at < "$quiet_file" 2>/dev/null || true + if [ "$quiet_version" = "$version" ] && valid_epoch "$quiet_at"; then + elapsed=$((now - quiet_at)) + if [ "$elapsed" -lt "$quiet_log_interval_seconds" ]; then + return + fi + fi + log "$cli is up to date at $version" + if printf '%s %s\n' "$version" "$now" > "$quiet_file" 2>/dev/null; then + chmod 0644 "$quiet_file" 2>/dev/null || true + else + log "$cli quiet log state failed" + fi +} + update_cli() { - local cli=$1 status + local cli=$1 package=$2 status installed_output installed_version + local published_output published_version if ! command -v "$cli" >/dev/null 2>&1; then log "$cli is missing; skipped" return fi - log "$cli update started" + installed_output=$("$cli" --version 2>/dev/null) + status=$? + if [ "$status" -ne 0 ]; then + log "$cli installed version probe failed (exit $status); skipped" + return + fi + if ! installed_version=$(parse_installed_version "$cli" "$installed_output"); then + log "$cli installed version parse failed; skipped" + return + fi + published_output=$(npm view "$package" version 2>/dev/null) + status=$? + if [ "$status" -ne 0 ]; then + log "$cli published version probe failed for $package (exit $status); skipped" + return + fi + if ! published_version=$(parse_published_version "$published_output"); then + log "$cli published version parse failed for $package; skipped" + return + fi + if [ "$installed_version" = "$published_version" ]; then + log_unchanged "$cli" "$installed_version" + return + fi + log "$cli update started: $installed_version -> $published_version" if "$cli" update >> "$log_file" 2>&1; then - log "$cli update completed" + log "$cli update completed: $installed_version -> $published_version" else status=$? - log "$cli update failed (exit $status)" + log "$cli update failed: $installed_version -> $published_version (exit $status)" fi } -update_cli codex -update_cli claude -log 'tick completed' +update_cli codex '@openai/codex' +update_cli claude '@anthropic-ai/claude-code' exit 0 From 12716a68183dc464e432a98f750a3994c7ef7606 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sat, 5 Sep 2026 22:27:20 -0700 Subject: [PATCH 3/3] box: delete 79 lines the updater did not need 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) Claude-Session: https://claude.ai/code/session_01QskaoYxATWsGKtWWVLHUWh --- .../guest-tests/test/agent-cli-update.test.ts | 33 +++----- .../usr/local/libexec/blitz-agent-cli-update | 83 +------------------ 2 files changed, 15 insertions(+), 101 deletions(-) diff --git a/packages/box/guest-tests/test/agent-cli-update.test.ts b/packages/box/guest-tests/test/agent-cli-update.test.ts index 5fb7c36b..1822fb61 100644 --- a/packages/box/guest-tests/test/agent-cli-update.test.ts +++ b/packages/box/guest-tests/test/agent-cli-update.test.ts @@ -41,7 +41,7 @@ class Harness { readonly bin = path.join(this.root, "bin"); readonly calls = path.join(this.root, "calls"); readonly stateDir = path.join(this.root, "state"); - readonly updateDir = path.join(this.root, "update"); + readonly updateDir = path.join(this.stateDir, "agent-cli-update"); constructor(options: { missing?: "codex" | "claude" } = {}) { temporaryDirectories.push(this.root); @@ -127,8 +127,6 @@ class Harness { ...process.env, PATH: `${this.bin}:/usr/bin:/bin`, BLITZ_STATE_DIR: this.stateDir, - BLITZ_AGENT_CLI_UPDATE_DIR: this.updateDir, - BLITZ_AGENT_CLI_UPDATE_LOCK_WAIT: "5", BLITZ_TEST_CALLS: this.calls, ...extra, }; @@ -204,7 +202,7 @@ describe("agent CLI updater", () => { expect(rootGuard).toBeGreaterThan(0); expect(source.indexOf("exit 77", rootGuard)).toBeLessThan(source.indexOf("mkdir -p")); expect(source).toContain("current_uid=$(/usr/bin/id -u)"); - expect(source).toContain('default_update_dir="$state_dir/agent-cli-update"'); + // State belongs under the state dir, never in the npm prefix an update rewrites. expect(source).not.toContain("/opt/blitz/npm/.blitz-agent-cli-update"); }); @@ -245,9 +243,7 @@ describe("agent CLI updater", () => { `npm|view @anthropic-ai/claude-code version|${harness.stateDir}/home|blitz|/opt/blitz/npm`, ]); expect(harness.callLines().some((line) => line.includes("|update|"))).toBe(false); - const log = readFileSync(path.join(harness.updateDir, "log"), "utf8"); - expect(log).toContain("codex is up to date at 0.153.4"); - expect(log).toContain("claude is up to date at 2.1.261"); + expect(readFileSync(path.join(harness.updateDir, "log"), "utf8")).toBe(""); expect(statSync(harness.updateDir).mode & 0o777).toBe(0o755); expect(statSync(path.join(harness.updateDir, "log")).mode & 0o777).toBe(0o644); }); @@ -335,20 +331,17 @@ describe("agent CLI updater", () => { .toContain("codex installed version parse failed; skipped"); }); - it("rate-limits unchanged logs to once per hour", () => { + it("does not invoke CLI updates or append logs for an up-to-date tick", () => { const harness = new Harness(); - const first = runUpdater(harness); - const second = runUpdater(harness); - - expect(first.status, first.stderr).toBe(0); - expect(second.status, second.stderr).toBe(0); - const unchanged = readFileSync(path.join(harness.updateDir, "log"), "utf8") - .split("\n") - .filter((line) => line.includes("is up to date at")); - expect(unchanged).toEqual([ - "blitz-agent-cli-update: codex is up to date at 0.153.4", - "blitz-agent-cli-update: claude is up to date at 2.1.261", - ]); + mkdirSync(harness.updateDir, { recursive: true }); + const logPath = path.join(harness.updateDir, "log"); + const initialLog = "previous update\n"; + writeFileSync(logPath, initialLog); + const result = runUpdater(harness); + + expect(result.status, result.stderr).toBe(0); + expect(harness.callLines().some((line) => line.includes("|update|"))).toBe(false); + expect(readFileSync(logPath, "utf8")).toBe(initialLog); }); it("serializes concurrent ticks with one flock", async () => { diff --git a/packages/box/rootfs/usr/local/libexec/blitz-agent-cli-update b/packages/box/rootfs/usr/local/libexec/blitz-agent-cli-update index 997be2f5..ae45767f 100755 --- a/packages/box/rootfs/usr/local/libexec/blitz-agent-cli-update +++ b/packages/box/rootfs/usr/local/libexec/blitz-agent-cli-update @@ -11,30 +11,13 @@ if [ "$current_uid" -eq 0 ]; then fi state_dir=${BLITZ_STATE_DIR:-/var/lib/blitz} -case "$state_dir" in - /*) ;; - *) state_dir=/var/lib/blitz ;; -esac -if [ "$state_dir" = / ]; then - state_dir=/var/lib/blitz -fi HOME="$state_dir/home" USER=blitz NPM_CONFIG_PREFIX=/opt/blitz/npm export HOME USER NPM_CONFIG_PREFIX -default_update_dir="$state_dir/agent-cli-update" -update_dir=${BLITZ_AGENT_CLI_UPDATE_DIR:-$default_update_dir} -# BLITZ_AGENT_CLI_UPDATE_DIR is a test-only seam. -# State stays outside the npm prefix. Matching versions do not write there. -case "$update_dir" in - /*) ;; - *) update_dir=$default_update_dir ;; -esac -if [ "$update_dir" = / ]; then - update_dir=$default_update_dir -fi +update_dir="$state_dir/agent-cli-update" if ! mkdir -p "$update_dir" 2>/dev/null || ! chmod 0755 "$update_dir" 2>/dev/null; then printf 'blitz-agent-cli-update: cannot prepare %s; skipping tick\n' "$update_dir" >&2 @@ -47,44 +30,18 @@ if ! touch "$log_file" 2>/dev/null || ! chmod 0644 "$log_file" 2>/dev/null; then printf 'blitz-agent-cli-update: cannot prepare %s; skipping tick\n' "$log_file" >&2 exit 0 fi - -rotate_log() { - local bytes - bytes=$(wc -c < "$log_file" 2>/dev/null || printf '0') - case "$bytes" in - '' | *[!0-9]*) bytes=0 ;; - esac - if [ "$bytes" -ge 1048576 ]; then - rm -f "$log_file.1" 2>/dev/null || true - if mv "$log_file" "$log_file.1" 2>/dev/null; then - chmod 0644 "$log_file.1" 2>/dev/null || true - : > "$log_file" - chmod 0644 "$log_file" 2>/dev/null || true - fi - fi -} - log() { local line="blitz-agent-cli-update: $*" printf '%s\n' "$line" >&2 - rotate_log printf '%s\n' "$line" >> "$log_file" 2>/dev/null || true - chmod 0644 "$log_file" 2>/dev/null || true } if [ "${1:-}" != '__blitz-agent-cli-update-locked' ]; then - lock_wait=${BLITZ_AGENT_CLI_UPDATE_LOCK_WAIT:-300} - case "$lock_wait" in - '' | *[!0-9]* | 0) lock_wait=300 ;; - esac - if [ "${#lock_wait}" -gt 9 ] || [ "$lock_wait" -eq 0 ]; then - lock_wait=300 - fi if ! command -v flock >/dev/null 2>&1; then log 'flock is missing; skipping tick' exit 0 fi - flock -w "$lock_wait" "$lock_file" "$0" '__blitz-agent-cli-update-locked' + flock -w 300 "$lock_file" "$0" '__blitz-agent-cli-update-locked' status=$? if [ "$status" -eq 0 ]; then exit 0 @@ -95,10 +52,8 @@ fi shift chmod 0644 "$lock_file" 2>/dev/null || true -rotate_log version_pattern='[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?' -quiet_log_interval_seconds=3600 parse_installed_version() { local cli=$1 output=$2 @@ -128,39 +83,6 @@ parse_published_version() { return 1 } -valid_epoch() { - local value=$1 - case "$value" in - '' | *[!0-9]*) return 1 ;; - esac - [ "${#value}" -le 10 ] -} - -log_unchanged() { - local cli=$1 version=$2 quiet_file now quiet_version quiet_at elapsed - quiet_file="$update_dir/quiet-$cli" - now=$(date +%s 2>/dev/null) - if ! valid_epoch "$now"; then - log "$cli is up to date at $version; clock probe failed" - return - fi - quiet_version= - quiet_at= - read -r quiet_version quiet_at < "$quiet_file" 2>/dev/null || true - if [ "$quiet_version" = "$version" ] && valid_epoch "$quiet_at"; then - elapsed=$((now - quiet_at)) - if [ "$elapsed" -lt "$quiet_log_interval_seconds" ]; then - return - fi - fi - log "$cli is up to date at $version" - if printf '%s %s\n' "$version" "$now" > "$quiet_file" 2>/dev/null; then - chmod 0644 "$quiet_file" 2>/dev/null || true - else - log "$cli quiet log state failed" - fi -} - update_cli() { local cli=$1 package=$2 status installed_output installed_version local published_output published_version @@ -189,7 +111,6 @@ update_cli() { return fi if [ "$installed_version" = "$published_version" ]; then - log_unchanged "$cli" "$installed_version" return fi log "$cli update started: $installed_version -> $published_version"