diff --git a/docs/LODY-MODELS.md b/docs/LODY-MODELS.md index fc7957ff..ad4a0c20 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. Three box places were the image-wide `ENV` in @@ -132,45 +132,51 @@ and `rootfs/etc/profile.d/blitz-npm.sh`. These sites cover s6 daemons, login shells, and direct commands. The now-retired broker set the fourth site in its spawn environment. -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 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 shell gets `/usr/local/bin:/opt/blitz/npm/bin:…`). So a second copy in the npm prefix cannot shadow the shim, and the native installer's `~/.local/bin` / -`~/.claude/local` are not on the box PATH at all. **Rewrite those comments when -the flag goes** — they are the justification the next agent will read, and they -will be wrong. +`~/.claude/local` are not on the box PATH at all. The Dockerfile, profile, and +shims now describe that PATH and update-service split directly. 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 bcd41a82..d0fa8502 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 @@ -198,14 +197,11 @@ RUN set -eux; \ # The Codex shim selects native login paths. # The Claude shim starts the native CLI without changing authentication. # -# 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, and the profile. 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 04358f65..d44f572b 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). - The box-owned `blitz-cred api-token` helper refreshes machine credentials. It carries no agent or control-plane API schema. @@ -33,9 +34,11 @@ reserved for boxes already in the field. Successor plan: - Supervision: pinned s6-overlay. Service graph: cgroups → init-state → sshd · ttyd · dufs · HTTP gateway · Docker · agent services. The credential refresher and rules sync also start after init-state. + The `agent-cli-update` longrun also starts after init-state. The box runs alone with native HOME credentials. The control plane remains an optional 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..1822fb61 --- /dev/null +++ b/packages/box/guest-tests/test/agent-cli-update.test.ts @@ -0,0 +1,381 @@ +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.stateDir, "agent-cli-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\"", + "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"), + ); + 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 [ \"$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"), + ); + } + } + + environment(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + ...process.env, + PATH: `${this.bin}:/usr/bin:/bin`, + BLITZ_STATE_DIR: this.stateDir, + 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)"); + // 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"); + }); + + 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:-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"); + 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("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); + 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); + }); + + 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`, + ]); + 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", + 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: 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, { 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); + expect(readFileSync(path.join(harness.updateDir, "log"), "utf8")) + .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("does not invoke CLI updates or append logs for an up-to-date tick", () => { + const harness = new Harness(); + 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 () => { + 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 0b87fba0..e1a6a02f 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 @@ -44,7 +43,7 @@ const shimCode = (name: string) => .join("\n"); 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} `); @@ -56,7 +55,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 8d45ca3d..7e4004ed 100644 --- a/packages/box/rootfs/etc/profile.d/blitz-npm.sh +++ b/packages/box/rootfs/etc/profile.d/blitz-npm.sh @@ -8,19 +8,17 @@ esac # ...and then put /usr/local/bin back in FRONT of it. The order is # load-bearing, not cosmetic: /usr/local/bin/codex is the shim that turns the -# startup update check on before it execs the pinned binary, and -# /opt/blitz/npm/bin/codex is that pinned binary. Leaving the npm prefix first -# means every terminal `codex` skips the shim and stops updating itself, which -# is exactly what a stray `PATH=/opt/blitz/npm/bin:$PATH` did before this block -# existed. +# startup update check on before it execs the managed binary, and +# /opt/blitz/npm/bin/codex is that vendor binary. Leaving the npm prefix first +# means every terminal `codex` skips the shim and its explicit startup check, +# which is exactly what a stray `PATH=/opt/blitz/npm/bin:$PATH` did before this +# block existed. case ":$PATH:" in *:/usr/local/bin:*) PATH="/usr/local/bin:$(printf '%s' "$PATH" | sed -e 's#^/usr/local/bin:##' -e 's#:/usr/local/bin:#:#g' -e 's#:/usr/local/bin$##')" ;; *) 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..0d122fad --- /dev/null +++ b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/agent-cli-update/run @@ -0,0 +1,34 @@ +#!/command/with-contenv bash +# 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:-300} +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 300 seconds' + interval=300 +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 fa402bdd..982282ee 100755 --- a/packages/box/rootfs/usr/local/bin/claude +++ b/packages/box/rootfs/usr/local/bin/claude @@ -7,9 +7,10 @@ # it untouched. # # IT STILL EXISTS BECAUSE CALLERS NAME IT. The Lody adapter points at this exact -# path (`packages/webapp/src/lody/agent-configs.ts`), and the auto-updater +# path (`packages/webapp/src/lody/agent-configs.ts`), and the periodic updater # rewrites the npm prefix in place, so a stable name is what those callers pin. # -# The auto-updater stays on. New CLI versions bring new models with no rebake. +# The background update check stays enabled. The agent-cli-update service runs +# `claude update` explicitly because headless checks do not install updates. exec /opt/blitz/npm/bin/claude "$@" 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..ae45767f --- /dev/null +++ b/packages/box/rootfs/usr/local/libexec/blitz-agent-cli-update @@ -0,0 +1,127 @@ +#!/command/with-contenv bash +# 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) +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} + +HOME="$state_dir/home" +USER=blitz +NPM_CONFIG_PREFIX=/opt/blitz/npm +export HOME USER NPM_CONFIG_PREFIX + +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 + 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 +log() { + local line="blitz-agent-cli-update: $*" + printf '%s\n' "$line" >&2 + printf '%s\n' "$line" >> "$log_file" 2>/dev/null || true +} + +if [ "${1:-}" != '__blitz-agent-cli-update-locked' ]; then + if ! command -v flock >/dev/null 2>&1; then + log 'flock is missing; skipping tick' + exit 0 + fi + flock -w 300 "$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 + +version_pattern='[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?' + +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 +} + +update_cli() { + 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 + 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 + return + fi + log "$cli update started: $installed_version -> $published_version" + if "$cli" update >> "$log_file" 2>&1; then + log "$cli update completed: $installed_version -> $published_version" + else + status=$? + log "$cli update failed: $installed_version -> $published_version (exit $status)" + fi +} + +update_cli codex '@openai/codex' +update_cli claude '@anthropic-ai/claude-code' +exit 0 diff --git a/packages/box/test/smoke.sh b/packages/box/test/smoke.sh index 75b6ca8f..5ef74d17 100755 --- a/packages/box/test/smoke.sh +++ b/packages/box/test/smoke.sh @@ -686,7 +686,7 @@ grep -q 'blitz-credential-refresh: skipped (no control-plane origin)' "$test_dir || fail "credential refresh did not skip cleanly" echo "PASS no-CP credential refresh skip" -# 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 1d1725c2..726996f0 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/init-state", "etc/s6-overlay/s6-rc.d/box-credential/run", "etc/s6-overlay/s6-rc.d/box-credential/type", @@ -63,6 +66,7 @@ export const PAYLOAD_ROOTFS_PATHS = Object.freeze([ "etc/s6-overlay/s6-rc.d/ttyd/dependencies.d/init-state", "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", @@ -88,6 +92,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", @@ -118,12 +123,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 a954f3f3..6c503a1b 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 6669bd1c..c3f278c7 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",