From 9fb7d2c415ed21882a2cb88a4eb8035f38a2414c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 26 Oct 2026 16:31:42 -0400 Subject: [PATCH 1/8] Safe updates, step 1: Plannotator downloads the install script and checksums itself On macOS and Linux the update wrapper no longer downloads with curl. The running binary's internal `update-prepare` fetches install.sh and both CPU types' .sha256 into a private temp dir; the wrapper runs that script with PLANNOTATOR_EXPECTED_SHA256, and install.sh exits 3 on a mismatch before it replaces anything. A failed download runs nothing (exit 75). Also: sem v0.8.0 checksums pinned in all three installers; a 1/3/7-day auto-update back-off after a mismatch; no curl | bash line offered after exit 3 or 75 (plannotator update, the Inbox failure dialog); tests and docs. --- .agents/skills/release/SKILL.md | 1 + apps/hook/server/index.ts | 8 + apps/hook/server/unknown-subcommand.ts | 1 + apps/hook/server/update-command-entry.ts | 10 +- apps/hook/server/update-command.test.ts | 64 ++- apps/hook/server/update-command.ts | 22 +- docs/agent-guides/environment.md | 6 +- docs/agent-guides/inbox-foundation.md | 2 +- packages/inbox/components/Updates.tsx | 34 +- packages/inbox/updates.test.tsx | 43 ++ packages/server/auto-update.test.ts | 217 ++++++++-- packages/server/auto-update.ts | 159 +++++++- packages/server/package.json | 1 + packages/server/update-prepare-entry.ts | 9 + packages/server/update-prepare.test.ts | 447 +++++++++++++++++++++ packages/server/update-prepare.ts | 197 +++++++++ packages/shared/update-check.ts | 8 + packages/ui/utils/autoUpdateNotice.test.ts | 25 +- packages/ui/utils/autoUpdateNotice.ts | 15 + scripts/install.cmd | 14 +- scripts/install.ps1 | 7 +- scripts/install.sh | 62 ++- scripts/install.test.ts | 99 +++++ 23 files changed, 1355 insertions(+), 96 deletions(-) create mode 100644 packages/server/update-prepare-entry.ts create mode 100644 packages/server/update-prepare.test.ts create mode 100644 packages/server/update-prepare.ts diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 9c4ba93bba..f7eb2ba6ce 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -267,6 +267,7 @@ Before tagging, verify: - [ ] The PR-safe `release-security` job generated a schema-valid, sentinel-complete SBOM and accepted the Grype policy with a fresh database - [ ] No scanner binary, database, generated SBOM/report, credential, or `DO_NOT_COMMIT` content is staged - [ ] For the first SBOM-enabled release, the canonical Mintlify install/verification page contains the README's SBOM scope, policy, checksum, SLSA, and CycloneDX commands (do not edit the deprecated Astro docs instead) +- [ ] If `scripts/install.sh` (or `install.ps1` / `install.cmd`) changed since the last release, the owner syncs it to S3 by hand so `https://plannotator.ai/install.sh` serves the new copy. Safe updates (from 0.28.33) depend on it: an update checks the binary against the checksums Plannotator fetched only when plannotator.ai serves an `install.sh` that reads `PLANNOTATOR_EXPECTED_SHA256`. Until it does, updates still work exactly as before (an older script ignores the variable), but without that check. Confirm with `curl -fsSL https://plannotator.ai/install.sh | grep -c PLANNOTATOR_EXPECTED_SHA256` (non-zero). After tagging, verify: - [ ] Release workflow completed with `release-security`, `attest`, `release`, and `npm-publish` green diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 868b29d3d7..3a6dd0cd0a 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -89,6 +89,7 @@ import { } from "@plannotator/server/review"; import { runGuideCli } from "@plannotator/server/guide-cli"; import { runUpdateCommand } from "./update-command"; +import { runUpdatePrepareCommand } from "@plannotator/server/update-prepare"; import { takeUpdateHint } from "@plannotator/shared/update-check"; import { startAnnotateServer as startAnnotateServerUnguarded, @@ -742,6 +743,13 @@ if (args[0] === "update") { process.exit(await runUpdateCommand(rawArgs.slice(rawArgs.indexOf("update") + 1), { currentVersion: getCliVersion() })); } +// Internal: the POSIX update wrapper's download step (safe updates, step 1). +// This binary's own fetch downloads the install script and the expected +// checksums into a private temp dir and prints the dir (update-prepare.ts). +if (args[0] === "update-prepare") { + process.exit(await runUpdatePrepareCommand(args.slice(1), { platform: process.platform })); +} + if (args[0] === "install-runtime") { const runtime = args[1]; if (runtime !== "agent-terminal" && runtime !== "call-flow") { diff --git a/apps/hook/server/unknown-subcommand.ts b/apps/hook/server/unknown-subcommand.ts index 15bdb9731c..5528187a89 100644 --- a/apps/hook/server/unknown-subcommand.ts +++ b/apps/hook/server/unknown-subcommand.ts @@ -6,6 +6,7 @@ import { const INTERNAL_SUBCOMMANDS = [ "install-runtime", + "update-prepare", "opencode-plan", "opencode-review", "opencode-review-directory", diff --git a/apps/hook/server/update-command-entry.ts b/apps/hook/server/update-command-entry.ts index 960f4728e3..07d82510dd 100644 --- a/apps/hook/server/update-command-entry.ts +++ b/apps/hook/server/update-command-entry.ts @@ -4,7 +4,15 @@ * full entry (index.ts) imports the built HTML, which the test job does not * build. index.ts dispatches the same function. */ +import { runUpdatePrepareCommand } from "@plannotator/server/update-prepare"; import { getCliVersion } from "./cli"; import { runUpdateCommand } from "./update-command"; -process.exit(await runUpdateCommand(process.argv.slice(2), { currentVersion: getCliVersion() })); +// The update wrapper calls back into its binary for `update-prepare`; a +// source run passes this entry (updatePrepareInvocation), so it answers too. +const argv = process.argv.slice(2); +process.exit( + argv[0] === "update-prepare" + ? await runUpdatePrepareCommand(argv.slice(1), { platform: process.platform }) + : await runUpdateCommand(argv, { currentVersion: getCliVersion() }), +); diff --git a/apps/hook/server/update-command.test.ts b/apps/hook/server/update-command.test.ts index 0de9e42b90..5193c17e91 100644 --- a/apps/hook/server/update-command.test.ts +++ b/apps/hook/server/update-command.test.ts @@ -5,7 +5,7 @@ * reaches GitHub or runs the real install script. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { spawn } from "node:child_process"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -96,10 +96,30 @@ describe("plannotator update (seams)", () => { expect(err).toContain("HTTP 429"); }); - test("a failed install says so, with the log (exit 1)", async () => { + test("a failed install says so, with the log and the install command (exit 1)", async () => { + expect(await run([], { runInstaller: async () => 5 })).toBe(1); + expect(err).toContain("exited with code 5"); + expect(err).toContain("update.log"); + expect(err).toContain("Or run the install script yourself: curl -fsSL"); + }); + + // Safe updates: after a mismatch (install.sh exit 3) or a failed download + // (the wrapper's 75) the old version is kept, and the `curl | bash` line is + // never offered: it would go through the connection that just failed. + test("a download that didn't match its checksum: the old version still works, no install command", async () => { expect(await run([], { runInstaller: async () => 3 })).toBe(1); - expect(err).toContain("exited with code 3"); + expect(err).toContain("The update to 0.28.11 stopped: the download didn't match the checksum Plannotator fetched for it."); + expect(err).toContain("0.28.10 is still installed and works. Try again later, or on another network: plannotator update"); + expect(err).toContain("update.log"); + expect(err).not.toContain("curl"); + expect(err).not.toContain("exited with code"); + }); + + test("an update that couldn't be downloaded: the old version still works, no install command", async () => { + expect(await run([], { runInstaller: async () => 75 })).toBe(1); + expect(err).toContain("Couldn't download the update. 0.28.10 is still installed and works. Try again later: plannotator update"); expect(err).toContain("update.log"); + expect(err).not.toContain("curl"); }); test("another update holding the lock: exit 1, no second install", async () => { @@ -119,12 +139,13 @@ describe("plannotator update (seams)", () => { // index.ts dispatches, without the built HTML the full entry imports), made a // "release" with --define and made "managed" by a temp HOME whose // ~/.local/bin/plannotator links to this bun. -describe.skipIf(process.platform === "win32" || !Bun.which("curl"))("plannotator update (process)", () => { +describe.skipIf(process.platform === "win32")("plannotator update (process)", () => { const entry = resolve(import.meta.dir, "update-command-entry.ts"); let home: string; let server: ReturnType; let installerExit = 0; let installerSleeps = false; + let sumsBroken = false; let apiHits = 0; beforeEach(() => { @@ -134,6 +155,7 @@ describe.skipIf(process.platform === "win32" || !Bun.which("curl"))("plannotator apiHits = 0; installerExit = 0; installerSleeps = false; + sumsBroken = false; server = Bun.serve({ hostname: "127.0.0.1", port: 0, @@ -144,13 +166,19 @@ describe.skipIf(process.platform === "win32" || !Bun.which("curl"))("plannotator return Response.json({ tag_name: "v9.9.9", published_at: "2026-10-10T00:00:00Z", body: "## What's New in v9.9.9\n\nThings." }); } if (path === "/install.sh") { - // The fake installer: records its argv and HOME, exits as told. + // The fake installer: records its argv, HOME and the pinned + // checksums, exits as told. The first two lines are what + // update-prepare requires of any script it saves. return new Response( - installerSleeps - ? `echo "fake installer started"\nsleep 30\n` - : `printf '%s\\n' "$@" > "$HOME/installer-args"\necho "fake installer ran"\nexit ${installerExit}\n`, + '#!/bin/bash\nREPO="backnotprop/plannotator"\n' + + (installerSleeps + ? `echo "fake installer started"\nsleep 30\n` + : `printf '%s\\n' "$@" > "$HOME/installer-args"\nprintf '%s' "$PLANNOTATOR_EXPECTED_SHA256" > "$HOME/installer-pins"\necho "fake installer ran"\nexit ${installerExit}\n`), ); } + const sum = /^\/download\/v9\.9\.9\/(plannotator-(?:darwin|linux)-(?:arm64|x64))\.sha256$/.exec(path); + if (sum && sumsBroken) return new Response("captive portal"); + if (sum) return new Response(`${sum[1]!.endsWith("arm64") ? "a".repeat(64) : "b".repeat(64)} ${sum[1]}\n`); return new Response("not found", { status: 404 }); }, }); @@ -171,6 +199,7 @@ describe.skipIf(process.platform === "win32" || !Bun.which("curl"))("plannotator PLANNOTATOR_DATA_DIR: dataDir, PLANNOTATOR_UPDATE_API_URL: `http://127.0.0.1:${server.port}/releases/latest`, PLANNOTATOR_INSTALL_SCRIPT_URL: `http://127.0.0.1:${server.port}/install.sh`, + PLANNOTATOR_RELEASE_DOWNLOAD_URL: `http://127.0.0.1:${server.port}/download`, }, stdout: "pipe", stderr: "pipe", @@ -197,6 +226,24 @@ describe.skipIf(process.platform === "win32" || !Bun.which("curl"))("plannotator expect(JSON.parse(readFileSync(join(dataDir, "update-result.json"), "utf-8"))).toEqual({ version: "v9.9.9", exitCode: 0 }); expect(readFileSync(join(dataDir, "update.log"), "utf-8")).toContain("fake installer ran"); expect(apiHits).toBe(1); + // Safe updates: the script ran from the copy Plannotator downloaded, with + // both CPU types' checksums, and the staged copy is gone afterwards. + const os = process.platform === "darwin" ? "darwin" : "linux"; + expect(readFileSync(join(home, "installer-pins"), "utf-8")).toBe(`plannotator-${os}-arm64=${"a".repeat(64)} plannotator-${os}-x64=${"b".repeat(64)}`); + expect(readdirSync(home).filter((n) => n.startsWith("plannotator-update-"))).toEqual([]); + }, 60_000); + + test("a checksum Plannotator can't read means nothing runs: exit 1, the old version kept, no install command", async () => { + sumsBroken = true; + const r = await cli(); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("Couldn't download the update. 0.1.0 is still installed and works."); + expect(r.stderr).not.toContain("curl"); + expect(existsSync(join(home, "installer-args"))).toBe(false); + expect(existsSync(join(dataDir, "update.lock"))).toBe(false); + expect(JSON.parse(readFileSync(join(dataDir, "update-result.json"), "utf-8"))).toEqual({ version: "v9.9.9", exitCode: 75 }); + expect(readFileSync(join(dataDir, "update.log"), "utf-8")).toContain("not a SHA-256 checksum"); + expect(readdirSync(home).filter((n) => n.startsWith("plannotator-update-"))).toEqual([]); }, 60_000); test("a failing install script exits 1 and says where the log is", async () => { @@ -218,6 +265,7 @@ describe.skipIf(process.platform === "win32" || !Bun.which("curl"))("plannotator PLANNOTATOR_DATA_DIR: dataDir, PLANNOTATOR_UPDATE_API_URL: `http://127.0.0.1:${server.port}/releases/latest`, PLANNOTATOR_INSTALL_SCRIPT_URL: `http://127.0.0.1:${server.port}/install.sh`, + PLANNOTATOR_RELEASE_DOWNLOAD_URL: `http://127.0.0.1:${server.port}/download`, }, detached: true, stdio: ["ignore", "pipe", "pipe"], diff --git a/apps/hook/server/update-command.ts b/apps/hook/server/update-command.ts index 2b7e2a8bd4..45abb61dc4 100644 --- a/apps/hook/server/update-command.ts +++ b/apps/hook/server/update-command.ts @@ -11,7 +11,8 @@ * update checks are off (PLANNOTATOR_UPDATE_CHECK=0); it says so. * * Exit codes: 0 up to date, updated, or `--check`; 1 the update could not run - * or failed; 2 usage. + * or failed (including a download that did not match its checksum, or one + * that could not be downloaded: the old binary is kept either way); 2 usage. */ import { homedir } from "node:os"; @@ -37,6 +38,8 @@ import { isManagedBinary, managedBinaryPath, runInstallerForeground, + UPDATE_CHECKSUM_MISMATCH, + UPDATE_PREPARE_FAILED, type InstallerLaunch, } from "@plannotator/server/auto-update"; import { UPDATE_COMMAND_USAGE } from "./cli"; @@ -157,6 +160,23 @@ export async function runUpdateCommand(args: string[], deps: UpdateCommandDeps): ); return 130; } + // Safe updates: never the `curl | bash` line here. After a mismatch it + // would send the person through the very connection that just failed. + if (code === UPDATE_CHECKSUM_MISMATCH) { + err( + `The update to ${latest} stopped: the download didn't match the checksum Plannotator fetched for it.\n` + + `${current} is still installed and works. Try again later, or on another network: plannotator update\n` + + `Details: ${autoUpdatePaths(dataDir).log}\n`, + ); + return 1; + } + if (code === UPDATE_PREPARE_FAILED) { + err( + `Couldn't download the update. ${current} is still installed and works. Try again later: plannotator update\n` + + `Details: ${autoUpdatePaths(dataDir).log}\n`, + ); + return 1; + } if (code !== 0) { err( `The update didn't install: the install script exited with code ${code}. Plannotator is still ${current}.\n` + diff --git a/docs/agent-guides/environment.md b/docs/agent-guides/environment.md index 14ea90f580..b38c741934 100644 --- a/docs/agent-guides/environment.md +++ b/docs/agent-guides/environment.md @@ -41,14 +41,16 @@ | `PLANNOTATOR_BITBUCKET_API_URL` | Testing / proxy knob: base URL of the Bitbucket Cloud REST API (default `https://api.bitbucket.org/2.0`). The fake API in `tests/test-fixtures/bitbucket/fake-bitbucket.ts` is reached through it. Only `https:` or loopback `http:` is accepted (credentials are sent there); anything else warns once and uses the default. Redirects (the PR `/diff` and `/diffstat` endpoints answer with one) and pagination `next` links are followed only within this origin. | | `PLANNOTATOR_GUIDE_HISTORY` | Set to `0` / `false` to disable persisting successful Guided Reviews (no guide copies are written to the data dir; the "Previous guides" list is then never populated, though already-saved guides remain readable and listed). **Note that a persisted guide includes a full copy of the diff it was generated against** — `history/.../guides/{id}.patch` beside the `{id}.json` envelope, uncapped, as large as the diff — because that patch is what a later portable export or share link renders (the diff is captured when the guide job launches, never re-read from the working tree). Deleting a guide removes both files; nothing prunes the directory otherwise. Turning this flag off skips the patch copy too, at the cost of exports and share links for guides from that session once the server exits. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "guideHistory": false }`); the env var takes precedence. | | `PLANNOTATOR_REVIEW_PROGRESS` | Set to `0` / `false` (also `off` / `disabled`) to stop code review remembering which files you marked viewed across review sessions. Default: enabled, which writes one small JSON record per file you mark viewed or unviewed under `review-progress/{scopeHash}/{pathHash}.json` in the data dir (`~/.plannotator` or `PLANNOTATOR_DATA_DIR`), for local Git and PR reviews. **Each record carries the repo-relative file path in plain text** (plus a content fingerprint and the viewed flag; the scope directory name is a hash of the worktree root, branch and comparison, or of the PR URL). Nothing prunes the directory; delete `review-progress/` to forget, and `plannotator uninstall --purge` removes it (a plain uninstall keeps it). With the knob off the review server neither reads nor writes these records: `/api/review-progress` answers `{ available: false }` and viewed marks ride the annotation draft instead, exactly as in workspace, non-Git and piped-patch reviews. Records already on disk are left alone. Can also be set via `~/.plannotator/config.json` (`{ "reviewProgress": false }`); the env var takes precedence, and an empty or unrecognized env value counts as unset. Resolved by `resolveReviewProgress` in `packages/shared/config.ts`, read on every review snapshot in both runtimes. | -| `PLANNOTATOR_AUTO_UPDATE` | Opt-in background auto-update (#1634). Set to `1` / `true` / `on` to enable, `0` / `false` / `off` / `disabled` to force off; empty or unrecognized counts as unset. **Default: off**; nothing changes for anyone who has not opted in. Can also be set via `~/.plannotator/config.json` (`{ "autoUpdate": true }`) or Settings → General → "Keep Plannotator up to date" (allowlisted on `POST /api/config` in both runtimes; `getServerConfig` always sends the config-file value plus `autoUpdateEnv` when this env var overrides it, and the toggle is disabled with a note in that case); the env var takes precedence (`resolveAutoUpdate` in `packages/shared/config.ts`). The whole mechanism is "run the normal install script": the compiled CLI (`apps/hook/server/index.ts`, `registerCliSession`) calls `scheduleAutoUpdateCheck` (`packages/server/auto-update.ts`) each time a session registers, which returns immediately and runs on a later, unref'd tick, so it is never awaited and adds no latency to any hook, review, annotate or CLI command. That tick first runs the shared release check (`PLANNOTATOR_UPDATE_CHECK` row: at most one request every 10 minutes per data dir, to the release page), then auto-update, which answers from the same `latestRelease` cache and never runs while update checks are off (outcome `checks-off`). The check is skipped for dev/source runs (no `__CLI_VERSION__`), when the running executable is not the file the install script manages (`~/.local/bin/plannotator`, `%LOCALAPPDATA%\plannotator\plannotator.exe`), and is throttled to once per 24h (`lastCheckAt` in `update-state.json` in the data dir). It reads the newest stable release from the shared cache (asking GitHub `releases/latest` only when the cache is stale; strictly newer, never a downgrade); if another Plannotator session is open (a live pid other than ours in the `sessions/` registry) it logs, does NOT spend the 24h window, and retries on the next session start. Otherwise it takes `update.lock` (stale after 1h) and spawns a detached wrapper that downloads `https://plannotator.ai/install.sh` (Windows: `install.ps1`) and runs it with `--version vX.Y.Z --non-interactive` (`-Version … -NonInteractive`), all output to `update.log`; the wrapper writes the exit code to `update-result.json` and releases the lock. The script applies the user's own `skipInstall` config and saved install prefs as a manual run would, and install flags are remembered: after a successful run each installer (`install.sh`, `install.ps1`, `install.cmd`, including the `--minimal` early exit) writes the install-affecting choices it took from its COMMAND LINE to `install-flags.json` in the data dir as neutral ids (`{"v":1,"flags":["minimal","skip-codex"]}`; the set is `minimal`, `no-minimal`, `verify-attestation`, `skip-attestation`, `with-call-flow`, `skip-codex`, `skip-gemini`, `skip-kiro`, `skip-vibe`, `skip-opencode`, `skip-skills`, `skip-claude-plugin`; never raw argv, paths or secrets), and a manual run with none of them overwrites it with an empty set, i.e. back to defaults. Auto-update reads it (`readInstallFlags`), keeps only allowlisted ids (unknown entries and a mutually-exclusive pair are dropped and logged), and passes them re-spelled per installer (`--skip-codex` / `-SkipCodex`): as argv after the `sh -c` wrapper (`"$@"`) on POSIX, and on Windows as the `PLANNOTATOR_UPDATE_FLAGS` env var that the `-EncodedCommand` wrapper re-checks against the same allowlist and splats as an array. A missing or malformed file means no flags (today's behavior) plus an `update.log` line. Inside the script the flag still beats the env var, which beats config. The OpenCode and Pi runtimes never arm the trigger. The next UI load (plan, annotate, review) shows a one-time toast from `autoUpdateNotice` on `/api/plan` / `/api/diff` (Bun servers only, and only when the compiled CLI enabled it): "Updated to vX" once the running binary is that version, or "Auto-update failed" with the log path when the script exited non-zero; "seen" is the `plannotator-auto-update-notice-seen` cookie. Install scripts replace the binary safely for this: `install.sh` stages next to the target and renames over it (a running process keeps its inode), `install.ps1` / `install.cmd` rename a running `plannotator.exe` aside to `plannotator.exe.old*` and sweep those on a later run. `plannotator uninstall --purge` removes the `update*` files (`update.log.1` included) and `install-flags.json`. | +| `PLANNOTATOR_AUTO_UPDATE` | Opt-in background auto-update (#1634). Set to `1` / `true` / `on` to enable, `0` / `false` / `off` / `disabled` to force off; empty or unrecognized counts as unset. **Default: off**; nothing changes for anyone who has not opted in. Can also be set via `~/.plannotator/config.json` (`{ "autoUpdate": true }`) or Settings → General → "Keep Plannotator up to date" (allowlisted on `POST /api/config` in both runtimes; `getServerConfig` always sends the config-file value plus `autoUpdateEnv` when this env var overrides it, and the toggle is disabled with a note in that case); the env var takes precedence (`resolveAutoUpdate` in `packages/shared/config.ts`). The whole mechanism is "run the normal install script": the compiled CLI (`apps/hook/server/index.ts`, `registerCliSession`) calls `scheduleAutoUpdateCheck` (`packages/server/auto-update.ts`) each time a session registers, which returns immediately and runs on a later, unref'd tick, so it is never awaited and adds no latency to any hook, review, annotate or CLI command. That tick first runs the shared release check (`PLANNOTATOR_UPDATE_CHECK` row: at most one request every 10 minutes per data dir, to the release page), then auto-update, which answers from the same `latestRelease` cache and never runs while update checks are off (outcome `checks-off`). The check is skipped for dev/source runs (no `__CLI_VERSION__`), when the running executable is not the file the install script manages (`~/.local/bin/plannotator`, `%LOCALAPPDATA%\plannotator\plannotator.exe`), and is throttled to once per 24h (`lastCheckAt` in `update-state.json` in the data dir). It reads the newest stable release from the shared cache (asking GitHub `releases/latest` only when the cache is stale; strictly newer, never a downgrade); if another Plannotator session is open (a live pid other than ours in the `sessions/` registry) it logs, does NOT spend the 24h window, and retries on the next session start. Otherwise it takes `update.lock` (stale after 1h) and spawns a detached wrapper that runs the install script with `--version vX.Y.Z --non-interactive` (`-Version … -NonInteractive`), all output to `update.log`; the wrapper writes the exit code to `update-result.json` and releases the lock. On macOS and Linux (safe updates, step 1) the wrapper never downloads with `curl`: it runs the same binary's internal `update-prepare vX.Y.Z` (`packages/server/update-prepare.ts`), whose own fetch downloads `https://plannotator.ai/install.sh` and the `.sha256` of both CPU types' binaries into a private `plannotator-update-*` temp dir (0700; removed on exit, Ctrl-C and TERM; a killed run's dir is swept by the next update after 1h), then runs that saved script with `PLANNOTATOR_EXPECTED_SHA256` (row below). If that download fails (offline, a proxy, a bad `.sha256`, a page that is not the script) nothing runs and the result is exit `75`. Windows downloads `install.ps1` itself, as before. A download that did not match (`install.sh` exit `3`, nothing replaced) is retried by auto-update after 1, then 3, then 7 days (`mismatchBackoff: { version, attempts, lastAttemptAt }` in `update-state.json`, outcome `backed-off`; the 24h window is still spent, a newer release is tried at once, Update now and `plannotator update` ignore it). The script applies the user's own `skipInstall` config and saved install prefs as a manual run would, and install flags are remembered: after a successful run each installer (`install.sh`, `install.ps1`, `install.cmd`, including the `--minimal` early exit) writes the install-affecting choices it took from its COMMAND LINE to `install-flags.json` in the data dir as neutral ids (`{"v":1,"flags":["minimal","skip-codex"]}`; the set is `minimal`, `no-minimal`, `verify-attestation`, `skip-attestation`, `with-call-flow`, `skip-codex`, `skip-gemini`, `skip-kiro`, `skip-vibe`, `skip-opencode`, `skip-skills`, `skip-claude-plugin`; never raw argv, paths or secrets), and a manual run with none of them overwrites it with an empty set, i.e. back to defaults. Auto-update reads it (`readInstallFlags`), keeps only allowlisted ids (unknown entries and a mutually-exclusive pair are dropped and logged), and passes them re-spelled per installer (`--skip-codex` / `-SkipCodex`): as argv after the `sh -c` wrapper (`"$@"`) on POSIX, and on Windows as the `PLANNOTATOR_UPDATE_FLAGS` env var that the `-EncodedCommand` wrapper re-checks against the same allowlist and splats as an array. A missing or malformed file means no flags (today's behavior) plus an `update.log` line. Inside the script the flag still beats the env var, which beats config. The OpenCode and Pi runtimes never arm the trigger. The next UI load (plan, annotate, review) shows a one-time toast from `autoUpdateNotice` on `/api/plan` / `/api/diff` (Bun servers only, and only when the compiled CLI enabled it): "Updated to vX" once the running binary is that version, or "Auto-update failed" with the log path when the script exited non-zero (exit `3`: "Update to vX stopped", once per version, `reason: "mismatch"`; exit `75` from a background run shows nothing, since nothing changed); "seen" is the `plannotator-auto-update-notice-seen` cookie. Install scripts replace the binary safely for this: `install.sh` stages next to the target and renames over it (a running process keeps its inode), `install.ps1` / `install.cmd` rename a running `plannotator.exe` aside to `plannotator.exe.old*` and sweep those on a later run. `plannotator uninstall --purge` removes the `update*` files (`update.log.1` included) and `install-flags.json`. | | `PLANNOTATOR_UPDATE_CHECK` | Update notices (owner-approved design; design record, not in this repo). **Default: on.** `0` / `false` / `off` / `disabled` turn it off, `1` / `true` / `on` force it on; empty or unrecognized counts as unset. Also `~/.plannotator/config.json` `{ "updateCheck": false }` (allowlisted on `POST /api/config` in all six servers; `getServerConfig` always sends `updateCheck`, the config-file value, plus `updateCheckEnv` when this env var overrides it); the env var wins (`resolveUpdateCheck` in `packages/shared/config.ts`). The machine asks, never the browser (the review windows' own GitHub fetch is gone, below): `runReleaseCheck` in `packages/shared/update-check.ts` (vendored to Pi) reads the newest release from GitHub's "latest release" link: one unauthenticated `HEAD https://github.com/backnotprop/plannotator/releases/latest` with `user-agent: plannotator-update-check` (no version, no cookies), read from its `302` to `…/releases/tag/vX.Y.Z` without following it (`fetchLatestFromPage`; the `Location` counts only on the same origin at exactly the request path with `latest` replaced by `tag/v`, `versionFromLatestRedirect`; a runtime that followed it anyway is read from `response.url`). That link is not under the API's 60 requests per hour per IP, is right the moment a release is published (drafts and pre-releases are never "latest", nor is a tag with no release), carries no body, and needs nothing from the release pipeline. It asks at most once every 10 minutes per data dir (`RELEASE_CHECK_INTERVAL_MS`; owner's decision 2026-10-24, so a new release shows within minutes), and an Inbox start or an Inbox page load asks when the answer is over a minute old (`RELEASE_CHECK_ON_START_MS`); it claims the attempt in `update-state.json` (`releaseCheck.attemptedAt`) before the request so concurrent starts ask once, and after a failure (offline, a 5xx, a proxy) waits 10 minutes whatever triggers the next check (a start or a reload included), never retrying in a loop (`RELEASE_CHECK_RETRY_MS`). When the page itself refuses for a rate limit (429, or 403 with `Retry-After` / `x-ratelimit-remaining: 0`: an office NAT can meet github.com's limits too), the next check waits the time it names, kept between 10 minutes and an hour, an hour when it names none (`RELEASE_PAGE_RATE_LIMITED_RETRY_MS`, `releaseCheck.pageRetryAt`, cleared by a success). When the page fails, the API is the fallback (`GET https://api.github.com/repos/backnotprop/plannotator/releases/latest`, `fetchLatestRelease`), asked at most once an hour (`RELEASE_API_FALLBACK_INTERVAL_MS`, `releaseCheck.apiAttemptedAt`); a rate-limited API answer (403/429) holds back only the API, until GitHub's `Retry-After` or `x-ratelimit-reset`, kept between 1 h and 6 h, else 6 h (`RELEASE_CHECK_RATE_LIMITED_RETRY_MS`, `releaseCheck.apiRetryAt`; a `retryAt` written by 0.28.30 is read the same way), never the page: before this change the API was the only source, and one 403 hid every notice for hours. A failure keeps the last known `latestRelease` and writes an `update.log` line only when the errors change (`releaseCheck.logged`, `failures` counts the run; the line after a change and the first success say how many failed before), so an offline machine does not add a line every 10 minutes; `update.log` moves to `update.log.1` once it passes 1 MiB (`UPDATE_LOG_MAX_BYTES`, `uninstall --purge` removes both); `releaseCheck.source` says which source answered (`page` or `api`). The tag is reduced to a canonical `X.Y.Z` before it is stored (pre-releases and junk are failures). The redirect carries no notes or publish date: the API answer does, and the Inbox fetches a newer release's notes by its tag (`fetchReleaseNotesFor`, throttled per version) when it learns of it, which also fills in `latestRelease.publishedAt`. Besides those requests, the Inbox (and only the Inbox) downloads, in the background whether or not What's new is opened, up to 12 images per release that the saved notes show, from GitHub's release-attachment image hosts (`github.com/user-attachments`, `private-user-images.githubusercontent.com`, `user-images.githubusercontent.com`, this repository on `raw.githubusercontent.com`), with the same user agent and a per-version claim and back-off (`releaseImagesFetch`); only while update checks are on (see "Images in the notes" under the Inbox's update control). The release notes (the body from `## What's New in` up to `## Install / Update`, 64 KB cap, plus the "Missed recent releases?" table) go to `update-notes/.json`. Who asks: the compiled CLI at `registerCliSession` (the same unref'd tick as auto-update; never in a build that is not a release, whose `__CLI_VERSION__` is absent or not `X.Y.Z`, e.g. `0.0.0-dev`); and the Plannotator Inbox of a release build, on start, on each page load (`GET /`, `updates.nudge()`) and on each health tick (`packages/server/inbox-updates.ts`; each answers from the cache within its window); and the Pi extension, on each review-window payload (due-gated like the rest) (`apps/pi-extension/server/update-advert.ts`). Everything else reads the cache only. Off means no automatic request to GitHub at all (machine or browser): no terminal hint, no review-window notice, no Inbox Update button for a release that is only available (a binary already on disk still shows Ready), and no auto-update; an explicit `plannotator update` (or `--check`) still asks, and says so on stderr. The terminal hint: after a `review` / `annotate` / `annotate-last` / `last` / `archive` session the compiled CLI prints `Plannotator X is available (you have Y). Update: plannotator update` on stderr, only when stderr is a TTY, never with `--json` / `--hook`, never with `CLAUDECODE` or `PLANNOTATOR_SESSION_TAG` set (an agent's shell or the mod), at most once per 24h (`hintShownAt`), read from the cache (`takeUpdateHint`). `plannotator --version` is unchanged (the Inbox parses it). `plannotator update [--check]` (`apps/hook/server/update-command.ts`) forces a check, then runs the SAME wrapper as auto-update in the foreground (`beginManualUpdate` + `runInstallerForeground`: `update.lock`, pinned tag, remembered `install-flags.json`, output shown and appended to `update.log`, `update-result.json`), without auto-update's 24h window and its open-sessions gate (open sessions keep their old binary); it refuses a build that is not a release (no request at all) and a binary the install script does not manage (printing the install command with the remembered flags); exit 0 up to date / updated / `--check`, 1 failed or could not run, 2 usage, 130 interrupted. Interruption: the POSIX wrapper traps INT / TERM / HUP and the PowerShell wrapper writes its result in `finally`, so a Ctrl-C records exit 130 in `update-result.json` and removes `update.lock` at once (it never waits out the 1h stale-lock window); `runInstallerForeground` passes an INT / TERM sent only to the CLI on to the wrapper, and a wrapper killed outright still ends with the lock released and `-1` recorded (`abortLaunch`). `update-command-entry.ts` runs the same function alone, for the process tests (the full entry imports the built HTML). `uninstall --purge` also removes `update-notes/`. Review windows (design §4.5, owner's answer 3): the browser never asks GitHub any more (`useUpdateCheck` lost its fetch; `?preview-update=X` still simulates one). The compiled CLI's plan, review and annotate servers add `updateAvailable: { version, current, releaseUrl, action, command, publishedAt? }` to `/api/plan` and `/api/diff` (`getUpdateAdvert(origin)` in `packages/server/auto-update.ts`, read from the cache only), where `action` is `opencode` for an OpenCode-launched CLI, `script` for the binary the install script manages in a session only this computer reaches, `copy` otherwise (an unmanaged binary, and every remote-mode or `--tailscale` session, so a device on the LAN or tailnet is shown the command, never offered the installer), and `command` is the install command with the remembered flags (`installCommandFor`; PowerShell scriptblock form on Windows). Pi's three servers send the same field with `action: "pi"` and `command: "pi update --extensions"`, compared with the Pi package's own version (`apps/pi-extension/server/update-advert.ts`, which also schedules the release check on each payload from the vendored module; nothing for `oh-my-pi`). Known gap: OpenCode's in-process embedded plan server sends nothing (it knows no version). The toast (`useUpdateToast`, `packages/ui/hooks/`) shows at most once per version and at most once a day (24 h) across versions (`claimUpdateToast`, cookie `plannotator-update-toast` = `@`, shared by every localhost port), first looks 5 s after load (past the first-run announcements' own window), waits while a first-run dialog of this load shows or a composer / dialog has focus (re-checking every 5 s for up to 2 min, unconsumed), never for a share link, archive or a session without a server, and never while auto-update is active and can install (`isAutoUpdateActive`: auto-update on, checks on, the managed binary, and `autoUpdateCanInstall`: an install is running, or auto-update's 24 h window has elapsed and no other Plannotator session is open; auto-update installs at most once a day and skips every start that finds another session open, so without these a person was not told for up to a day, or never while a session was always open). The sessions it counts come from `listLiveSessions` (`packages/server/sessions.ts`), a read-only lister that never deletes a file, since this runs on every `/api/plan` and `/api/diff`; `registerSession` writes atomically (temp + rename), so `listSessions`' cleanup never takes a half-written file for a corrupt one. Its actions are **What's new** (the GitHub release) and, for `script`, **Update** (`POST /api/update`, `handleUpdateRoute`: `403 forbidden_host` unless the Host is loopback with this server's port (`isLoopbackHostHeader`), `403` cross-origin, `403 update_exposed` for a remote-mode or `--tailscale` session, `409 session_mismatch` for a body `serverSession` naming another session (the client sends it, `withServerSession`); then `startManualUpdateForThisBinary`, the same wrapper and `update.lock` as the Inbox's Update now; `409 update_locked` / `update_unmanaged` / `update_dev_build` / …), else **Copy command**. The Options button keeps its dot (no label shimmer any more) and its version section shows "X is available · What's new", the host's command with Copy, and Update now for `script`. Settings → General gains "Tell me when a new version is out" (`useUpdateCheckSetting`, shown when `serverConfig.updateCheck` is present; written with `POST /api/config`, deliberately NOT a settings-registry entry, because the registry is bundled into the pinned guides.show viewer and must not change its build) above "Keep Plannotator up to date", which is locked with "Needs update checks" while checks are off. | | `PLANNOTATOR_UPDATE_API_URL` | Testing / mirror knob for the update check: the URL answering like GitHub's `releases/latest` (`releaseApiUrl` in `packages/shared/update-check.ts`). Only `https:` or loopback `http:` is accepted; anything else is ignored. One release's notes by its tag go to the URL resolved against it (`tags/v` in place of its last segment, `releaseTagApiUrl`). The `plannotator update` process test serves a fake one on loopback. A loopback `http:` value also lets the Inbox fetch What's new images from that same origin (`allowedReleaseImageUrl` in `packages/server/inbox-whats-new-images.ts`); any other value adds no image host. Set alone (no `PLANNOTATOR_UPDATE_LATEST_URL`), it is the ONLY source: the release page on github.com is not asked (`releasePageUrl` answers null), so a GHE proxy or a test fake is never bypassed; the API is then asked at most once an hour. The test preload sets it to a closed loopback port, which is what keeps every bun test away from github.com. | | `PLANNOTATOR_TELEMETRY` | Hard off for usage counts of experimental features (see "Usage counts for experimental features"). `0` / `false` / `off` / `disabled` / `no` turn counts off everywhere, whatever config.json says (counting also needs the feature on in config.json and its notice shown, config.json `usageCountsNotice`): nothing is recorded, nothing is sent, and the counts on disk that were not sent are deleted the next time the recorder or sender runs. No value turns counts on (`1` is the same as unset). Read on each record and each send (`usageCountsEnvBlock` in `packages/shared/usage-counts.ts`). | | `DO_NOT_TRACK` | Honored ([consoledonottrack.com](https://consoledonottrack.com)): any value but empty, `0`, `false`, `no` or `off` turns usage counts off everywhere, and wins over `PLANNOTATOR_TELEMETRY` and config.json. Nothing else in Plannotator reads it. | | `PLANNOTATOR_TELEMETRY_URL` | Testing knob for usage counts: a loopback `http:` origin (`http://127.0.0.1:`, no path) that the sender POSTs to instead of `https://counts.plannotator.ai/v1/counts`. It applies only to a build that is not a release (no X.Y.Z `__CLI_VERSION__`: a source run or a test), or under `PLANNOTATOR_TEST_SANDBOX`. A release ignores it, so nothing can redirect real counts. A build that is not a release and has no valid value neither records nor sends. The test preload unsets it. | | `PLANNOTATOR_UPDATE_LATEST_URL` | Testing / mirror knob for the update check's primary source: a URL answering like GitHub's `https://github.com///releases/latest`, i.e. `302` to `…/releases/tag/vX.Y.Z` on the same origin (`releasePageUrl` / `fetchLatestFromPage` in `packages/shared/update-check.ts`). Only `https:` or loopback `http:` is accepted; anything else is ignored. Used by `apps/hook/server/update-notices-cli.test.ts` and `tests/e2e/inbox-update-notice.spec.ts`. | -| `PLANNOTATOR_INSTALL_SCRIPT_URL` | Testing / mirror knob: the install script the update wrapper downloads (`installScriptUrl` in `packages/server/auto-update.ts`; default `https://plannotator.ai/install.sh`, `install.ps1` on Windows), for `plannotator update`, Update now and auto-update alike. Only `https:` or loopback `http:` is accepted. | +| `PLANNOTATOR_INSTALL_SCRIPT_URL` | Testing / mirror knob: the install script an update downloads (`installScriptUrl` in `packages/server/auto-update.ts`; default `https://plannotator.ai/install.sh`, `install.ps1` on Windows), for `plannotator update`, Update now and auto-update alike. On macOS and Linux `update-prepare` downloads it with Plannotator's own fetch and keeps it only when it starts with `#!` and contains `REPO="backnotprop/plannotator"` (under 4 MB); on Windows the wrapper downloads it. Only `https:` or loopback `http:` is accepted. | +| `PLANNOTATOR_RELEASE_DOWNLOAD_URL` | Testing / mirror knob: the base `update-prepare` downloads each binary's `/vX.Y.Z/plannotator--.sha256` from (`releaseDownloadUrl` in `packages/server/update-prepare.ts`; default `https://github.com/backnotprop/plannotator/releases/download`). Only `https:` or loopback `http:` is accepted. The update tests serve a fake one on loopback. | +| `PLANNOTATOR_EXPECTED_SHA256` | Set by Plannotator's update wrapper, not by people: `plannotator--arm64= plannotator--x64=`, the checksums `update-prepare` fetched with its own connection. `install.sh` uses the entry for the binary it picked as the expected checksum (and does not fetch the `.sha256` again); on a mismatch it prints "Stopped: …", replaces nothing and exits `3`. A missing or malformed entry prints a note and falls back to the `.sha256` check. Unset (a person running the script, an older Plannotator), nothing changes. `plannotator update` and the Inbox's failure dialog never offer the `curl \| bash` line after exit `3` or `75`. Internal wrapper variables alongside it: `PLANNOTATOR_UPDATE_BINARY` / `PLANNOTATOR_UPDATE_ENTRY` (who runs `update-prepare`; a source run passes its script). | | `PLANNOTATOR_CURSOR_SANDBOX` | Set to `0` / `false` / `disabled` to stop passing `--sandbox enabled` when launching Cursor's CLI for review jobs (`cursor-agent`, else `agent` only when that is Cursor's own CLI: `resolveMarkerBinary` over `packages/shared/agent-program.ts`, vendored to Pi, the install-layout path rule then an asynchronous 2 s `--version` probe, so another program named `agent` such as Grok Build's is never started and Cursor reads as unavailable in `/api/agents/capabilities`) — the flag pair is omitted entirely, deferring to the user's own Cursor Agent sandbox configuration. For systems where Cursor's sandbox cannot start (NixOS, AppArmor-restricted Linux). Default: enabled (`--sandbox enabled` is passed). Can also be set via `~/.plannotator/config.json` (`{ "cursorSandbox": false }`); the env var takes precedence. Note: opting out means the review job's write protection relies on `--mode ask` plus the user's own Cursor configuration. | | `PLANNOTATOR_CLAUDE_SANDBOX` | Set to `0` / `false` / `disabled` to turn Claude Code's own sandbox OFF for Claude-engine agent jobs (code review, Code Tour, Guided Review) by passing `--settings '{"sandbox":{"enabled":false}}'` (#1627). For Linux systems where the sandbox cannot start (no bubblewrap/socat, AppArmor-restricted user namespaces) while `~/.claude/settings.json` enables it: jobs run with `--permission-mode dontAsk`, so Claude's unsandboxed fallback is refused and every Bash call fails. Default: enabled, meaning Plannotator passes nothing and the user's own Claude Code sandbox setting applies. Opting out runs the job's commands without OS containment, the same as for any user who never enabled Claude's sandbox (Claude Code's default). `--tools`, the `--allowedTools` prefixes, the `--disallowedTools` list and `dontAsk` are unchanged, but the command rules limit what the model can run rather than containing it (Claude Code matches rule text, so a deny rule covers the spellings a model normally writes, not every spelling a program accepts), so treat the opt-out as removing the only OS-level boundary. Managed (enterprise) settings still outrank `--settings`. When a Claude job finishes with no shell command that ran and at least one refused command its own `--allowedTools` admits outright — a plain invocation with no operators, substitutions or variables, which `dontAsk` never refuses unless the shell itself cannot run (`detectClaudeShellBlocked`), the job carries a `warning` pointing at this knob, shown in the Agents tab and the job detail panel; the sandbox is never turned off automatically. Independently of this knob, every Claude agent job passes `--strict-mcp-config` (no `--mcp-config`, so none of the user's MCP servers load into a job) and an `--append-system-prompt` telling the model to run one simple command at a time, since `dontAsk` refuses compound commands (`$(...)`, `&&`, pipes) as a whole (`claudeJobIsolationArgs` in `packages/server/claude-review.ts`, vendored to Pi), plus `--setting-sources user`, so the user's own settings (auth, env, sandbox preference) and managed policy apply but the job directory's project/local `.claude/settings*.json` (a PR checkout is someone else's branch) cannot add allow rules or hooks to a job. Side effects: Claude auth or provider env kept only in a repo's `.claude/settings.json` / `.claude/settings.local.json` does not apply to jobs, and neither does a sandbox setting or `permissions.deny` set only in project settings; managed settings still apply. All three job kinds share ONE command allow/deny definition (`CLAUDE_JOB_ALLOWED_TOOLS` / `CLAUDE_JOB_DISALLOWED_TOOLS` via `claudeJobToolArgs`, same file): exactly the read-only commands the prompts instruct (git status/diff/log/show/blame/merge-base/rev-parse/ls-files/ls-tree, `git branch --show-current`, `git remote -v` / `get-url`, the read-only jj commands, `gh pr view|diff` / `gh issue view` and the glab equivalents, `wc`), with deny rules for git `--output` and `-c` / `--config-env`, jj `--config*` / `--tool`, and `git grep`, `git ls-remote`, `gh api`, `glab api`. There is no `git -C`: workspace prompts send the combined diff inline and point the model at the folder-prefixed files. `claude-job-isolation.test.ts` reads the commands out of the real prompt text and fails if the allowlist stops admitting one. `detectClaudeShellBlocked` also takes the job's `--disallowedTools`, so a refusal a deny rule issues by design never reads as a blocked shell. Can also be set via `~/.plannotator/config.json` (`{ "claudeSandbox": false }`); the env var takes precedence. | | `PLANNOTATOR_GIT_REMOTE_CHECK` | Set to `0` / `false` / `disabled` to stop code review contacting the git remote (issue #1553). Default: enabled, which runs `git ls-remote --symref origin HEAD` to discover the remote default branch and to compare the remote tip against the local tracking ref for the "Baseline is behind GitHub" banner. That probe is **not** on a timer: it runs at startup (the compare-target detection plus the staleness probe), on `/api/diff` page load, on `/api/diff/switch` (the diff-type/base pickers and the "Diff out of date · Refresh" button), and on the explicit `/api/fetch-base`, each rate limited to once per 60s per session, with the interval DOUBLING on failure up to a 15-minute cap and resetting to 60s on the next success (`nextRemoteBaseCheckInterval` in `packages/shared/review-core.ts`, shared by both runtimes). It is deliberately absent from `/api/diff/fresh`, which the client polls every 5s for as long as the page is open — hanging the probe there made an idle review query the remote once a minute forever, which on a smartcard-backed SSH setup is one hardware-key touch prompt per minute with nothing on screen to explain it. With the check off the session makes **zero** `ls-remote` calls, startup probes included: the compare target stays whatever LOCAL ref discovery resolved (`getDefaultBranch`: `origin/HEAD` → `origin/main` → `main` → …), `baseBehindRemote` never rides any payload so the banner never shows, and `POST /api/fetch-base` stays available because an explicit Fetch is the user asking for the network — note that with the banner gone so is its one-click Fetch button, which lives in that banner, so the endpoint matters to API callers rather than to the reviewer, and its post-fetch `refreshRemoteBaseInfo()` is a no-op under the opt-out. Only plain local git sessions are affected (PR, jj, GitButler, P4, workspace and static-patch sessions never ran the check). Can also be set via `~/.plannotator/config.json` (`{ "gitRemoteCheck": false }`); precedence is `review --no-git-remote-check` flag > env var > config (`resolveGitRemoteCheck` in `packages/shared/config.ts`). The flag is parsed by the shared `parseReviewArgs`, so it works on every host that forwards review arguments — Claude Code, OpenCode, and Pi — not just the Bun CLI. | diff --git a/docs/agent-guides/inbox-foundation.md b/docs/agent-guides/inbox-foundation.md index b910ca01a7..705e22145d 100644 --- a/docs/agent-guides/inbox-foundation.md +++ b/docs/agent-guides/inbox-foundation.md @@ -73,7 +73,7 @@ One long-lived local server per machine. Agents leave messages, `:::question` bl - Callers re-read the registry on every call. - An Inbox whose data is gone exits: every 5 s (`INBOX_DATA_CHECK_MS`) it reads `inbox.json` (`probeInboxData`). It is a miss ONLY when the file or its folder does not exist (ENOENT, ENOTDIR) or the file names another `serverSession`; any other error (EACCES, EIO, ESTALE, a file that does not parse) is unknown and leaves the count alone, and the folder's device and inode are not looked at (a remount or a moved copy must not end it). Two misses in a row stop it cleanly, then it says why on stderr (plus the manual `tailscale serve --https= off` when the tailnet take-down failed) and the process exits 0 (`onDataGone`). Nothing could reach it (agents find an Inbox through the file) and it has no store, so it must not run on headless (an e2e once removed its temp folder under a live `--tailscale` Inbox, which ran for two days at 100% CPU, cause unknown). It is a timer, so it cannot end an Inbox stuck in a synchronous loop. After it starts, the Inbox rewrites `inbox.json` only while the file names its own `serverSession` (`updateOwnInboxRegistry`, used by the tailnet and Wi-Fi saves; read then write, so a start overlapping a stop can still lose its entry, and then exits by the same check), so a stopping Inbox never rewrites its successor's entry and never makes a removed data folder again. - The detached Inbox runs with `inbox/` as its working directory and `windowsHide`. -- The update control (`packages/server/inbox-updates.ts`; design record, not in this repo): health, the list model and the event stream's `status` carry `update: { available: true, state, version, current, action, releaseUrl, publishedAt? } | null` (`InboxUpdate` in `packages/core/inbox-types.ts`; `available` and `version` keep the older shape). `ready`: on the health tick (60 s, every 2 s while `update.lock` is held), a compiled Inbox stats the binary on disk and, only when the file's identity (device, inode, size, mtime) changed, runs it with `--version`; a different version is ready (the old restart-to-update signal). `updating`: `update.lock` is held. `failed`: `update-result.json` records a non-zero exit for an install the PERSON started (`pending.by: "person"`; a background auto-update failure stays out of the sidebar). `available`: a release build (X.Y.Z, so never `dev` or `0.0.0-dev`) runs the shared release check on start and on each page load (when the answer is over a minute old) and on each tick (when it is over 10 minutes old, and 10 minutes after a failure; never while `PLANNOTATOR_UPDATE_CHECK` is off; a new release therefore shows within 10 minutes, at once on a restart or a reload) and shows a newer stable release at once, before anything is downloaded (owner's answer 2), unless "Remind me tomorrow" snoozed that version (`update-state.json` `snooze: { version, until }`, 24 h, shared by every tab; a newer release clears it). `action`: `script` when the running binary is the install script's (`isManagedBinary`), else `copy`. The list model also carries `updated`: the version this run started on when it is newer than the one the Inbox ran before (`inboxVersion` in `update-state.json`), for the sidebar's quiet "What's new in X" link this run only. That start also keeps the version it found as `inboxPreviousVersion` (additive; kept across restarts on the same version, replaced at the next update, removed on a downgrade), so the What's new page of a release this Inbox already runs still lists the releases skipped since the version run before the update (the answer's additive `since`, which `skipped` counts from; a newer release counts from the running version, and `current` stays the running version). The release check saves notes only for the `releases/latest` it fetched, so a version installed outside the Inbox (the install script run by hand) before a check saw it has none: `fetchReleaseNotesFor` (`packages/shared/update-check.ts`) then asks for that release by its tag (`releases/tags/vX`, `releaseTagApiUrl`, the same client rules: the `plannotator-update-check` user agent, nothing while `PLANNOTATOR_UPDATE_CHECK` is off, claimed in `update-state.json` `notesFetch[]` before the request, then 6 h after a failure and 24 h after an answer with no "What's New" part, never a loop) and saves `update-notes/.json`. A stored file with an empty "What's New" part (saved before the release body was edited) counts as missing and is fetched again under the same throttle. The Inbox runs it on its tick for `updated` and from `GET /api/inbox/whats-new`, only for a version it has reason to show (the one it runs, the newest known release, the binary on disk, an install in progress); the route waits up to 3 s (`WHATS_NEW_WAIT_MS`), then answers `503 { code: "notes_loading" }` with `Retry-After: 1` and the page asks again (up to 15 times). `updated` is null while its notes are neither saved nor fetchable (checks off, or a fetch in its back-off), so no link points at a page with nothing on it; the event stream's `status` carries `updated` too, so the link appears or goes once the fetch settles. Update now is `POST /api/inbox/update` (`startManualUpdate`: the same wrapper, lock, log, pinned tag and remembered flags as auto-update, without its 24 h window or its open-sessions gate; a second press while it runs is `409 update_locked`); the page that pressed it restarts the Inbox itself once `ready` arrives. A tailnet page reaches none of the update routes. +- The update control (`packages/server/inbox-updates.ts`; design record, not in this repo): health, the list model and the event stream's `status` carry `update: { available: true, state, version, current, action, releaseUrl, publishedAt? } | null` (`InboxUpdate` in `packages/core/inbox-types.ts`; `available` and `version` keep the older shape). `ready`: on the health tick (60 s, every 2 s while `update.lock` is held), a compiled Inbox stats the binary on disk and, only when the file's identity (device, inode, size, mtime) changed, runs it with `--version`; a different version is ready (the old restart-to-update signal). `updating`: `update.lock` is held. `failed`: `update-result.json` records a non-zero exit for an install the PERSON started (`pending.by: "person"`; a background auto-update failure stays out of the sidebar). `available`: a release build (X.Y.Z, so never `dev` or `0.0.0-dev`) runs the shared release check on start and on each page load (when the answer is over a minute old) and on each tick (when it is over 10 minutes old, and 10 minutes after a failure; never while `PLANNOTATOR_UPDATE_CHECK` is off; a new release therefore shows within 10 minutes, at once on a restart or a reload) and shows a newer stable release at once, before anything is downloaded (owner's answer 2), unless "Remind me tomorrow" snoozed that version (`update-state.json` `snooze: { version, until }`, 24 h, shared by every tab; a newer release clears it). `action`: `script` when the running binary is the install script's (`isManagedBinary`), else `copy`. The list model also carries `updated`: the version this run started on when it is newer than the one the Inbox ran before (`inboxVersion` in `update-state.json`), for the sidebar's quiet "What's new in X" link this run only. That start also keeps the version it found as `inboxPreviousVersion` (additive; kept across restarts on the same version, replaced at the next update, removed on a downgrade), so the What's new page of a release this Inbox already runs still lists the releases skipped since the version run before the update (the answer's additive `since`, which `skipped` counts from; a newer release counts from the running version, and `current` stays the running version). The release check saves notes only for the `releases/latest` it fetched, so a version installed outside the Inbox (the install script run by hand) before a check saw it has none: `fetchReleaseNotesFor` (`packages/shared/update-check.ts`) then asks for that release by its tag (`releases/tags/vX`, `releaseTagApiUrl`, the same client rules: the `plannotator-update-check` user agent, nothing while `PLANNOTATOR_UPDATE_CHECK` is off, claimed in `update-state.json` `notesFetch[]` before the request, then 6 h after a failure and 24 h after an answer with no "What's New" part, never a loop) and saves `update-notes/.json`. A stored file with an empty "What's New" part (saved before the release body was edited) counts as missing and is fetched again under the same throttle. The Inbox runs it on its tick for `updated` and from `GET /api/inbox/whats-new`, only for a version it has reason to show (the one it runs, the newest known release, the binary on disk, an install in progress); the route waits up to 3 s (`WHATS_NEW_WAIT_MS`), then answers `503 { code: "notes_loading" }` with `Retry-After: 1` and the page asks again (up to 15 times). `updated` is null while its notes are neither saved nor fetchable (checks off, or a fetch in its back-off), so no link points at a page with nothing on it; the event stream's `status` carries `updated` too, so the link appears or goes once the fetch settles. Update now is `POST /api/inbox/update` (`startManualUpdate`: the same wrapper, lock, log, pinned tag and remembered flags as auto-update, without its 24 h window, its open-sessions gate or its mismatch back-off; a second press while it runs is `409 update_locked`); the page that pressed it restarts the Inbox itself once `ready` arrives. On macOS and Linux that wrapper downloads the install script and checksums with the Inbox binary's own fetch (`update-prepare`, environment.md `PLANNOTATOR_AUTO_UPDATE`). The failure dialog (`FailedDialog` in `packages/inbox/components/Updates.tsx`) reads `failure.exit_code`: `3` (the download did not match the checksum Plannotator fetched) and `75` (Plannotator could not download the update) say nothing changed and the old version still works, show the log and Try Again, and never the `curl | bash` line; any other code keeps "Or run the install script yourself". A tailnet page reaches none of the update routes. - Plain `uninstall` and `--purge` both remove the `plannotator-inbox` MCP entries the connect step added (see "Connecting the agents on this computer"; `removeInboxAgentConnections` in `packages/server/uninstall.ts`): with the binary gone, an entry would fail to start at every agent launch. A plain uninstall then forgets those records, so a reinstall connects them again. - `uninstall --purge` stops a running Inbox first (its token-guarded stop route, then SIGTERM only while the port still answers with that `serverSession` and pid) and removes `inbox/` (`inbox` is in `PURGE_OWNED_TOP_LEVEL`, `packages/server/uninstall.ts`). An Inbox that is still running after that blocks the data purge: uninstall says to quit it and run `uninstall --purge` again. It also takes down a tailnet mapping a crashed Inbox left (`takeDownInboxTailnet`, only when `inbox.json` has `tailnet`; `takeDownInboxTailscale` for the window's, only when `tailscale` keeps its ports). `--dry-run` lists the stop and the take-down. A plain uninstall keeps the data. diff --git a/packages/inbox/components/Updates.tsx b/packages/inbox/components/Updates.tsx index 7a231c169a..2d4a08adfd 100644 --- a/packages/inbox/components/Updates.tsx +++ b/packages/inbox/components/Updates.tsx @@ -383,6 +383,11 @@ export function RestartDialog({ update, subject, onRestart, onCancel }: { update ); } +/** install.sh's exit when the binary didn't match the checksum Plannotator fetched (packages/server/auto-update.ts). */ +const UPDATE_EXIT_MISMATCH = 3; +/** The update wrapper's exit when Plannotator couldn't download the update (packages/server/auto-update.ts). */ +const UPDATE_EXIT_DOWNLOAD_FAILED = 75; + /** Failed → press: the plain reason, the log, Try Again. */ export function FailedDialog({ update, @@ -398,18 +403,31 @@ export function FailedDialog({ onClose: () => void; }) { const code = details?.failure?.exit_code; + // Safe updates (step 1): 3 = the download didn't match the checksum + // Plannotator fetched; 75 = Plannotator couldn't download the update. + // Neither offers the `curl | bash` line: it would go through the same + // connection that just failed. + const mismatch = code === UPDATE_EXIT_MISMATCH; + const couldNotDownload = code === UPDATE_EXIT_DOWNLOAD_FAILED; + const sub = mismatch + ? `The download didn't match the checksum Plannotator fetched for it. Nothing was changed; Plannotator ${update.current} still works. Try again later, or on another network.` + : couldNotDownload + ? `Plannotator couldn't download the update. Nothing was changed; Plannotator ${update.current} still works. Try again later.` + : `Plannotator is still ${update.current}.${code !== undefined ? ` The install script exited with code ${code}.` : ''}`; return ( - -

The update didn't install

-

- Plannotator is still {update.current}.{code !== undefined ? ` The install script exited with code ${code}.` : ''} -

+ +

{mismatch ? 'The update stopped' : <>The update didn't install}

+

{sub}

{details && ( <> -

The log says what went wrong:

+

{mismatch || couldNotDownload ? 'Details are in the log:' : 'The log says what went wrong:'}

-

Or run the install script yourself:

- + {!mismatch && !couldNotDownload && ( + <> +

Or run the install script yourself:

+ + + )} )}
diff --git a/packages/inbox/updates.test.tsx b/packages/inbox/updates.test.tsx index aa7bb8bdd4..f236f0b068 100644 --- a/packages/inbox/updates.test.tsx +++ b/packages/inbox/updates.test.tsx @@ -194,3 +194,46 @@ test.skipIf(!hasDom)('the failure dialog names the exit code and the log, and of act(() => retry.click()); expect(retried).toBe(1); }); + +// Safe updates (step 1): after a mismatch (install.sh exit 3) or a failed +// download (the wrapper's 75) the dialog must not hand the person the +// `curl | bash` line: it would go through the connection that just failed. +for (const [code, heading] of [ + [3, 'The update stopped'], + [75, "The update didn't install"], +] as const) { + test.skipIf(!hasDom)(`exit ${code}: no install-script line, the old version still works, Try Again stays`, () => { + let retried = 0; + const el = render( + retried++} + onClose={() => {}} + />, + ); + expect(el.querySelector('h2')?.textContent).toBe(heading); + expect(el.textContent).not.toContain('curl -fsSL'); + expect(el.textContent).not.toContain('Or run the install script yourself'); + expect(el.textContent).not.toContain('exited with code'); + expect(el.textContent).toContain('Plannotator 0.28.10 still works'); + expect(el.textContent).toContain('~/.plannotator/update.log'); + const retry = [...el.querySelectorAll('button')].find((b) => b.textContent === 'Try Again') as HTMLButtonElement; + act(() => retry.click()); + expect(retried).toBe(1); + }); +} + +test.skipIf(!hasDom)('any other failure still offers the install script to run by hand', () => { + const el = render( + {}} + onClose={() => {}} + />, + ); + expect(el.textContent).toContain('curl -fsSL https://plannotator.ai/install.sh | bash -s -- --skip-gemini'); +}); diff --git a/packages/server/auto-update.test.ts b/packages/server/auto-update.test.ts index 3c8c2cefe9..d062a30770 100644 --- a/packages/server/auto-update.test.ts +++ b/packages/server/auto-update.test.ts @@ -1,10 +1,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { spawn } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { AUTO_UPDATE_INTERVAL_MS, + AUTO_UPDATE_LOCK_STALE_MS, autoUpdatePaths, autoUpdateCanInstall, compareStableVersions, @@ -15,8 +16,11 @@ import { readInstallFlags, isCheckDue, isManagedBinary, + isMismatchBackedOff, isNewerStableVersion, managedBinaryPath, + mismatchBackoffMs, + noteMismatchResult, readAutoUpdateState, runAutoUpdateCheck, runInstallerForeground, @@ -25,6 +29,7 @@ import { type InstallerLaunch, } from "./auto-update"; import { RELEASE_API_FALLBACK_INTERVAL_MS, RELEASE_CHECK_RETRY_MS } from "@plannotator/shared/update-check"; +import { sweepStaleUpdateDirs } from "./update-prepare"; import type { SessionInfo } from "./sessions"; const NOW = 1_800_000_000_000; @@ -223,6 +228,104 @@ describe("post-update notice", () => { }); }); +describe("safe-update failures in the notice", () => { + const bg = { version: "0.28.0", fromVersion: "0.27.22", startedAt: NOW }; + const person = { ...bg, by: "person" as const }; + + test("a mismatch (exit 3) is shown once per version, whatever the attempt", () => { + const a = deriveAutoUpdateNotice({ pending: bg }, { version: "v0.28.0", exitCode: 3 }, "0.27.22", "/d/update.log"); + const b = deriveAutoUpdateNotice({ pending: { ...bg, startedAt: NOW + 5 } }, { version: "v0.28.0", exitCode: 3 }, "0.27.22", "/d/update.log"); + expect(a).toMatchObject({ kind: "failed", reason: "mismatch" }); + expect(a!.id).toBe(b!.id); + }); + + test("a background run that couldn't download (75) changed nothing and shows nothing; one the person asked for does", () => { + expect(deriveAutoUpdateNotice({ pending: bg }, { version: "v0.28.0", exitCode: 75 }, "0.27.22", "/d/update.log")).toBeUndefined(); + expect(deriveAutoUpdateNotice({ pending: person }, { version: "v0.28.0", exitCode: 75 }, "0.27.22", "/d/update.log")).toMatchObject({ + kind: "failed", + reason: "download", + }); + }); +}); + +describe("mismatch back-off (1, 3, then 7 days)", () => { + const DAY = AUTO_UPDATE_INTERVAL_MS; + /** One background install of v0.28.0 at `at` that ended with `exitCode`. */ + async function attempt(at: number, exitCode: number) { + const outcome = await runAutoUpdateCheck(deps({ now: () => at })); + if (outcome === "launched") { + const paths = autoUpdatePaths(dataDir); + writeFileSync(paths.result, JSON.stringify({ version: "v0.28.0", exitCode })); + rmSync(paths.lock, { force: true }); + } + return outcome; + } + + test("each mismatch waits longer; the window is spent while it waits, so nothing loops", async () => { + expect(await attempt(NOW, 3)).toBe("launched"); + // 1 day: the next daily check tries again. + expect(await attempt(NOW + DAY, 3)).toBe("launched"); + expect(readAutoUpdateState(dataDir).mismatchBackoff).toEqual({ version: "0.28.0", attempts: 1, lastAttemptAt: NOW }); + // Second mismatch: 3 days from it. + expect(await attempt(NOW + 2 * DAY, 3)).toBe("backed-off"); + // A back-off spends the 24 h window: the same day asks nothing more. + expect(await attempt(NOW + 2 * DAY + 60_000, 3)).toBe("throttled"); + expect(await attempt(NOW + 3 * DAY, 3)).toBe("backed-off"); + expect(await attempt(NOW + 4 * DAY, 3)).toBe("launched"); + // Third: 7 days, and 7 after that. + expect(await attempt(NOW + 10 * DAY, 3)).toBe("backed-off"); + expect(await attempt(NOW + 11 * DAY, 3)).toBe("launched"); + expect(await attempt(NOW + 17 * DAY, 3)).toBe("backed-off"); + expect(await attempt(NOW + 18 * DAY, 3)).toBe("launched"); + expect(readAutoUpdateState(dataDir).mismatchBackoff?.attempts).toBe(4); + expect(launches).toHaveLength(5); + }); + + test("a newer release is tried at once, and other failures never back off", async () => { + expect(await attempt(NOW, 3)).toBe("launched"); + expect(await attempt(NOW + DAY, 3)).toBe("launched"); + // v0.28.1 ships: the hold on 0.28.0 does not apply to it. + const newer = await runAutoUpdateCheck(deps({ now: () => NOW + 2 * DAY, fetchLatestTag: async () => "v0.28.1" })); + expect(newer).toBe("launched"); + expect(launches.at(-1)?.version).toBe("v0.28.1"); + expect(isMismatchBackedOff({ version: "0.28.0", attempts: 3, lastAttemptAt: NOW }, "0.28.1", NOW + 1)).toBe(false); + // A failed download (75) or another installer failure is not a mismatch. + expect(noteMismatchResult({ pending: { version: "0.28.0", fromVersion: "0.27.22", startedAt: NOW } }, { version: "v0.28.0", exitCode: 75 })).toBeUndefined(); + expect(noteMismatchResult({ pending: { version: "0.28.0", fromVersion: "0.27.22", startedAt: NOW } }, { version: "v0.28.0", exitCode: 1 })).toBeUndefined(); + }); + + test("the same attempt is counted once, however many checks read its result", () => { + const state = { pending: { version: "0.28.0", fromVersion: "0.27.22", startedAt: NOW } }; + const first = noteMismatchResult(state, { version: "v0.28.0", exitCode: 3 }); + expect(first).toEqual({ version: "0.28.0", attempts: 1, lastAttemptAt: NOW }); + expect(noteMismatchResult({ ...state, mismatchBackoff: first }, { version: "v0.28.0", exitCode: 3 })).toBe(first); + }); + + test("Update now ignores the back-off", async () => { + expect(await attempt(NOW, 3)).toBe("launched"); + expect(await attempt(NOW + DAY, 3)).toBe("launched"); + expect(await attempt(NOW + 2 * DAY, 3)).toBe("backed-off"); + const r = startManualUpdate({ + currentVersion: "0.27.22", + dataDir, + now: () => NOW + 2 * DAY + 1, + platform: "linux", + isManaged: () => true, + launchInstaller: (launch) => { + launches.push(launch); + }, + }); + expect(r.outcome).toBe("launched"); + }); + + test("a clock that went backwards or a garbled record never holds an update back", () => { + expect(isMismatchBackedOff({ version: "0.28.0", attempts: 1, lastAttemptAt: NOW + DAY }, "0.28.0", NOW)).toBe(false); + expect(isMismatchBackedOff({ version: "0.28.0", attempts: 1, lastAttemptAt: Number.NaN }, "0.28.0", NOW)).toBe(false); + expect(mismatchBackoffMs(0)).toBe(DAY); + expect(mismatchBackoffMs(99)).toBe(7 * DAY); + }); +}); + describe("managed binary", () => { test("only the path the install script writes counts", () => { const env = { LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local" }; @@ -349,28 +452,60 @@ describe("remembered install flags", () => { }); }); -describe.skipIf(process.platform === "win32" || !Bun.which("curl"))("POSIX wrapper end to end", () => { - test("the downloaded installer receives the version and the remembered flags as argv", () => { - const script = join(dataDir, "fake-install.sh"); - writeFileSync(script, 'for a in "$@"; do echo "ARG:$a"; done\n'); - const { file, args } = installerCommand("linux", ["minimal", "skip-codex"]); - const paths = autoUpdatePaths(dataDir); - const r = Bun.spawnSync([file, ...args], { - env: { - PATH: process.env.PATH ?? "/usr/bin:/bin", - TMPDIR: dataDir, - PLANNOTATOR_UPDATE_VERSION: "v0.28.0", - PLANNOTATOR_UPDATE_RESULT: paths.result, - PLANNOTATOR_UPDATE_LOCK: paths.lock, - PLANNOTATOR_UPDATE_SCRIPT_URL: `file://${script}`, - }, - stdout: "pipe", - stderr: "pipe", - }); - const argv = r.stdout.toString().split("\n").filter((l) => l.startsWith("ARG:")).map((l) => l.slice(4)); - expect(argv).toEqual(["--version", "v0.28.0", "--non-interactive", "--minimal", "--skip-codex"]); - expect(JSON.parse(readFileSync(paths.result, "utf-8"))).toEqual({ version: "v0.28.0", exitCode: 0 }); +/** + * A loopback stand-in for plannotator.ai and the release downloads: the + * install script (with the two lines update-prepare requires) and a `.sha256` + * per binary. The POSIX wrapper's `update-prepare` runs from source + * (update-prepare-entry.ts) against it. + */ +function fakeRelease(scriptBody: string) { + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(req) { + const path = new URL(req.url).pathname; + if (path === "/install.sh") return new Response(`#!/bin/bash\nREPO="backnotprop/plannotator"\n${scriptBody}`); + if (/^\/download\/v\d+\.\d+\.\d+\/plannotator-[a-z]+-(arm64|x64)\.sha256$/.test(path)) return new Response(`${"c".repeat(64)} x\n`); + return new Response("not found", { status: 404 }); + }, }); + const env = { + PLANNOTATOR_INSTALL_SCRIPT_URL: `http://127.0.0.1:${server.port}/install.sh`, + PLANNOTATOR_RELEASE_DOWNLOAD_URL: `http://127.0.0.1:${server.port}/download`, + }; + return { server, env }; +} +const PREPARE = { binary: process.execPath, entry: join(import.meta.dir, "update-prepare-entry.ts") }; +const PREPARE_ENV = { PLANNOTATOR_UPDATE_BINARY: PREPARE.binary, PLANNOTATOR_UPDATE_ENTRY: PREPARE.entry }; + +describe.skipIf(process.platform === "win32")("POSIX wrapper end to end", () => { + test("the downloaded installer receives the version and the remembered flags as argv", async () => { + const { server, env } = fakeRelease('for a in "$@"; do echo "ARG:$a"; done\n'); + try { + const { file, args } = installerCommand("linux", ["minimal", "skip-codex"]); + const paths = autoUpdatePaths(dataDir); + const proc = Bun.spawn([file, ...args], { + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + TMPDIR: dataDir, + PLANNOTATOR_UPDATE_VERSION: "v0.28.0", + PLANNOTATOR_UPDATE_RESULT: paths.result, + PLANNOTATOR_UPDATE_LOCK: paths.lock, + ...PREPARE_ENV, + ...env, + }, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + await proc.exited; + const argv = stdout.split("\n").filter((l) => l.startsWith("ARG:")).map((l) => l.slice(4)); + expect(argv).toEqual(["--version", "v0.28.0", "--non-interactive", "--minimal", "--skip-codex"]); + expect(JSON.parse(readFileSync(paths.result, "utf-8"))).toEqual({ version: "v0.28.0", exitCode: 0 }); + } finally { + server.stop(true); + } + }, 30_000); }); // Runs the real encoded wrapper under Windows PowerShell (the same @@ -514,13 +649,9 @@ describe("the toast steps aside for auto-update only when it can install", () => }); }); -describe.skipIf(process.platform === "win32" || !Bun.which("curl"))("interrupted installs release the lock", () => { - function fakeServer(body: string) { - return Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response(body) }); - } - +describe.skipIf(process.platform === "win32")("interrupted installs release the lock", () => { test("a group SIGINT (Ctrl-C) to the wrapper records 130 and removes update.lock", async () => { - const server = fakeServer('echo "fake installer started"\nsleep 30\n'); + const { server, env: releaseEnv } = fakeRelease('echo "fake installer started"\nsleep 30\n'); const paths = autoUpdatePaths(dataDir); writeFileSync(paths.lock, "1"); try { @@ -534,7 +665,8 @@ describe.skipIf(process.platform === "win32" || !Bun.which("curl"))("interrupted PLANNOTATOR_UPDATE_VERSION: "v0.28.0", PLANNOTATOR_UPDATE_RESULT: paths.result, PLANNOTATOR_UPDATE_LOCK: paths.lock, - PLANNOTATOR_UPDATE_SCRIPT_URL: `http://127.0.0.1:${server.port}/install.sh`, + ...PREPARE_ENV, + ...releaseEnv, }, }); let out = ""; @@ -546,21 +678,25 @@ describe.skipIf(process.platform === "win32" || !Bun.which("curl"))("interrupted expect(await exited).toBe(130); expect(existsSync(paths.lock)).toBe(false); expect(JSON.parse(readFileSync(paths.result, "utf-8"))).toEqual({ version: "v0.28.0", exitCode: 130 }); + // The trap removed the staged copy too. + expect(readdirSync(dataDir).filter((n) => n.startsWith("plannotator-update-"))).toEqual([]); } finally { server.stop(true); } }, 30_000); test("a wrapper killed outright (no trap runs) still ends with the lock released and a failed result", async () => { - const server = fakeServer('echo "fake installer started"\nsleep 3\n'); - const saved = process.env.PLANNOTATOR_INSTALL_SCRIPT_URL; - process.env.PLANNOTATOR_INSTALL_SCRIPT_URL = `http://127.0.0.1:${server.port}/install.sh`; + const { server, env: releaseEnv } = fakeRelease('echo "fake installer started"\nsleep 3\n'); + const saved = { url: process.env.PLANNOTATOR_INSTALL_SCRIPT_URL, dl: process.env.PLANNOTATOR_RELEASE_DOWNLOAD_URL, tmp: process.env.TMPDIR }; + process.env.TMPDIR = dataDir; + process.env.PLANNOTATOR_INSTALL_SCRIPT_URL = releaseEnv.PLANNOTATOR_INSTALL_SCRIPT_URL; + process.env.PLANNOTATOR_RELEASE_DOWNLOAD_URL = releaseEnv.PLANNOTATOR_RELEASE_DOWNLOAD_URL; const paths = autoUpdatePaths(dataDir); writeFileSync(paths.lock, "1"); try { let out = ""; const code = await runInstallerForeground( - { version: "v0.28.0", logPath: paths.log, resultPath: paths.result, lockPath: paths.lock, platform: "linux", flags: [] }, + { version: "v0.28.0", logPath: paths.log, resultPath: paths.result, lockPath: paths.lock, platform: "linux", flags: [], prepare: PREPARE }, (chunk) => (out += chunk), { onSpawn: (child) => { @@ -575,9 +711,20 @@ describe.skipIf(process.platform === "win32" || !Bun.which("curl"))("interrupted expect(code).toBe(-1); expect(existsSync(paths.lock)).toBe(false); expect(JSON.parse(readFileSync(paths.result, "utf-8")).exitCode).toBe(-1); + // No trap ran, so the staged copy is left; the next update's sweep + // removes it once it is as old as a stale lock, never sooner. + const staged = () => readdirSync(dataDir).filter((n) => n.startsWith("plannotator-update-")); + expect(staged()).toHaveLength(1); + expect(sweepStaleUpdateDirs(dataDir, Date.now())).toBe(0); + expect(sweepStaleUpdateDirs(dataDir, Date.now() + AUTO_UPDATE_LOCK_STALE_MS + 1000)).toBe(1); + expect(staged()).toEqual([]); } finally { - if (saved === undefined) delete process.env.PLANNOTATOR_INSTALL_SCRIPT_URL; - else process.env.PLANNOTATOR_INSTALL_SCRIPT_URL = saved; + if (saved.tmp === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = saved.tmp; + if (saved.url === undefined) delete process.env.PLANNOTATOR_INSTALL_SCRIPT_URL; + else process.env.PLANNOTATOR_INSTALL_SCRIPT_URL = saved.url; + if (saved.dl === undefined) delete process.env.PLANNOTATOR_RELEASE_DOWNLOAD_URL; + else process.env.PLANNOTATOR_RELEASE_DOWNLOAD_URL = saved.dl; server.stop(true); } }, 30_000); diff --git a/packages/server/auto-update.ts b/packages/server/auto-update.ts index b3e408e02e..a3cecaaaf3 100644 --- a/packages/server/auto-update.ts +++ b/packages/server/auto-update.ts @@ -93,6 +93,17 @@ export { } from "@plannotator/shared/update-check"; export const AUTO_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000; +/** + * The wrapper's exit code when the running binary could not download the + * install script or the checksums (update-prepare.ts): nothing ran, nothing + * changed, try again later (sysexits EX_TEMPFAIL; curl, whose exit codes + * install.sh can pass on, never uses it). + */ +export const UPDATE_PREPARE_FAILED = 75; +/** install.sh's exit code when the binary did not match PLANNOTATOR_EXPECTED_SHA256 (the old binary is kept). */ +export const UPDATE_CHECKSUM_MISMATCH = 3; +/** Auto-update's wait before retrying a version whose download did not match: 1, 3, then 7 days. */ +export const MISMATCH_BACKOFF_DAYS: readonly number[] = [1, 3, 7]; /** A lock older than this is from a run that died without cleaning up. */ export const AUTO_UPDATE_LOCK_STALE_MS = 60 * 60 * 1000; @@ -105,12 +116,18 @@ export interface AutoUpdateResult { } export interface AutoUpdateNotice { - /** Stable per install attempt, so a client can remember it was shown. */ + /** Stable per install attempt (per version for a mismatch), so a client can remember it was shown. */ id: string; kind: "updated" | "failed"; version: string; releaseUrl: string; logPath?: string; + /** + * Why a failed install failed, when it is one of the safe-update outcomes: + * `mismatch` (the download did not match the checksum Plannotator fetched) + * or `download` (Plannotator could not download the update). Additive. + */ + reason?: "mismatch" | "download"; } export function autoUpdatePaths(dataDir: string = getPlannotatorDataDir()) { @@ -155,11 +172,54 @@ export function deriveAutoUpdateNotice( return { id, kind: "updated", version, releaseUrl: releaseUrlFor(version) }; } if (result && result.version.replace(/^v/, "") === version && result.exitCode !== 0) { - return { id, kind: "failed", version, releaseUrl: releaseUrlFor(version), logPath }; + // A background run that could not download anything changed nothing: + // update.log only, and the next window tries again. Never a notice. + if (result.exitCode === UPDATE_PREPARE_FAILED && pending.by !== "person") return undefined; + const reason = + result.exitCode === UPDATE_CHECKSUM_MISMATCH ? "mismatch" : result.exitCode === UPDATE_PREPARE_FAILED ? "download" : undefined; + return { + // A mismatch shows once per version, however many retries the back-off makes. + id: reason === "mismatch" ? `${version}@mismatch` : id, + kind: "failed", + version, + releaseUrl: releaseUrlFor(version), + logPath, + ...(reason && { reason }), + }; } return undefined; } +/** How long auto-update waits after the `attempts`-th mismatching download of one version. */ +export function mismatchBackoffMs(attempts: number): number { + const days = MISMATCH_BACKOFF_DAYS[Math.min(Math.max(attempts, 1), MISMATCH_BACKOFF_DAYS.length) - 1]!; + return days * AUTO_UPDATE_INTERVAL_MS; +} + +/** + * Count a finished install whose download did not match (exit 3) into + * `mismatchBackoff`, once per install attempt (`pending.startedAt`). Returns + * the back-off as it stands. Pure. + */ +export function noteMismatchResult(state: AutoUpdateState, result: AutoUpdateResult | null): AutoUpdateState["mismatchBackoff"] { + const backoff = state.mismatchBackoff; + if (!result || result.exitCode !== UPDATE_CHECKSUM_MISMATCH) return backoff; + const version = result.version.replace(/^v/, ""); + const pending = state.pending; + if (!pending || pending.version.replace(/^v/, "") !== version) return backoff; + if (backoff && backoff.version === version && backoff.lastAttemptAt === pending.startedAt) return backoff; + const attempts = backoff && backoff.version === version && Number.isFinite(backoff.attempts) ? backoff.attempts + 1 : 1; + return { version, attempts, lastAttemptAt: pending.startedAt }; +} + +/** Whether background auto-update holds back `latest` for now. Pure. */ +export function isMismatchBackedOff(backoff: AutoUpdateState["mismatchBackoff"], latest: string, now: number): boolean { + if (!backoff || backoff.version !== latest.replace(/^v/, "")) return false; + const at = backoff.lastAttemptAt; + if (typeof at !== "number" || !Number.isFinite(at) || at > now) return false; + return now - at < mismatchBackoffMs(backoff.attempts); +} + /** * Where the install script puts the binary. Auto-update only runs when the * running executable IS that file; a binary installed some other way @@ -265,6 +325,8 @@ export interface InstallerLaunch { platform: NodeJS.Platform; /** Validated remembered install flags (see readInstallFlags). */ flags: InstallFlagId[]; + /** Who runs `update-prepare` (POSIX). Default: this process (updatePrepareInvocation). Tests point it at a source entry. */ + prepare?: { binary: string; entry: string }; } export interface AutoUpdateDeps { @@ -299,6 +361,7 @@ export type AutoUpdateOutcome = | "up-to-date" | "sessions-open" | "locked" + | "backed-off" | "launched" | "launch-failed"; @@ -357,6 +420,19 @@ export async function runAutoUpdateCheck(deps: AutoUpdateDeps): Promise ({ ...s, mismatchBackoff: backoff }), deps.dataDir); + if (isMismatchBackedOff(backoff, latest, now)) { + log( + `v${latest} is held back until ${new Date(backoff!.lastAttemptAt + mismatchBackoffMs(backoff!.attempts)).toISOString()}: its last download did not match the checksum Plannotator fetched`, + deps.dataDir, + ); + return "backed-off"; + } + const others = otherOpenSessions(deps.listSessions(), deps.pid); if (others.length > 0) { // Do not spend the 24h install window: the next session start retries, @@ -511,35 +587,66 @@ export function startManualUpdateForThisBinary(currentVersion: string | undefine } /** - * The detached wrapper. It downloads the same script a user pipes by hand, - * runs it for exactly `version` without prompts plus the remembered install - * flags, records the exit code, and releases the lock. Inputs travel as - * environment variables or argv entries, never interpolated into the command - * text: on POSIX the flags are the wrapper's own positional parameters - * (`"$@"`). + * The detached POSIX wrapper (safe updates, step 1). It never downloads with + * the shell's curl: it asks the running Plannotator (`update-prepare`, its own + * fetch, update-prepare.ts) to download the install script and the expected + * checksums into a private temp dir, then runs that script for exactly + * `version` without prompts plus the remembered install flags, with + * PLANNOTATOR_EXPECTED_SHA256 so the script refuses a binary that does not + * match (exit 3, before it replaces anything). It records the exit code, + * removes the temp dir and releases the lock. When the download step fails + * nothing runs and the result is UPDATE_PREPARE_FAILED (75). + * + * Inputs travel as environment variables or argv entries, never interpolated + * into the command text: the flags are the wrapper's own positional + * parameters (`"$@"`). The staged dir comes from our own binary's stdout and + * is only used (or removed) when it is a single absolute path whose last part + * is plannotator-update-*. */ const POSIX_WRAPPER = ` -tmp=$(mktemp "\${TMPDIR:-/tmp}/plannotator-install.XXXXXX") || exit 1 +update_dir="" code=1 +cleanup() { + [ -n "$update_dir" ] && rm -rf "$update_dir" +} +record() { + printf '{"version":"%s","exitCode":%d}\\n' "$PLANNOTATOR_UPDATE_VERSION" "$1" > "$PLANNOTATOR_UPDATE_RESULT.tmp" && mv -f "$PLANNOTATOR_UPDATE_RESULT.tmp" "$PLANNOTATOR_UPDATE_RESULT" +} # Interrupted (Ctrl-C on plannotator update, a TERM): record 130 and release # the lock now, so the next update does not wait an hour for a stale lock. on_signal() { trap '' INT TERM HUP - rm -f "$tmp" - printf '{"version":"%s","exitCode":130}\\n' "$PLANNOTATOR_UPDATE_VERSION" > "$PLANNOTATOR_UPDATE_RESULT.tmp" && mv -f "$PLANNOTATOR_UPDATE_RESULT.tmp" "$PLANNOTATOR_UPDATE_RESULT" + cleanup + record 130 echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] update: interrupted" rm -f "$PLANNOTATOR_UPDATE_LOCK" exit 130 } trap on_signal INT TERM HUP -if curl -fsSL "$PLANNOTATOR_UPDATE_SCRIPT_URL" -o "$tmp"; then - bash "$tmp" --version "$PLANNOTATOR_UPDATE_VERSION" --non-interactive "$@" /dev/null) bash "$update_dir/install.sh" --version "$PLANNOTATOR_UPDATE_VERSION" --non-interactive "$@" "$PLANNOTATOR_UPDATE_RESULT.tmp" && mv -f "$PLANNOTATOR_UPDATE_RESULT.tmp" "$PLANNOTATOR_UPDATE_RESULT" +cleanup +record "$code" echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] update: install.sh exited $code" rm -f "$PLANNOTATOR_UPDATE_LOCK" `; @@ -621,8 +728,23 @@ export function installScriptUrl(platform: NodeJS.Platform, env: NodeJS.ProcessE return platform === "win32" ? INSTALL_PS1_URL : INSTALL_SH_URL; } +/** + * How the POSIX wrapper reaches `update-prepare`: the compiled binary is its + * own entry; a source run (`bun …/index.ts`, the process tests' entries) + * passes its script as the first argument. + */ +export function updatePrepareInvocation(main: string = typeof Bun !== "undefined" ? Bun.main : process.argv[1] ?? ""): { + binary: string; + entry: string; +} { + const normalized = main.replace(/\\/g, "/"); + const compiled = normalized.startsWith("/$bunfs/") || /^\/?[A-Za-z]:\/(?:\$bunfs|~BUN)\//i.test(normalized); + return { binary: process.execPath, entry: compiled ? "" : main }; +} + function installerEnv(launch: InstallerLaunch): { file: string; args: string[]; env: NodeJS.ProcessEnv } { const { file, args, env: commandEnv } = installerCommand(launch.platform, launch.flags); + const prepare = launch.prepare ?? updatePrepareInvocation(); return { file, args, @@ -631,7 +753,12 @@ function installerEnv(launch: InstallerLaunch): { file: string; args: string[]; PLANNOTATOR_UPDATE_VERSION: launch.version, PLANNOTATOR_UPDATE_RESULT: launch.resultPath, PLANNOTATOR_UPDATE_LOCK: launch.lockPath, + // Windows only: its wrapper still downloads install.ps1 itself (Windows + // blocks the leaked eMudhra keys in its own certificate store). PLANNOTATOR_UPDATE_SCRIPT_URL: installScriptUrl(launch.platform), + // POSIX: the binary that runs `update-prepare` (this one). + PLANNOTATOR_UPDATE_BINARY: prepare.binary, + PLANNOTATOR_UPDATE_ENTRY: prepare.entry, ...commandEnv, }, }; diff --git a/packages/server/package.json b/packages/server/package.json index eb526b2f28..1c2b95cd0e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -27,6 +27,7 @@ "./qr": "./qr.ts", "./sessions": "./sessions.ts", "./auto-update": "./auto-update.ts", + "./update-prepare": "./update-prepare.ts", "./project": "./project.ts", "./pr": "./pr.ts", "./html-assets": "./html-assets.ts", diff --git a/packages/server/update-prepare-entry.ts b/packages/server/update-prepare-entry.ts new file mode 100644 index 0000000000..c71e0e6d86 --- /dev/null +++ b/packages/server/update-prepare-entry.ts @@ -0,0 +1,9 @@ +/** + * `update-prepare` alone, for the POSIX wrapper tests: they point the + * wrapper's PLANNOTATOR_UPDATE_BINARY / PLANNOTATOR_UPDATE_ENTRY at + * `bun `, since the compiled binary is not built in the test job. + */ +import { runUpdatePrepareCommand } from "./update-prepare"; + +const argv = process.argv.slice(2); +process.exit(argv[0] === "update-prepare" ? await runUpdatePrepareCommand(argv.slice(1), { platform: process.platform }) : 2); diff --git a/packages/server/update-prepare.test.ts b/packages/server/update-prepare.test.ts new file mode 100644 index 0000000000..3faacb3de0 --- /dev/null +++ b/packages/server/update-prepare.test.ts @@ -0,0 +1,447 @@ +/** + * Safe updates, step 1: `update-prepare` (the running Plannotator downloads + * the install script and both binaries' checksums with its own fetch) and the + * POSIX update wrapper that runs it, then runs the saved script with + * PLANNOTATOR_EXPECTED_SHA256. + * + * The rule these guard: an update either installs the genuine release or + * changes nothing, and never leaves the install stuck. Offline, a refusing + * proxy, a bad `.sha256`, Ctrl-C and a kill all end with the old binary in + * place, the lock released and a result recorded. + * + * Everything is loopback or a stub: a fake release server, a stub `curl` for + * install.sh's own binary download, a temp HOME and TMPDIR. Nothing reaches + * the network and nothing downloaded is a real binary. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { autoUpdatePaths, installerCommand } from "./auto-update"; +import { + UPDATE_CHECKSUM_MISMATCH, + UPDATE_PREPARE_FAILED, + candidateAssets, + normalizeReleaseTag, + prepareUpdate, + releaseDownloadUrl, + runUpdatePrepareCommand, + sweepStaleUpdateDirs, +} from "./update-prepare"; + +const SCRIPT_HEAD = '#!/bin/bash\nREPO="backnotprop/plannotator"\n'; +const OS = process.platform === "darwin" ? "darwin" : "linux"; +const ARM = "a".repeat(64); +const X64 = "b".repeat(64); + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "plannotator-prep-test-")); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +const staged = (dir: string = root) => readdirSync(dir).filter((n) => n.startsWith("plannotator-update-")); + +type Route = (path: string) => Response | Promise; +/** A fetch over a route table, as the release server would answer. */ +function fakeFetch(route: Route): typeof fetch { + return (async (input: RequestInfo | URL) => route(new URL(String(input)).pathname)) as typeof fetch; +} +const genuine: Route = (path) => { + if (path === "/install.sh") return new Response(`${SCRIPT_HEAD}echo ok\n# PLANNOTATOR_EXPECTED_SHA256\n`); + const m = /\/v1\.2\.3\/(plannotator-[a-z]+-(arm64|x64))\.sha256$/.exec(path); + if (m) return new Response(`${m[2] === "arm64" ? ARM : X64.toUpperCase()} ${m[1]}\n`); + return new Response("not found", { status: 404 }); +}; +const ENV = { PLANNOTATOR_INSTALL_SCRIPT_URL: "http://127.0.0.1:9/install.sh", PLANNOTATOR_RELEASE_DOWNLOAD_URL: "http://127.0.0.1:9/dl" }; +const prepare = (route: Route, version = "1.2.3", platform: NodeJS.Platform = "linux") => + prepareUpdate({ version, platform, env: ENV, tmpRoot: root, fetchImpl: fakeFetch(route) }); + +describe("prepareUpdate", () => { + test("stages the script and both CPU types' checksums in a private dir", async () => { + const r = await prepare(genuine); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(readFileSync(join(r.dir, "expected-sha256"), "utf-8")).toBe(`plannotator-linux-arm64=${ARM} plannotator-linux-x64=${X64}`); + expect(readFileSync(join(r.dir, "install.sh"), "utf-8")).toStartWith(SCRIPT_HEAD); + expect(statSync(r.dir).mode & 0o777).toBe(0o700); + expect(statSync(join(r.dir, "install.sh")).mode & 0o777).toBe(0o600); + expect(statSync(join(r.dir, "expected-sha256")).mode & 0o777).toBe(0o600); + expect(r.scriptChecksPin).toBe(true); + }); + + test("a version with or without the v reaches the same tag; anything else is refused", async () => { + expect((await prepare(genuine, "v1.2.3")).ok).toBe(true); + expect(normalizeReleaseTag("01.2.3")).toBe("v1.2.3"); + for (const bad of ["1.2.3-rc.1", "1.2", "latest", "1.2.3; rm -rf /", "../1.2.3"]) { + expect(normalizeReleaseTag(bad)).toBeNull(); + expect(await prepare(genuine, bad)).toMatchObject({ ok: false }); + } + }); + + test("the install script picks the CPU type itself, so both are pinned; macOS asks for the darwin pair", () => { + expect(candidateAssets("darwin")).toEqual(["plannotator-darwin-arm64", "plannotator-darwin-x64"]); + expect(candidateAssets("linux")).toEqual(["plannotator-linux-arm64", "plannotator-linux-x64"]); + }); + + // Never stuck: each of these is "couldn't download", nothing is staged, and the caller retries later. + const failures: Array<[string, Route]> = [ + ["offline", () => Promise.reject(new TypeError("Unable to connect. Is the computer able to access the url?"))], + ["a refusing proxy, as Bun 1.3.14 reports it (a 502 response)", () => new Response("Bad Gateway", { status: 502 })], + ["a refusing proxy, as Bun 1.4.4 reports it (a thrown error)", () => Promise.reject(new Error("ConnectionRefused"))], + ["a missing .sha256", (p) => (p.endsWith(".sha256") ? new Response("", { status: 404 }) : genuine(p))], + ["a .sha256 that is a web page", (p) => (p.endsWith(".sha256") ? new Response("sign in to the wifi") : genuine(p))], + ["a short .sha256", (p) => (p.endsWith(".sha256") ? new Response("abc123 plannotator\n") : genuine(p))], + ["only one CPU type's .sha256", (p) => (p.endsWith("x64.sha256") ? new Response("", { status: 404 }) : genuine(p))], + ["a captive portal instead of the script", (p) => (p === "/install.sh" ? new Response("login") : genuine(p))], + ["a script that is not Plannotator's", (p) => (p === "/install.sh" ? new Response("#!/bin/bash\necho hi\n") : genuine(p))], + ["an oversized script", (p) => (p === "/install.sh" ? new Response(SCRIPT_HEAD + "#".repeat(5 * 1024 * 1024)) : genuine(p))], + ]; + for (const [name, route] of failures) { + test(`${name}: fails, stages nothing`, async () => { + const r = await prepare(route); + expect(r.ok).toBe(false); + expect(staged()).toEqual([]); + }); + } + + test("a stalled server times out instead of hanging the update", async () => { + const hang: typeof fetch = ((_: unknown, init?: RequestInit) => + new Promise((_r, reject) => init?.signal?.addEventListener("abort", () => reject(new Error("timed out"))))) as typeof fetch; + const r = await prepareUpdate({ version: "1.2.3", platform: "linux", env: ENV, tmpRoot: root, fetchImpl: hang, timeoutMs: 50 }); + expect(r).toMatchObject({ ok: false }); + }); + + test("the download base can only move to https or loopback", () => { + expect(releaseDownloadUrl({ PLANNOTATOR_RELEASE_DOWNLOAD_URL: "http://127.0.0.1:5/dl/" })).toBe("http://127.0.0.1:5/dl"); + expect(releaseDownloadUrl({ PLANNOTATOR_RELEASE_DOWNLOAD_URL: "https://mirror.example/dl" })).toBe("https://mirror.example/dl"); + for (const bad of ["http://evil.example/dl", "file:///tmp/dl", "https://u:p@mirror.example/dl"]) { + expect(releaseDownloadUrl({ PLANNOTATOR_RELEASE_DOWNLOAD_URL: bad })).toBe("https://github.com/backnotprop/plannotator/releases/download"); + } + }); + + test("Windows never comes here (its wrapper is unchanged)", async () => { + expect(await prepare(genuine, "1.2.3", "win32")).toMatchObject({ ok: false }); + }); +}); + +describe("update-prepare command", () => { + test("prints only the staged dir on stdout", async () => { + let out = ""; + let err = ""; + const code = await runUpdatePrepareCommand(["1.2.3"], { + platform: "linux", + env: ENV, + tmpRoot: root, + fetchImpl: fakeFetch(genuine), + out: (t) => (out += t), + err: (t) => (err += t), + }); + expect(code).toBe(0); + expect(out).toBe(`${join(root, staged()[0]!)}\n`); + expect(err).toBe(""); + }); + + test("a failure exits 75 with the reason on stderr and nothing on stdout", async () => { + let out = ""; + let err = ""; + const code = await runUpdatePrepareCommand(["1.2.3"], { + platform: "linux", + env: ENV, + tmpRoot: root, + fetchImpl: fakeFetch(() => Promise.reject(new Error("offline"))), + out: (t) => (out += t), + err: (t) => (err += t), + }); + expect(code).toBe(UPDATE_PREPARE_FAILED); + expect(out).toBe(""); + expect(err).toContain("could not download the update"); + expect(err).toContain("nothing was changed"); + }); + + test("an older install script (no pin check) still runs, with a note in the log", async () => { + let err = ""; + const old: Route = (p) => (p === "/install.sh" ? new Response(`${SCRIPT_HEAD}echo old\n`) : genuine(p)); + const code = await runUpdatePrepareCommand(["1.2.3"], { platform: "linux", env: ENV, tmpRoot: root, fetchImpl: fakeFetch(old), out: () => {}, err: (t) => (err += t) }); + expect(code).toBe(0); + expect(err).toContain("does not check the pinned checksum yet"); + }); + + test("leftovers of a killed run are swept once as old as a stale lock, never a live run's", () => { + const old = join(root, "plannotator-update-old"); + const fresh = join(root, "plannotator-update-fresh"); + const other = join(root, "something-else"); + for (const d of [old, fresh, other]) mkdirSync(d); + const hourAgo = (Date.now() - 2 * 60 * 60 * 1000) / 1000; + utimesSync(old, hourAgo, hourAgo); + utimesSync(other, hourAgo, hourAgo); + expect(sweepStaleUpdateDirs(root)).toBe(1); + expect(existsSync(old)).toBe(false); + expect(existsSync(fresh)).toBe(true); + expect(existsSync(other)).toBe(true); + }); +}); + +// ─────────────── The POSIX wrapper, end to end ─────────────── + +const ENTRY = resolve(import.meta.dir, "update-prepare-entry.ts"); +const INSTALL_SH = resolve(import.meta.dir, "../../scripts/install.sh"); + +interface Release { + /** The install script served at /install.sh. */ + script: string; + /** The .sha256 body per asset name; absent = 404. */ + sums: Record; +} + +function serve(release: Release) { + const hits: string[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(req) { + const path = new URL(req.url).pathname; + hits.push(path); + if (path === "/install.sh") return new Response(release.script); + const m = /^\/dl\/v\d+\.\d+\.\d+\/(.+)\.sha256$/.exec(path); + if (m && release.sums[m[1]!] !== undefined) return new Response(release.sums[m[1]!]); + return new Response("not found", { status: 404 }); + }, + }); + return { server, hits }; +} + +/** + * A sandbox for one wrapper run: HOME, TMPDIR, a data dir, and stub-bin with + * a `curl` that serves the binary install.sh downloads (and logs every call, + * so a test can prove the wrapper itself never used curl), plus a `git` that + * fails so the skills checkout never reaches the network. + */ +function sandbox(binaryServed: string) { + const home = join(root, "home"); + const tmp = join(root, "tmp"); + const data = join(root, "data"); + const stub = join(root, "stub-bin"); + for (const d of [home, tmp, data, stub]) mkdirSync(d, { recursive: true }); + const binarySource = join(root, "served-binary"); + writeFileSync(binarySource, binaryServed); + const curlLog = join(root, "curl.log"); + writeFileSync( + join(stub, "curl"), + `#!/bin/bash +printf '%s\\n' "$*" >> "${curlLog}" +out=""; url=""; prev="" +for a in "$@"; do + if [ "$prev" = "-o" ]; then out="$a"; fi + case "$a" in http*) url="$a" ;; esac + prev="$a" +done +case "$url" in + *.sha256) printf '%s plannotator\\n' "$(shasum -a 256 "${binarySource}" 2>/dev/null | cut -d' ' -f1 || sha256sum "${binarySource}" | cut -d' ' -f1)" ;; + */releases/download/*) cat "${binarySource}" > "$out" ;; + *) exit 22 ;; +esac +`, + { mode: 0o755 }, + ); + writeFileSync(join(stub, "git"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + writeFileSync(join(stub, "gh"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const installed = join(home, ".local", "bin", "plannotator"); + mkdirSync(join(home, ".local", "bin"), { recursive: true }); + writeFileSync(installed, "#!/bin/sh\necho plannotator-old\n", { mode: 0o755 }); + return { home, tmp, data, stub, installed, curlLog }; +} + +type Box = ReturnType; + +function wrapperEnv(box: Box, port: number, extra: Record = {}) { + const paths = autoUpdatePaths(box.data); + return { + paths, + env: { + PATH: `${box.stub}:/usr/bin:/bin`, + HOME: box.home, + TMPDIR: box.tmp, + PLANNOTATOR_DATA_DIR: box.data, + PLANNOTATOR_UPDATE_CHECK: "0", + PLANNOTATOR_SKIP_SEM_INSTALL: "1", + PLANNOTATOR_SKIP_AGENT_TERMINAL_INSTALL: "1", + PLANNOTATOR_UPDATE_VERSION: "v1.2.3", + PLANNOTATOR_UPDATE_RESULT: paths.result, + PLANNOTATOR_UPDATE_LOCK: paths.lock, + PLANNOTATOR_UPDATE_BINARY: process.execPath, + PLANNOTATOR_UPDATE_ENTRY: ENTRY, + PLANNOTATOR_INSTALL_SCRIPT_URL: `http://127.0.0.1:${port}/install.sh`, + PLANNOTATOR_RELEASE_DOWNLOAD_URL: `http://127.0.0.1:${port}/dl`, + ...extra, + }, + }; +} + +async function runWrapper(box: Box, port: number, extra: Record = {}) { + const { paths, env } = wrapperEnv(box, port, extra); + writeFileSync(paths.lock, "1"); + const { file, args } = installerCommand("linux", ["skip-skills"]); + const proc = Bun.spawn([file, ...args], { env, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); + const result = existsSync(paths.result) ? (JSON.parse(readFileSync(paths.result, "utf-8")) as { version: string; exitCode: number }) : null; + return { out: stdout + stderr, exitCode, result, lockHeld: existsSync(paths.lock) }; +} + +const sha = (s: string) => createHash("sha256").update(s).digest("hex"); +const pins = (arm: string, x64: string) => ({ [`plannotator-${OS}-arm64`]: `${arm} x\n`, [`plannotator-${OS}-x64`]: `${x64} x\n` }); + +describe.skipIf(process.platform === "win32")("POSIX update wrapper (safe updates)", () => { + test("never-stuck: offline, nothing runs; 75 recorded, lock released, nothing staged, binary untouched", async () => { + const box = sandbox("new"); + const { server } = serve({ script: "", sums: {} }); + const port = server.port; + server.stop(true); + const r = await runWrapper(box, port!); + expect(r.result).toEqual({ version: "v1.2.3", exitCode: UPDATE_PREPARE_FAILED }); + expect(r.lockHeld).toBe(false); + expect(staged(box.tmp)).toEqual([]); + expect(readFileSync(box.installed, "utf-8")).toContain("plannotator-old"); + expect(existsSync(box.curlLog)).toBe(false); + }, 30_000); + + test("never-stuck: a proxy that refuses every connection is a failed download, not a hang", async () => { + const box = sandbox("new"); + const closed = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("") }); + const proxyPort = closed.port!; + closed.stop(true); + const r = await runWrapper(box, 9, { + // .invalid never resolves, so even a Bun that ignored the proxy reaches nothing. + PLANNOTATOR_INSTALL_SCRIPT_URL: "https://plannotator.invalid/install.sh", + PLANNOTATOR_RELEASE_DOWNLOAD_URL: "https://plannotator.invalid/dl", + HTTPS_PROXY: `http://127.0.0.1:${proxyPort}`, + https_proxy: `http://127.0.0.1:${proxyPort}`, + }); + expect(r.result?.exitCode).toBe(UPDATE_PREPARE_FAILED); + expect(r.lockHeld).toBe(false); + expect(readFileSync(box.installed, "utf-8")).toContain("plannotator-old"); + }, 60_000); + + test("never-stuck: a bad .sha256 means the script never runs", async () => { + const box = sandbox("new"); + const marker = join(box.home, "script-ran"); + const { server } = serve({ script: `${SCRIPT_HEAD}touch "${marker}"\n`, sums: pins("not-a-checksum", X64) }); + try { + const r = await runWrapper(box, server.port!); + expect(r.result?.exitCode).toBe(UPDATE_PREPARE_FAILED); + expect(existsSync(marker)).toBe(false); + expect(r.lockHeld).toBe(false); + expect(staged(box.tmp)).toEqual([]); + } finally { + server.stop(true); + } + }, 30_000); + + test("tamper, through the real install.sh: the network swaps the binary and its .sha256; exit 3, old binary kept", async () => { + // The attacker controls install.sh's own curl (the stub serves an altered + // binary with a matching .sha256), but not Plannotator's fetch, which got + // the genuine checksums. + const box = sandbox("#!/bin/sh\necho evil\n"); + const genuineSha = sha("#!/bin/sh\necho genuine\n"); + const { server, hits } = serve({ script: readFileSync(INSTALL_SH, "utf-8"), sums: pins(genuineSha, genuineSha) }); + try { + const r = await runWrapper(box, server.port!); + expect(r.result).toEqual({ version: "v1.2.3", exitCode: UPDATE_CHECKSUM_MISMATCH }); + expect(r.out).toContain("Stopped: the v1.2.3 download doesn't match the checksum Plannotator fetched for it."); + expect(readFileSync(box.installed, "utf-8")).toContain("plannotator-old"); + expect(r.lockHeld).toBe(false); + expect(staged(box.tmp)).toEqual([]); + // The script came from Plannotator's fetch, not curl. + expect(hits).toContain("/install.sh"); + expect(readFileSync(box.curlLog, "utf-8")).not.toContain("install.sh"); + } finally { + server.stop(true); + } + }, 60_000); + + test("the genuine release, through the real install.sh: installed, checksum confirmed, the staged copy removed", async () => { + const binary = "#!/bin/sh\necho plannotator-new\n"; + const box = sandbox(binary); + const { server } = serve({ script: readFileSync(INSTALL_SH, "utf-8"), sums: pins(sha(binary), sha(binary)) }); + try { + const r = await runWrapper(box, server.port!); + expect(r.out).toContain("Checksum matches the one Plannotator fetched for v1.2.3."); + expect(readFileSync(box.installed, "utf-8")).toBe(binary); + expect(r.result).toEqual({ version: "v1.2.3", exitCode: 0 }); + expect(r.lockHeld).toBe(false); + expect(staged(box.tmp)).toEqual([]); + // install.sh did not fetch the .sha256 again over its own connection. + expect(readFileSync(box.curlLog, "utf-8")).not.toContain(".sha256"); + } finally { + server.stop(true); + } + }, 120_000); + + test("compatibility: an older install.sh (no pin check) runs exactly as before and its exit code is recorded", async () => { + const box = sandbox("new"); + const argsFile = join(box.home, "args"); + const { server } = serve({ script: `${SCRIPT_HEAD}printf '%s\\n' "$@" > "${argsFile}"\nexit 7\n`, sums: pins(ARM, X64) }); + try { + const r = await runWrapper(box, server.port!); + expect(readFileSync(argsFile, "utf-8").trim().split("\n")).toEqual(["--version", "v1.2.3", "--non-interactive", "--skip-skills"]); + expect(r.result).toEqual({ version: "v1.2.3", exitCode: 7 }); + expect(r.out).toContain("does not check the pinned checksum yet"); + expect(r.lockHeld).toBe(false); + } finally { + server.stop(true); + } + }, 30_000); + + test("Ctrl-C mid-install: 130 recorded, lock released, the staged copy removed", async () => { + const box = sandbox("new"); + const { server } = serve({ script: `${SCRIPT_HEAD}echo "fake installer started"\nsleep 30\n`, sums: pins(ARM, X64) }); + try { + const { paths, env } = wrapperEnv(box, server.port!); + writeFileSync(paths.lock, "1"); + const { file, args } = installerCommand("linux", []); + const child = spawn(file, args, { detached: true, stdio: ["ignore", "pipe", "pipe"], env }); + let out = ""; + child.stdout!.on("data", (c) => (out += c)); + const exited = new Promise((r) => child.on("close", (code) => r(code))); + const deadline = Date.now() + 20_000; + while (!out.includes("fake installer started") && Date.now() < deadline) await Bun.sleep(20); + expect(staged(box.tmp)).toHaveLength(1); + process.kill(-child.pid!, "SIGINT"); + expect(await exited).toBe(130); + expect(existsSync(paths.lock)).toBe(false); + expect(JSON.parse(readFileSync(paths.result, "utf-8"))).toEqual({ version: "v1.2.3", exitCode: 130 }); + expect(staged(box.tmp)).toEqual([]); + } finally { + server.stop(true); + } + }, 30_000); + + // The wrapper removes the dir `update-prepare` printed: it must only ever + // remove a plannotator-update-* path, whatever that output is. + for (const [name, printed] of [ + ["the temp root itself", "$TMPDIR"], + ["a dir below one named plannotator-update-*", "$TMPDIR/plannotator-update-x/inner"], + ["a path without the prefix", "$HOME"], + ["two lines", "$TMPDIR/plannotator-update-x\n$HOME"], + ["a relative path", "plannotator-update-x"], + ] as const) { + test(`a prepare step that prints ${name} runs nothing and removes nothing`, async () => { + const box = sandbox("new"); + const keep = join(box.home, "keep-me"); + writeFileSync(keep, "x"); + writeFileSync(join(box.tmp, "keep-me"), "x"); + mkdirSync(join(box.tmp, "plannotator-update-x", "inner"), { recursive: true }); + writeFileSync(join(box.tmp, "plannotator-update-x", "inner", "install.sh"), "x"); + const fakePrepare = join(box.stub, "fake-prepare"); + writeFileSync(fakePrepare, `#!/bin/sh\nprintf '%s\\n' "${printed}"\n`, { mode: 0o755 }); + const r = await runWrapper(box, 9, { PLANNOTATOR_UPDATE_BINARY: fakePrepare, PLANNOTATOR_UPDATE_ENTRY: "" }); + expect(r.result?.exitCode).toBe(UPDATE_PREPARE_FAILED); + expect(existsSync(keep)).toBe(true); + expect(existsSync(join(box.tmp, "keep-me"))).toBe(true); + expect(existsSync(join(box.tmp, "plannotator-update-x", "inner", "install.sh"))).toBe(true); + expect(r.lockHeld).toBe(false); + }, 30_000); + } +}); diff --git a/packages/server/update-prepare.ts b/packages/server/update-prepare.ts new file mode 100644 index 0000000000..61f45424c8 --- /dev/null +++ b/packages/server/update-prepare.ts @@ -0,0 +1,197 @@ +/** + * Safe updates, step 1 (owner-approved, .product/approved/safe-updates-step1/). + * + * Why: the update wrapper used to download install.sh with the system `curl`, + * and on macOS that curl still trusts the root certificates whose keys leaked + * in the eMudhra incident, so anyone holding them could serve a different + * install script or binary. This module is the part the running Plannotator + * does with its OWN fetch (Bun 1.4.4 blocks those keys) before anything runs: + * + * 1. download the install script (https://plannotator.ai/install.sh, or the + * PLANNOTATOR_INSTALL_SCRIPT_URL test/mirror knob) and the `.sha256` of + * both binaries this OS's install script may pick (it picks the CPU type + * itself, with `uname -m`, which can differ from this process's arch + * under Rosetta); + * 2. write the script and `expected-sha256` into a private temp dir (0700, + * files 0600) and print that dir. + * + * The POSIX update wrapper (auto-update.ts) runs it as `plannotator + * update-prepare `, then runs the saved script with + * PLANNOTATOR_EXPECTED_SHA256, which install.sh compares its own download + * against before it replaces anything (exit 3 on a mismatch, the old binary + * kept). Any failure here means nothing runs and nothing changes: exit + * UPDATE_PREPARE_FAILED, an `update:` line on stderr (update.log), and a + * later retry. There is never a fallback to downloading the script with curl. + * + * Windows is unchanged: Windows blocks the leaked keys in its own store, which + * the PowerShell wrapper's Invoke-WebRequest uses. + */ + +import { chmodSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isAllowedOverrideUrl } from "@plannotator/shared/update-check"; +import { AUTO_UPDATE_LOCK_STALE_MS, UPDATE_PREPARE_FAILED, installScriptUrl } from "./auto-update"; + +export { UPDATE_CHECKSUM_MISMATCH, UPDATE_PREPARE_FAILED } from "./auto-update"; + +export const UPDATE_DIR_PREFIX = "plannotator-update-"; +export const UPDATE_PREPARE_TIMEOUT_MS = 30_000; +/** A script larger than this is not ours (install.sh is about 125 KB). */ +const MAX_SCRIPT_BYTES = 4 * 1024 * 1024; +/** Present in every install.sh: a page that lacks it (a proxy's error page) is never run. */ +const SCRIPT_MARKER = 'REPO="backnotprop/plannotator"'; +const DEFAULT_RELEASE_DOWNLOAD_URL = "https://github.com/backnotprop/plannotator/releases/download"; + +/** "0.28.33", "v0.28.33" -> "v0.28.33"; anything else (a pre-release, junk) -> null. */ +export function normalizeReleaseTag(version: string): string | null { + const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(version.trim()); + return m ? `v${Number(m[1])}.${Number(m[2])}.${Number(m[3])}` : null; +} + +/** The binaries this OS's install script may download: it picks the CPU type itself. */ +export function candidateAssets(platform: NodeJS.Platform): string[] { + const os = platform === "darwin" ? "darwin" : "linux"; + return [`plannotator-${os}-arm64`, `plannotator-${os}-x64`]; +} + +/** Where release assets download from: PLANNOTATOR_RELEASE_DOWNLOAD_URL (`https:` or loopback `http:`), else GitHub. */ +export function releaseDownloadUrl(env: NodeJS.ProcessEnv = process.env): string { + const raw = env.PLANNOTATOR_RELEASE_DOWNLOAD_URL?.trim().replace(/\/+$/, ""); + return raw && isAllowedOverrideUrl(raw) ? raw : DEFAULT_RELEASE_DOWNLOAD_URL; +} + +export type PrepareResult = + | { ok: true; dir: string; expected: string; scriptChecksPin: boolean } + | { ok: false; error: string }; + +export interface PrepareDeps { + version: string; + platform: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + tmpRoot?: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; +} + +async function fetchText(url: string, f: typeof fetch, timeoutMs: number): Promise<{ ok: true; text: string } | { ok: false; error: string }> { + let host = url; + try { + host = new URL(url).host; + } catch {} + try { + const res = await f(url, { + redirect: "follow", + headers: { "user-agent": "plannotator-update" }, + signal: AbortSignal.timeout(timeoutMs), + }); + // Bun 1.3.14 hands back a refusing proxy's 502 as a response, 1.4.4 throws; both end here. + if (!res.ok) return { ok: false, error: `HTTP ${res.status} from ${host}` }; + return { ok: true, text: await res.text() }; + } catch (err) { + return { ok: false, error: `${host}: ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}` }; + } +} + +/** + * Download and stage. Never throws; on any failure nothing is left on disk. + */ +export async function prepareUpdate(deps: PrepareDeps): Promise { + const env = deps.env ?? process.env; + const f = deps.fetchImpl ?? fetch; + const timeoutMs = deps.timeoutMs ?? UPDATE_PREPARE_TIMEOUT_MS; + const tag = normalizeReleaseTag(deps.version); + if (!tag) return { ok: false, error: `not a release version: ${deps.version}` }; + if (deps.platform === "win32") return { ok: false, error: "Windows updates do not use this step" }; + + const assets = candidateAssets(deps.platform); + const base = releaseDownloadUrl(env); + const [script, ...sums] = await Promise.all([ + fetchText(installScriptUrl(deps.platform, env), f, timeoutMs), + ...assets.map((a) => fetchText(`${base}/${tag}/${a}.sha256`, f, timeoutMs)), + ]); + if (!script!.ok) return { ok: false, error: `install script: ${script!.error}` }; + const body = script!.text; + if (!body.startsWith("#!") || !body.includes(SCRIPT_MARKER) || body.length > MAX_SCRIPT_BYTES) { + return { ok: false, error: "install script: the download is not Plannotator's install script" }; + } + const pairs: string[] = []; + for (let i = 0; i < assets.length; i++) { + const r = sums[i]!; + if (!r.ok) return { ok: false, error: `${assets[i]}.sha256: ${r.error}` }; + const m = /^([0-9a-fA-F]{64})(?:\s|$)/.exec(r.text); + if (!m) return { ok: false, error: `${assets[i]}.sha256: not a SHA-256 checksum` }; + pairs.push(`${assets[i]}=${m[1]!.toLowerCase()}`); + } + + let dir: string | null = null; + try { + dir = mkdtempSync(join(deps.tmpRoot ?? tmpdir(), UPDATE_DIR_PREFIX)); + chmodSync(dir, 0o700); + writeFileSync(join(dir, "install.sh"), body, { mode: 0o600 }); + writeFileSync(join(dir, "expected-sha256"), pairs.join(" "), { mode: 0o600 }); + return { ok: true, dir, expected: pairs.join(" "), scriptChecksPin: body.includes("PLANNOTATOR_EXPECTED_SHA256") }; + } catch (err) { + if (dir) rmSync(dir, { recursive: true, force: true }); + return { ok: false, error: `could not stage the update: ${err instanceof Error ? err.message : String(err)}` }; + } +} + +/** + * The dirs a wrapper killed outright (SIGKILL, a power cut) left behind: + * removed at the start of the next update once older than the lock's own + * stale age, so a live run's dir is never touched. + */ +export function sweepStaleUpdateDirs(tmpRoot: string = tmpdir(), now: number = Date.now(), maxAgeMs: number = AUTO_UPDATE_LOCK_STALE_MS): number { + let removed = 0; + let names: string[] = []; + try { + names = readdirSync(tmpRoot); + } catch { + return 0; + } + for (const name of names) { + if (!name.startsWith(UPDATE_DIR_PREFIX)) continue; + const path = join(tmpRoot, name); + try { + if (now - statSync(path).mtimeMs >= maxAgeMs) { + rmSync(path, { recursive: true, force: true }); + removed++; + } + } catch {} + } + return removed; +} + +export interface UpdatePrepareCommandDeps extends Omit { + out?: (text: string) => void; + err?: (text: string) => void; + now?: () => number; +} + +/** + * `plannotator update-prepare ` (internal; the POSIX update wrapper + * runs it). Prints the staged dir on stdout, and nothing else; reasons go to + * stderr. Exit 0, or UPDATE_PREPARE_FAILED. + */ +export async function runUpdatePrepareCommand(args: string[], deps: UpdatePrepareCommandDeps): Promise { + const out = deps.out ?? ((t: string) => process.stdout.write(t)); + const err = deps.err ?? ((t: string) => process.stderr.write(t)); + const version = args[0]; + if (!version || args.length !== 1) { + err("update: usage: plannotator update-prepare \n"); + return UPDATE_PREPARE_FAILED; + } + sweepStaleUpdateDirs(deps.tmpRoot ?? tmpdir(), (deps.now ?? Date.now)()); + const result = await prepareUpdate({ ...deps, version }); + if (!result.ok) { + err(`update: could not download the update (${result.error}); nothing was changed\n`); + return UPDATE_PREPARE_FAILED; + } + if (!result.scriptChecksPin) { + err("update: this install script does not check the pinned checksum yet; it runs as before\n"); + } + out(`${result.dir}\n`); + return 0; +} + diff --git a/packages/shared/update-check.ts b/packages/shared/update-check.ts index deb8873104..7f83d3a779 100644 --- a/packages/shared/update-check.ts +++ b/packages/shared/update-check.ts @@ -194,6 +194,14 @@ export interface UpdateState { * Inbox writes it; nothing here fetches an image. */ releaseImagesFetch?: Record; + /** + * Safe updates: installs of `version` whose download did not match the + * checksum Plannotator fetched (install.sh exit 3), counted once each. + * Background auto-update waits 1, then 3, then 7 days (and 7 after that) + * from the last one before trying that version again; a newer release is + * tried at once, and Update now / `plannotator update` always try. + */ + mismatchBackoff?: { version: string; attempts: number; lastAttemptAt: number }; } export function updateCheckPaths(dataDir: string = getPlannotatorDataDir()) { diff --git a/packages/ui/utils/autoUpdateNotice.test.ts b/packages/ui/utils/autoUpdateNotice.test.ts index cbf2211816..e53380a226 100644 --- a/packages/ui/utils/autoUpdateNotice.test.ts +++ b/packages/ui/utils/autoUpdateNotice.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { parseAutoUpdateNotice } from './autoUpdateNotice'; +import { describeAutoUpdateNotice, parseAutoUpdateNotice } from './autoUpdateNotice'; describe('parseAutoUpdateNotice', () => { const valid = { @@ -25,3 +25,26 @@ describe('parseAutoUpdateNotice', () => { expect(parseAutoUpdateNotice({ ...valid, releaseUrl: 'https://evil.example/' })).toBeUndefined(); }); }); + +describe('safe-update failure reasons', () => { + const failed = { + id: '0.28.33@mismatch', + kind: 'failed', + version: '0.28.33', + releaseUrl: 'https://github.com/backnotprop/plannotator/releases/tag/v0.28.33', + logPath: '/x/update.log', + }; + + test('a known reason is kept; an unknown one is dropped (the generic toast)', () => { + expect(parseAutoUpdateNotice({ ...failed, reason: 'mismatch' })?.reason).toBe('mismatch'); + expect(parseAutoUpdateNotice({ ...failed, reason: 'download' })?.reason).toBe('download'); + expect(parseAutoUpdateNotice({ ...failed, reason: 'other' })?.reason).toBeUndefined(); + }); + + test('a mismatch says nothing changed and offers no install command', () => { + const text = describeAutoUpdateNotice(parseAutoUpdateNotice({ ...failed, reason: 'mismatch' })!); + expect(text.title).toBe('Update to v0.28.33 stopped'); + expect(text.description).toContain('Nothing was changed'); + expect(text.description).not.toContain('curl'); + }); +}); diff --git a/packages/ui/utils/autoUpdateNotice.ts b/packages/ui/utils/autoUpdateNotice.ts index 17134e0cd6..b778c482dc 100644 --- a/packages/ui/utils/autoUpdateNotice.ts +++ b/packages/ui/utils/autoUpdateNotice.ts @@ -17,6 +17,8 @@ export interface AutoUpdateNotice { version: string; releaseUrl: string; logPath?: string; + /** `mismatch`: the download did not match its checksum; `download`: it could not be downloaded. The old version is kept either way. */ + reason?: 'mismatch' | 'download'; } const SEEN_KEY = 'plannotator-auto-update-notice-seen'; @@ -34,6 +36,7 @@ export function parseAutoUpdateNotice(value: unknown): AutoUpdateNotice | undefi version: v.version, releaseUrl: v.releaseUrl, ...(typeof v.logPath === 'string' && { logPath: v.logPath }), + ...((v.reason === 'mismatch' || v.reason === 'download') && { reason: v.reason }), }; } @@ -52,6 +55,18 @@ export function describeAutoUpdateNotice(notice: AutoUpdateNotice): { title: str if (notice.kind === 'updated') { return { title: `Updated to v${notice.version}`, description: 'Plannotator updated itself in the background.' }; } + if (notice.reason === 'mismatch') { + return { + title: `Update to v${notice.version} stopped`, + description: "The download didn't match the checksum Plannotator fetched for it. Nothing was changed; it tries again later.", + }; + } + if (notice.reason === 'download') { + return { + title: "Couldn't download the update", + description: 'Nothing was changed. Try again later: plannotator update', + }; + } return { title: 'Auto-update failed', description: notice.logPath ? `See ${notice.logPath}` : `Could not install v${notice.version}.`, diff --git a/scripts/install.cmd b/scripts/install.cmd index 3b9d1c3118..9a874530a3 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -2010,7 +2010,6 @@ if exist "!SEM_PATH!" ( set "SEM_BASE_URL=https://github.com/!SEM_REPO!/releases/download/!SEM_VERSION!" set "SEM_ARCHIVE=%TEMP%\plannotator-sem-%RANDOM%.zip" -set "SEM_CHECKSUMS=%TEMP%\plannotator-sem-checksums-%RANDOM%.txt" set "SEM_EXTRACT=%TEMP%\plannotator-sem-%RANDOM%" mkdir "!SEM_EXTRACT!" >nul 2>&1 @@ -2022,16 +2021,10 @@ if !ERRORLEVEL! neq 0 ( goto :sem_cleanup ) -curl -fsSL --connect-timeout 10 --max-time 60 "!SEM_BASE_URL!/checksums.txt" -o "!SEM_CHECKSUMS!" -if !ERRORLEVEL! neq 0 ( - echo Skipping semantic diff sidecar install ^(checksum download failed^) - goto :sem_cleanup -) - +REM Pinned for SEM_VERSION, not read from the release's checksums.txt (same +REM connection as the archive). Keep in step with install.sh / install.ps1. set "EXPECTED_SEM_CHECKSUM=" -for /f "usebackq tokens=1,2" %%i in ("!SEM_CHECKSUMS!") do ( - if "%%j"=="!SEM_ASSET!" set "EXPECTED_SEM_CHECKSUM=%%i" -) +if /i "!SEM_ASSET!"=="sem-windows-x86_64.zip" set "EXPECTED_SEM_CHECKSUM=2a4b57f8368f0c88ded52f9ecbe0a85771008e37aa09215fc9ce750adab03d06" if not defined EXPECTED_SEM_CHECKSUM ( echo Skipping semantic diff sidecar install ^(checksum missing for !SEM_ASSET!^) goto :sem_cleanup @@ -2073,7 +2066,6 @@ if !ERRORLEVEL! equ 0 ( :sem_cleanup if exist "!SEM_ARCHIVE!" del "!SEM_ARCHIVE!" -if exist "!SEM_CHECKSUMS!" del "!SEM_CHECKSUMS!" if exist "!SEM_EXTRACT!" rmdir /s /q "!SEM_EXTRACT!" goto :eof diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 0a9a48f28c..baee180c86 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -299,13 +299,14 @@ function Install-SemSidecar { try { $semBaseUrl = "https://github.com/$semRepo/releases/download/$semVersion" $semArchive = Join-Path $tmpSemDir $semAsset - $semChecksums = Join-Path $tmpSemDir "checksums.txt" # Bounded so a slow/hung download of this optional sidecar can't wedge an # install where plannotator already landed; the catch below skips it. Invoke-WebRequest -Uri "$semBaseUrl/$semAsset" -OutFile $semArchive -UseBasicParsing -TimeoutSec 120 - Invoke-WebRequest -Uri "$semBaseUrl/checksums.txt" -OutFile $semChecksums -UseBasicParsing -TimeoutSec 60 - $expected = (Get-Content $semChecksums | Where-Object { $_ -match "\s$([regex]::Escape($semAsset))$" } | ForEach-Object { ($_ -split '\s+')[0] } | Select-Object -First 1) + # Pinned for $semVersion, not read from the release's checksums.txt + # (same connection as the archive). Keep in step with install.sh / install.cmd. + $semSha256 = @{ "sem-windows-x86_64.zip" = "2a4b57f8368f0c88ded52f9ecbe0a85771008e37aa09215fc9ce750adab03d06" } + $expected = $semSha256[$semAsset] if (-not $expected) { Write-Host "Skipping semantic diff sidecar install (checksum missing for $semAsset)" return diff --git a/scripts/install.sh b/scripts/install.sh index 212692ed2d..84d6e18eb2 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -802,7 +802,36 @@ mkdir -p "$INSTALL_DIR" tmp_file=$(mktemp) curl -fsSL -o "$tmp_file" "$binary_url" -expected_checksum=$(curl -fsSL "$checksum_url" | cut -d' ' -f1) +# Safe updates (step 1): `plannotator update`, Update now and auto-update run +# this script after the running Plannotator downloaded it, and the .sha256 of +# both binaries this OS may use, with its OWN connection (not this script's +# curl, which on macOS still trusts the leaked eMudhra root keys). They arrive +# as PLANNOTATOR_EXPECTED_SHA256="= =": both CPU types, +# because this script picks the arch itself (uname -m). A valid value for +# ${binary_name} is the expected checksum, and the .sha256 is not fetched again; +# a mismatch exits 3 before anything is replaced. Without the variable (a +# person running the script, an older Plannotator) nothing changes. +pinned_checksum="" +for _pin_pair in ${PLANNOTATOR_EXPECTED_SHA256:-}; do + case "$_pin_pair" in + "${binary_name}="*) pinned_checksum=$(printf '%s' "${_pin_pair#*=}" | tr 'ABCDEF' 'abcdef') ;; + esac +done +unset _pin_pair +case "$pinned_checksum" in + *[!0123456789abcdef]*) pinned_checksum="" ;; +esac +[ "${#pinned_checksum}" -eq 64 ] || pinned_checksum="" +if [ -n "$pinned_checksum" ]; then + expected_checksum="$pinned_checksum" +else + if [ -n "${PLANNOTATOR_EXPECTED_SHA256:-}" ]; then + # Only our own bug can cause this (the value comes from the running + # Plannotator, never the network): check as before rather than block. + echo "Note: the update carried no checksum for ${binary_name}; checking the release's .sha256 instead." + fi + expected_checksum=$(curl -fsSL "$checksum_url" | cut -d' ' -f1) +fi if [ "$(uname -s)" = "Darwin" ]; then actual_checksum=$(shasum -a 256 "$tmp_file" | cut -d' ' -f1) @@ -811,10 +840,19 @@ else fi if [ "$actual_checksum" != "$expected_checksum" ]; then - echo "Checksum verification failed!" >&2 rm -f "$tmp_file" + if [ -n "$pinned_checksum" ]; then + echo "Stopped: the ${latest_tag} download doesn't match the checksum Plannotator fetched for it." >&2 + echo "Nothing was changed; the Plannotator you have still works." >&2 + echo "Something on the network may have changed the download. Try again later, or on another network." >&2 + exit 3 + fi + echo "Checksum verification failed!" >&2 exit 1 fi +if [ -n "$pinned_checksum" ]; then + echo "Checksum matches the one Plannotator fetched for ${latest_tag}." +fi if [ "$verify_attestation" -eq 1 ]; then # $verify_attestation was resolved before the download; MIN_ATTESTED_VERSION @@ -1115,6 +1153,18 @@ if [ "$T3_HOOK_FLAG" -ne -1 ]; then fi fi +# The sem archives' SHA-256 for SEM_VERSION, pinned here instead of read from +# the release's checksums.txt, which would come over the same connection as +# the archive. Bump them with SEM_VERSION (keep install.ps1 / install.cmd in step). +sem_sha256_for_asset() { + case "$1" in + sem-darwin-arm64.tar.gz) echo "a98235ac9f8fae93364ddb7c681036fbe33af756db40e067331ef2091ecc8e6a" ;; + sem-linux-arm64.tar.gz) echo "c0d4ccede55e166481358013c397f2772e4d76b72df45d567c45792b8033aec9" ;; + sem-linux-x86_64.tar.gz) echo "d3476497d8e5648ddb4aa6f82f75ad1a21d0155a5941744c744f464109928707" ;; + *) return 1 ;; + esac +} + sem_asset_for_platform() { case "$platform" in darwin-arm64) echo "sem-darwin-arm64.tar.gz" ;; @@ -1147,7 +1197,6 @@ install_sem_sidecar() { tmp_sem_dir="$(mktemp -d)" sem_archive="${tmp_sem_dir}/${sem_asset}" - sem_checksums="${tmp_sem_dir}/checksums.txt" sem_base_url="https://github.com/${SEM_REPO}/releases/download/${SEM_VERSION}" # Bounded so a slow/hung download of this optional sidecar can't wedge an @@ -1158,13 +1207,8 @@ install_sem_sidecar() { rm -rf "$tmp_sem_dir" return 0 fi - if ! curl -fsSL --connect-timeout 10 --max-time 60 -o "$sem_checksums" "${sem_base_url}/checksums.txt"; then - echo "Skipping semantic diff sidecar install (checksum download failed)" - rm -rf "$tmp_sem_dir" - return 0 - fi - expected_sem_checksum="$(awk -v name="$sem_asset" '$2 == name { print $1 }' "$sem_checksums")" + expected_sem_checksum="$(sem_sha256_for_asset "$sem_asset" 2>/dev/null || true)" if [ -z "$expected_sem_checksum" ]; then echo "Skipping semantic diff sidecar install (checksum missing for ${sem_asset})" rm -rf "$tmp_sem_dir" diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 6d5a28525a..1c3ab6eda3 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -2465,6 +2465,105 @@ describe.skipIf(process.platform === "win32" || !Bun.which("node"))( }, ); +// Safe updates (step 1): Plannotator's own updater downloads this script and +// the .sha256 of both binaries with its own connection, and hands them over in +// PLANNOTATOR_EXPECTED_SHA256. The script must compare its download against +// that value before anything is replaced, exit 3 on a mismatch, and keep the +// installed binary. Without the variable it behaves exactly as before. +describe.skipIf(process.platform === "win32" || !Bun.which("node"))( + "install.sh PLANNOTATOR_EXPECTED_SHA256 (safe updates)", + () => { + const args = ["--version", "v99.9.9", "--non-interactive", "--skip-skills"]; + const binary = "#!/bin/sh\necho plannotator-fixture-new\n"; + const os = process.platform === "darwin" ? "darwin" : "linux"; + const machine = Bun.spawnSync(["uname", "-m"]).stdout.toString().trim(); + const arch = machine === "x86_64" || machine === "amd64" ? "x64" : "arm64"; + const mine = `plannotator-${os}-${arch}`; + const other = `plannotator-${os}-${arch === "x64" ? "arm64" : "x64"}`; + const OLD = "#!/bin/sh\necho plannotator-old\n"; + + function run(pin: string | undefined, opts: { servedChecksum?: string } = {}) { + const sandbox = setupInstallSandbox({ gh: "pass-all", binary }); + const installed = join(sandbox.home, ".local", "bin", "plannotator"); + mkdirSync(join(sandbox.home, ".local", "bin"), { recursive: true }); + writeFileSync(installed, OLD, { mode: 0o755 }); + const log = join(sandbox.home, "curl.log"); + const env: Record = { STUB_CURL_LOG: log }; + if (pin !== undefined) env.PLANNOTATOR_EXPECTED_SHA256 = pin; + if (opts.servedChecksum) env.STUB_CHECKSUM = opts.servedChecksum; + const result = runInstallSh(sandbox, args, env); + const calls = existsSync(log) ? readFileSync(log, "utf8").trim().split("\n") : []; + return { ...result, calls, installed: readFileSync(installed, "utf8"), checksum: sandbox.binaryChecksum! }; + } + const sha = (s: string) => createHash("sha256").update(s).digest("hex"); + const fetchedSha256 = (calls: string[]) => calls.some((c) => c.endsWith(".sha256")); + + test("a matching pin installs, says so, and does not fetch the .sha256 again", () => { + const r = run(`${other}=${"0".repeat(64)} ${mine}=${sha(binary)}`); + expect(r.code).toBe(0); + expect(r.out).toContain("Checksum matches the one Plannotator fetched for v99.9.9."); + expect(fetchedSha256(r.calls)).toBe(false); + expect(r.installed).toBe(binary); + }); + + test("the pin is read for this machine's CPU type only, and in any letter case", () => { + expect(run(`${mine}=${sha(binary).toUpperCase()} ${other}=${"f".repeat(64)}`).code).toBe(0); + // Only the other CPU type matches: this one is a mismatch. + const r = run(`${mine}=${"f".repeat(64)} ${other}=${sha(binary)}`); + expect(r.code).toBe(3); + }); + + test("tamper: a download that differs from the pin exits 3 and keeps the installed binary", () => { + // The network serves a .sha256 that matches the altered binary: only the pin catches it. + const r = run(`${mine}=${sha("the genuine binary")} ${other}=${sha("the genuine binary")}`, { servedChecksum: sha(binary) }); + expect(r.code).toBe(3); + expect(r.out).toContain("Stopped: the v99.9.9 download doesn't match the checksum Plannotator fetched for it."); + expect(r.out).toContain("Nothing was changed; the Plannotator you have still works."); + expect(r.out).not.toContain("curl -fsSL"); + expect(r.installed).toBe(OLD); + expect(fetchedSha256(r.calls)).toBe(false); + }); + + test("a pin with no entry for this binary, or a malformed one, falls back to the .sha256 check", () => { + for (const pin of [`${other}=${sha(binary)}`, `${mine}=nothex`, `${mine}=${"a".repeat(63)}`, "garbage"]) { + const r = run(pin); + expect(r.code).toBe(0); + expect(r.out).toContain(`the update carried no checksum for ${mine}`); + expect(fetchedSha256(r.calls)).toBe(true); + expect(r.installed).toBe(binary); + } + }); + + test("without the variable nothing changes: the .sha256 is fetched and a mismatch is exit 1, as before", () => { + const ok = run(undefined); + expect(ok.code).toBe(0); + expect(fetchedSha256(ok.calls)).toBe(true); + expect(ok.out).not.toContain("Plannotator fetched"); + const bad = run(undefined, { servedChecksum: "0".repeat(64) }); + expect(bad.code).toBe(1); + expect(bad.out).toContain("Checksum verification failed!"); + expect(bad.installed).toBe(OLD); + }); + }, +); + +describe("install scripts pin the sem sidecar's checksums (no checksums.txt fetch)", () => { + const sh = readFileSync(join(scriptsDir, "install.sh"), "utf8"); + const ps1 = readFileSync(join(scriptsDir, "install.ps1"), "utf8"); + const cmd = readFileSync(join(scriptsDir, "install.cmd"), "utf8"); + // Bumping SEM_VERSION without new pins would make every sem install skip + // ("checksum mismatch"); this fails first, naming the version to re-pin. + test("each installer pins sem v0.8.0 and no longer reads checksums.txt", () => { + for (const [name, text] of [["install.sh", sh], ["install.ps1", ps1], ["install.cmd", cmd]] as const) { + expect(`${name}:${/v0\.8\.0/.test(text)}`).toBe(`${name}:true`); + expect(`${name}:${text.includes("checksums.txt\"") || /\/checksums\.txt/.test(text)}`).toBe(`${name}:false`); + } + expect(sh).toContain("a98235ac9f8fae93364ddb7c681036fbe33af756db40e067331ef2091ecc8e6a"); + expect(ps1).toContain("2a4b57f8368f0c88ded52f9ecbe0a85771008e37aa09215fc9ce750adab03d06"); + expect(cmd).toContain("2a4b57f8368f0c88ded52f9ecbe0a85771008e37aa09215fc9ce750adab03d06"); + }); +}); + describe("is_release_tag (install.sh): only a plain vX.Y.Z tag", () => { const script = readScript("install.sh"); const fn = script.slice(script.indexOf("is_release_tag() {"), script.indexOf("\n}\n", script.indexOf("is_release_tag() {")) + 3); From eadf25eb2f44bb31c0780353f99a4c314e469173 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 26 Oct 2026 17:26:07 -0400 Subject: [PATCH 2/8] Safe updates: review fixes - update-prepare requires only this machine's CPU type's .sha256 (the one install.sh picks); the other is pinned when readable, so a release that drops one CPU type never stops updates for the other. - A checksum mismatch is install.sh exit 113 plus a marker file the wrapper names; the wrapper records 113 only with the marker and any other 113 as 1. The codes live in @plannotator/core/update-exit-codes, shared by the CLI, the Inbox failure dialog and the notice. - Tests: the letter-case pin asserts the pin path; update-prepare through the index.ts entrypoint; release.yml smoke-binaries runs scripts/smoke-update-prepare.sh on the compiled binary (update-prepare and a full plannotator update through the wrapper, loopback only). --- .github/workflows/release.yml | 13 +++ apps/hook/server/update-command.test.ts | 74 ++++++++++++++- docs/agent-guides/environment.md | 4 +- docs/agent-guides/inbox-foundation.md | 2 +- packages/core/package.json | 1 + packages/core/update-exit-codes.ts | 16 ++++ packages/inbox/components/Updates.tsx | 12 +-- packages/inbox/updates.test.tsx | 7 +- packages/server/auto-update.test.ts | 41 +++++---- packages/server/auto-update.ts | 26 ++++-- packages/server/update-prepare.test.ts | 64 +++++++++++-- packages/server/update-prepare.ts | 48 +++++++--- scripts/install.sh | 15 ++- scripts/install.test.ts | 43 +++++++-- scripts/smoke-update-prepare.sh | 117 ++++++++++++++++++++++++ 15 files changed, 407 insertions(+), 76 deletions(-) create mode 100644 packages/core/update-exit-codes.ts create mode 100755 scripts/smoke-update-prepare.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0a9669f08..3c681a129f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -503,6 +503,19 @@ jobs: ) chmod 755 "$guard_root/parent" + # Safe updates (step 1): the compiled binary's `update-prepare` (the + # update wrapper's download step) prints exactly the staged dir, and a + # full `plannotator update` reaches it through the wrapper (compiled- + # binary detection) against a loopback mirror. Temp HOME and data dir; a + # stand-in install script; nothing leaves the runner. + - name: Smoke-test update-prepare (safe updates) + if: runner.os != 'Windows' + env: + BINARY: artifacts/${{ matrix.binary }} + run: | + chmod +x "$BINARY" + bash scripts/smoke-update-prepare.sh "$BINARY" + - name: Smoke-test binary if: runner.os == 'Windows' shell: pwsh diff --git a/apps/hook/server/update-command.test.ts b/apps/hook/server/update-command.test.ts index 5193c17e91..49e303852d 100644 --- a/apps/hook/server/update-command.test.ts +++ b/apps/hook/server/update-command.test.ts @@ -10,6 +10,7 @@ import { spawn } from "node:child_process"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { runUpdateCommand, type UpdateCommandDeps } from "./update-command"; +import { UPDATE_EXIT_CHECKSUM_MISMATCH } from "@plannotator/core/update-exit-codes"; import type { FetchReleaseResult } from "@plannotator/shared/update-check"; let dataDir: string; @@ -103,11 +104,11 @@ describe("plannotator update (seams)", () => { expect(err).toContain("Or run the install script yourself: curl -fsSL"); }); - // Safe updates: after a mismatch (install.sh exit 3) or a failed download + // Safe updates: after a mismatch (install.sh's pin check) or a failed download // (the wrapper's 75) the old version is kept, and the `curl | bash` line is // never offered: it would go through the connection that just failed. test("a download that didn't match its checksum: the old version still works, no install command", async () => { - expect(await run([], { runInstaller: async () => 3 })).toBe(1); + expect(await run([], { runInstaller: async () => UPDATE_EXIT_CHECKSUM_MISMATCH })).toBe(1); expect(err).toContain("The update to 0.28.11 stopped: the download didn't match the checksum Plannotator fetched for it."); expect(err).toContain("0.28.10 is still installed and works. Try again later, or on another network: plannotator update"); expect(err).toContain("update.log"); @@ -227,9 +228,12 @@ describe.skipIf(process.platform === "win32")("plannotator update (process)", () expect(readFileSync(join(dataDir, "update.log"), "utf-8")).toContain("fake installer ran"); expect(apiHits).toBe(1); // Safe updates: the script ran from the copy Plannotator downloaded, with - // both CPU types' checksums, and the staged copy is gone afterwards. + // the checksums it fetched (this machine's CPU type first), and the staged + // copy is gone afterwards. const os = process.platform === "darwin" ? "darwin" : "linux"; - expect(readFileSync(join(home, "installer-pins"), "utf-8")).toBe(`plannotator-${os}-arm64=${"a".repeat(64)} plannotator-${os}-x64=${"b".repeat(64)}`); + const pins = readFileSync(join(home, "installer-pins"), "utf-8").split(" "); + expect(pins[0]).toStartWith(`plannotator-${os}-${process.arch === "x64" ? "x64" : "arm64"}=`); + expect(pins.toSorted()).toEqual([`plannotator-${os}-arm64=${"a".repeat(64)}`, `plannotator-${os}-x64=${"b".repeat(64)}`]); expect(readdirSync(home).filter((n) => n.startsWith("plannotator-update-"))).toEqual([]); }, 60_000); @@ -292,3 +296,65 @@ describe.skipIf(process.platform === "win32")("plannotator update (process)", () expect(existsSync(join(home, "installer-args"))).toBe(false); }, 60_000); }); + +// The update wrapper calls ` update-prepare ` and trusts its +// stdout to be exactly the staged dir. This runs it through the real CLI +// entrypoint (index.ts, from source), so losing the dispatch, or any stdout +// printed before it, fails here. (The compiled binary is checked the same way +// in release.yml's smoke-binaries job.) index.ts imports the built HTML at +// load, so missing pages are stubbed as in cli.test.ts. +describe.skipIf(process.platform === "win32")("update-prepare through the CLI entrypoint", () => { + const entry = resolve(import.meta.dir, "index.ts"); + + test("prints exactly the staged dir; a failed download prints nothing and exits 75", async () => { + const { removeStubbedHtml, stubHookDistHtml } = await import("../../../tests/helpers/hook-dist-stub"); + const stubbed = stubHookDistHtml(); + const work = mkdtempSync(join(tmpdir(), "plannotator-prepare-entry-")); + let sumsUp = true; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(req) { + const path = new URL(req.url).pathname; + if (path === "/install.sh") return new Response('#!/bin/bash\nREPO="backnotprop/plannotator"\n# PLANNOTATOR_EXPECTED_SHA256\n'); + if (sumsUp && path.endsWith(".sha256")) return new Response(`${"d".repeat(64)} x\n`); + return new Response("not found", { status: 404 }); + }, + }); + try { + const run = async () => { + const proc = Bun.spawn(["bun", "run", entry, "update-prepare", "v9.9.9"], { + cwd: work, + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + HOME: work, + TMPDIR: work, + PLANNOTATOR_DATA_DIR: join(work, "data"), + PLANNOTATOR_UPDATE_CHECK: "0", + PLANNOTATOR_INSTALL_SCRIPT_URL: `http://127.0.0.1:${server.port}/install.sh`, + PLANNOTATOR_RELEASE_DOWNLOAD_URL: `http://127.0.0.1:${server.port}/dl`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); + return { stdout, stderr, code }; + }; + const ok = await run(); + expect(ok.code, ok.stderr).toBe(0); + expect(ok.stdout).toMatch(/^\/[^\n]*\/plannotator-update-[^/\n]+\n$/); + const dir = ok.stdout.trim(); + expect(realpathSync(join(dir, ".."))).toBe(realpathSync(work)); + expect(readFileSync(join(dir, "expected-sha256"), "utf-8")).toContain(`=${"d".repeat(64)}`); + sumsUp = false; + const bad = await run(); + expect(bad.code).toBe(75); + expect(bad.stdout).toBe(""); + expect(bad.stderr).toContain("could not download the update"); + } finally { + server.stop(true); + rmSync(work, { recursive: true, force: true }); + removeStubbedHtml(stubbed); + } + }, 60_000); +}); diff --git a/docs/agent-guides/environment.md b/docs/agent-guides/environment.md index b38c741934..323a530004 100644 --- a/docs/agent-guides/environment.md +++ b/docs/agent-guides/environment.md @@ -41,7 +41,7 @@ | `PLANNOTATOR_BITBUCKET_API_URL` | Testing / proxy knob: base URL of the Bitbucket Cloud REST API (default `https://api.bitbucket.org/2.0`). The fake API in `tests/test-fixtures/bitbucket/fake-bitbucket.ts` is reached through it. Only `https:` or loopback `http:` is accepted (credentials are sent there); anything else warns once and uses the default. Redirects (the PR `/diff` and `/diffstat` endpoints answer with one) and pagination `next` links are followed only within this origin. | | `PLANNOTATOR_GUIDE_HISTORY` | Set to `0` / `false` to disable persisting successful Guided Reviews (no guide copies are written to the data dir; the "Previous guides" list is then never populated, though already-saved guides remain readable and listed). **Note that a persisted guide includes a full copy of the diff it was generated against** — `history/.../guides/{id}.patch` beside the `{id}.json` envelope, uncapped, as large as the diff — because that patch is what a later portable export or share link renders (the diff is captured when the guide job launches, never re-read from the working tree). Deleting a guide removes both files; nothing prunes the directory otherwise. Turning this flag off skips the patch copy too, at the cost of exports and share links for guides from that session once the server exits. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "guideHistory": false }`); the env var takes precedence. | | `PLANNOTATOR_REVIEW_PROGRESS` | Set to `0` / `false` (also `off` / `disabled`) to stop code review remembering which files you marked viewed across review sessions. Default: enabled, which writes one small JSON record per file you mark viewed or unviewed under `review-progress/{scopeHash}/{pathHash}.json` in the data dir (`~/.plannotator` or `PLANNOTATOR_DATA_DIR`), for local Git and PR reviews. **Each record carries the repo-relative file path in plain text** (plus a content fingerprint and the viewed flag; the scope directory name is a hash of the worktree root, branch and comparison, or of the PR URL). Nothing prunes the directory; delete `review-progress/` to forget, and `plannotator uninstall --purge` removes it (a plain uninstall keeps it). With the knob off the review server neither reads nor writes these records: `/api/review-progress` answers `{ available: false }` and viewed marks ride the annotation draft instead, exactly as in workspace, non-Git and piped-patch reviews. Records already on disk are left alone. Can also be set via `~/.plannotator/config.json` (`{ "reviewProgress": false }`); the env var takes precedence, and an empty or unrecognized env value counts as unset. Resolved by `resolveReviewProgress` in `packages/shared/config.ts`, read on every review snapshot in both runtimes. | -| `PLANNOTATOR_AUTO_UPDATE` | Opt-in background auto-update (#1634). Set to `1` / `true` / `on` to enable, `0` / `false` / `off` / `disabled` to force off; empty or unrecognized counts as unset. **Default: off**; nothing changes for anyone who has not opted in. Can also be set via `~/.plannotator/config.json` (`{ "autoUpdate": true }`) or Settings → General → "Keep Plannotator up to date" (allowlisted on `POST /api/config` in both runtimes; `getServerConfig` always sends the config-file value plus `autoUpdateEnv` when this env var overrides it, and the toggle is disabled with a note in that case); the env var takes precedence (`resolveAutoUpdate` in `packages/shared/config.ts`). The whole mechanism is "run the normal install script": the compiled CLI (`apps/hook/server/index.ts`, `registerCliSession`) calls `scheduleAutoUpdateCheck` (`packages/server/auto-update.ts`) each time a session registers, which returns immediately and runs on a later, unref'd tick, so it is never awaited and adds no latency to any hook, review, annotate or CLI command. That tick first runs the shared release check (`PLANNOTATOR_UPDATE_CHECK` row: at most one request every 10 minutes per data dir, to the release page), then auto-update, which answers from the same `latestRelease` cache and never runs while update checks are off (outcome `checks-off`). The check is skipped for dev/source runs (no `__CLI_VERSION__`), when the running executable is not the file the install script manages (`~/.local/bin/plannotator`, `%LOCALAPPDATA%\plannotator\plannotator.exe`), and is throttled to once per 24h (`lastCheckAt` in `update-state.json` in the data dir). It reads the newest stable release from the shared cache (asking GitHub `releases/latest` only when the cache is stale; strictly newer, never a downgrade); if another Plannotator session is open (a live pid other than ours in the `sessions/` registry) it logs, does NOT spend the 24h window, and retries on the next session start. Otherwise it takes `update.lock` (stale after 1h) and spawns a detached wrapper that runs the install script with `--version vX.Y.Z --non-interactive` (`-Version … -NonInteractive`), all output to `update.log`; the wrapper writes the exit code to `update-result.json` and releases the lock. On macOS and Linux (safe updates, step 1) the wrapper never downloads with `curl`: it runs the same binary's internal `update-prepare vX.Y.Z` (`packages/server/update-prepare.ts`), whose own fetch downloads `https://plannotator.ai/install.sh` and the `.sha256` of both CPU types' binaries into a private `plannotator-update-*` temp dir (0700; removed on exit, Ctrl-C and TERM; a killed run's dir is swept by the next update after 1h), then runs that saved script with `PLANNOTATOR_EXPECTED_SHA256` (row below). If that download fails (offline, a proxy, a bad `.sha256`, a page that is not the script) nothing runs and the result is exit `75`. Windows downloads `install.ps1` itself, as before. A download that did not match (`install.sh` exit `3`, nothing replaced) is retried by auto-update after 1, then 3, then 7 days (`mismatchBackoff: { version, attempts, lastAttemptAt }` in `update-state.json`, outcome `backed-off`; the 24h window is still spent, a newer release is tried at once, Update now and `plannotator update` ignore it). The script applies the user's own `skipInstall` config and saved install prefs as a manual run would, and install flags are remembered: after a successful run each installer (`install.sh`, `install.ps1`, `install.cmd`, including the `--minimal` early exit) writes the install-affecting choices it took from its COMMAND LINE to `install-flags.json` in the data dir as neutral ids (`{"v":1,"flags":["minimal","skip-codex"]}`; the set is `minimal`, `no-minimal`, `verify-attestation`, `skip-attestation`, `with-call-flow`, `skip-codex`, `skip-gemini`, `skip-kiro`, `skip-vibe`, `skip-opencode`, `skip-skills`, `skip-claude-plugin`; never raw argv, paths or secrets), and a manual run with none of them overwrites it with an empty set, i.e. back to defaults. Auto-update reads it (`readInstallFlags`), keeps only allowlisted ids (unknown entries and a mutually-exclusive pair are dropped and logged), and passes them re-spelled per installer (`--skip-codex` / `-SkipCodex`): as argv after the `sh -c` wrapper (`"$@"`) on POSIX, and on Windows as the `PLANNOTATOR_UPDATE_FLAGS` env var that the `-EncodedCommand` wrapper re-checks against the same allowlist and splats as an array. A missing or malformed file means no flags (today's behavior) plus an `update.log` line. Inside the script the flag still beats the env var, which beats config. The OpenCode and Pi runtimes never arm the trigger. The next UI load (plan, annotate, review) shows a one-time toast from `autoUpdateNotice` on `/api/plan` / `/api/diff` (Bun servers only, and only when the compiled CLI enabled it): "Updated to vX" once the running binary is that version, or "Auto-update failed" with the log path when the script exited non-zero (exit `3`: "Update to vX stopped", once per version, `reason: "mismatch"`; exit `75` from a background run shows nothing, since nothing changed); "seen" is the `plannotator-auto-update-notice-seen` cookie. Install scripts replace the binary safely for this: `install.sh` stages next to the target and renames over it (a running process keeps its inode), `install.ps1` / `install.cmd` rename a running `plannotator.exe` aside to `plannotator.exe.old*` and sweep those on a later run. `plannotator uninstall --purge` removes the `update*` files (`update.log.1` included) and `install-flags.json`. | +| `PLANNOTATOR_AUTO_UPDATE` | Opt-in background auto-update (#1634). Set to `1` / `true` / `on` to enable, `0` / `false` / `off` / `disabled` to force off; empty or unrecognized counts as unset. **Default: off**; nothing changes for anyone who has not opted in. Can also be set via `~/.plannotator/config.json` (`{ "autoUpdate": true }`) or Settings → General → "Keep Plannotator up to date" (allowlisted on `POST /api/config` in both runtimes; `getServerConfig` always sends the config-file value plus `autoUpdateEnv` when this env var overrides it, and the toggle is disabled with a note in that case); the env var takes precedence (`resolveAutoUpdate` in `packages/shared/config.ts`). The whole mechanism is "run the normal install script": the compiled CLI (`apps/hook/server/index.ts`, `registerCliSession`) calls `scheduleAutoUpdateCheck` (`packages/server/auto-update.ts`) each time a session registers, which returns immediately and runs on a later, unref'd tick, so it is never awaited and adds no latency to any hook, review, annotate or CLI command. That tick first runs the shared release check (`PLANNOTATOR_UPDATE_CHECK` row: at most one request every 10 minutes per data dir, to the release page), then auto-update, which answers from the same `latestRelease` cache and never runs while update checks are off (outcome `checks-off`). The check is skipped for dev/source runs (no `__CLI_VERSION__`), when the running executable is not the file the install script manages (`~/.local/bin/plannotator`, `%LOCALAPPDATA%\plannotator\plannotator.exe`), and is throttled to once per 24h (`lastCheckAt` in `update-state.json` in the data dir). It reads the newest stable release from the shared cache (asking GitHub `releases/latest` only when the cache is stale; strictly newer, never a downgrade); if another Plannotator session is open (a live pid other than ours in the `sessions/` registry) it logs, does NOT spend the 24h window, and retries on the next session start. Otherwise it takes `update.lock` (stale after 1h) and spawns a detached wrapper that runs the install script with `--version vX.Y.Z --non-interactive` (`-Version … -NonInteractive`), all output to `update.log`; the wrapper writes the exit code to `update-result.json` and releases the lock. On macOS and Linux (safe updates, step 1) the wrapper never downloads with `curl`: it runs the same binary's internal `update-prepare vX.Y.Z` (`packages/server/update-prepare.ts`), whose own fetch downloads `https://plannotator.ai/install.sh` and the `.sha256` of the binary install.sh will pick on this machine (`process.arch`; required) plus the other CPU type's when it can be read (optional, so a release that drops one CPU type never stops updates for the other) into a private `plannotator-update-*` temp dir (0700; removed on exit, Ctrl-C and TERM; a killed run's dir is swept by the next update after 1h), then runs that saved script with `PLANNOTATOR_EXPECTED_SHA256` (row below). If that download fails (offline, a proxy, a bad `.sha256`, a page that is not the script) nothing runs and the result is exit `75`. Windows downloads `install.ps1` itself, as before. A download that did not match (`install.sh` exit `113` plus the wrapper's `checksum-mismatch` marker, nothing replaced; codes in `packages/core/update-exit-codes.ts`) is retried by auto-update after 1, then 3, then 7 days (`mismatchBackoff: { version, attempts, lastAttemptAt }` in `update-state.json`, outcome `backed-off`; the 24h window is still spent, a newer release is tried at once, Update now and `plannotator update` ignore it). The script applies the user's own `skipInstall` config and saved install prefs as a manual run would, and install flags are remembered: after a successful run each installer (`install.sh`, `install.ps1`, `install.cmd`, including the `--minimal` early exit) writes the install-affecting choices it took from its COMMAND LINE to `install-flags.json` in the data dir as neutral ids (`{"v":1,"flags":["minimal","skip-codex"]}`; the set is `minimal`, `no-minimal`, `verify-attestation`, `skip-attestation`, `with-call-flow`, `skip-codex`, `skip-gemini`, `skip-kiro`, `skip-vibe`, `skip-opencode`, `skip-skills`, `skip-claude-plugin`; never raw argv, paths or secrets), and a manual run with none of them overwrites it with an empty set, i.e. back to defaults. Auto-update reads it (`readInstallFlags`), keeps only allowlisted ids (unknown entries and a mutually-exclusive pair are dropped and logged), and passes them re-spelled per installer (`--skip-codex` / `-SkipCodex`): as argv after the `sh -c` wrapper (`"$@"`) on POSIX, and on Windows as the `PLANNOTATOR_UPDATE_FLAGS` env var that the `-EncodedCommand` wrapper re-checks against the same allowlist and splats as an array. A missing or malformed file means no flags (today's behavior) plus an `update.log` line. Inside the script the flag still beats the env var, which beats config. The OpenCode and Pi runtimes never arm the trigger. The next UI load (plan, annotate, review) shows a one-time toast from `autoUpdateNotice` on `/api/plan` / `/api/diff` (Bun servers only, and only when the compiled CLI enabled it): "Updated to vX" once the running binary is that version, or "Auto-update failed" with the log path when the script exited non-zero (exit `113`: "Update to vX stopped", once per version, `reason: "mismatch"`; exit `75` from a background run shows nothing, since nothing changed); "seen" is the `plannotator-auto-update-notice-seen` cookie. Install scripts replace the binary safely for this: `install.sh` stages next to the target and renames over it (a running process keeps its inode), `install.ps1` / `install.cmd` rename a running `plannotator.exe` aside to `plannotator.exe.old*` and sweep those on a later run. `plannotator uninstall --purge` removes the `update*` files (`update.log.1` included) and `install-flags.json`. | | `PLANNOTATOR_UPDATE_CHECK` | Update notices (owner-approved design; design record, not in this repo). **Default: on.** `0` / `false` / `off` / `disabled` turn it off, `1` / `true` / `on` force it on; empty or unrecognized counts as unset. Also `~/.plannotator/config.json` `{ "updateCheck": false }` (allowlisted on `POST /api/config` in all six servers; `getServerConfig` always sends `updateCheck`, the config-file value, plus `updateCheckEnv` when this env var overrides it); the env var wins (`resolveUpdateCheck` in `packages/shared/config.ts`). The machine asks, never the browser (the review windows' own GitHub fetch is gone, below): `runReleaseCheck` in `packages/shared/update-check.ts` (vendored to Pi) reads the newest release from GitHub's "latest release" link: one unauthenticated `HEAD https://github.com/backnotprop/plannotator/releases/latest` with `user-agent: plannotator-update-check` (no version, no cookies), read from its `302` to `…/releases/tag/vX.Y.Z` without following it (`fetchLatestFromPage`; the `Location` counts only on the same origin at exactly the request path with `latest` replaced by `tag/v`, `versionFromLatestRedirect`; a runtime that followed it anyway is read from `response.url`). That link is not under the API's 60 requests per hour per IP, is right the moment a release is published (drafts and pre-releases are never "latest", nor is a tag with no release), carries no body, and needs nothing from the release pipeline. It asks at most once every 10 minutes per data dir (`RELEASE_CHECK_INTERVAL_MS`; owner's decision 2026-10-24, so a new release shows within minutes), and an Inbox start or an Inbox page load asks when the answer is over a minute old (`RELEASE_CHECK_ON_START_MS`); it claims the attempt in `update-state.json` (`releaseCheck.attemptedAt`) before the request so concurrent starts ask once, and after a failure (offline, a 5xx, a proxy) waits 10 minutes whatever triggers the next check (a start or a reload included), never retrying in a loop (`RELEASE_CHECK_RETRY_MS`). When the page itself refuses for a rate limit (429, or 403 with `Retry-After` / `x-ratelimit-remaining: 0`: an office NAT can meet github.com's limits too), the next check waits the time it names, kept between 10 minutes and an hour, an hour when it names none (`RELEASE_PAGE_RATE_LIMITED_RETRY_MS`, `releaseCheck.pageRetryAt`, cleared by a success). When the page fails, the API is the fallback (`GET https://api.github.com/repos/backnotprop/plannotator/releases/latest`, `fetchLatestRelease`), asked at most once an hour (`RELEASE_API_FALLBACK_INTERVAL_MS`, `releaseCheck.apiAttemptedAt`); a rate-limited API answer (403/429) holds back only the API, until GitHub's `Retry-After` or `x-ratelimit-reset`, kept between 1 h and 6 h, else 6 h (`RELEASE_CHECK_RATE_LIMITED_RETRY_MS`, `releaseCheck.apiRetryAt`; a `retryAt` written by 0.28.30 is read the same way), never the page: before this change the API was the only source, and one 403 hid every notice for hours. A failure keeps the last known `latestRelease` and writes an `update.log` line only when the errors change (`releaseCheck.logged`, `failures` counts the run; the line after a change and the first success say how many failed before), so an offline machine does not add a line every 10 minutes; `update.log` moves to `update.log.1` once it passes 1 MiB (`UPDATE_LOG_MAX_BYTES`, `uninstall --purge` removes both); `releaseCheck.source` says which source answered (`page` or `api`). The tag is reduced to a canonical `X.Y.Z` before it is stored (pre-releases and junk are failures). The redirect carries no notes or publish date: the API answer does, and the Inbox fetches a newer release's notes by its tag (`fetchReleaseNotesFor`, throttled per version) when it learns of it, which also fills in `latestRelease.publishedAt`. Besides those requests, the Inbox (and only the Inbox) downloads, in the background whether or not What's new is opened, up to 12 images per release that the saved notes show, from GitHub's release-attachment image hosts (`github.com/user-attachments`, `private-user-images.githubusercontent.com`, `user-images.githubusercontent.com`, this repository on `raw.githubusercontent.com`), with the same user agent and a per-version claim and back-off (`releaseImagesFetch`); only while update checks are on (see "Images in the notes" under the Inbox's update control). The release notes (the body from `## What's New in` up to `## Install / Update`, 64 KB cap, plus the "Missed recent releases?" table) go to `update-notes/.json`. Who asks: the compiled CLI at `registerCliSession` (the same unref'd tick as auto-update; never in a build that is not a release, whose `__CLI_VERSION__` is absent or not `X.Y.Z`, e.g. `0.0.0-dev`); and the Plannotator Inbox of a release build, on start, on each page load (`GET /`, `updates.nudge()`) and on each health tick (`packages/server/inbox-updates.ts`; each answers from the cache within its window); and the Pi extension, on each review-window payload (due-gated like the rest) (`apps/pi-extension/server/update-advert.ts`). Everything else reads the cache only. Off means no automatic request to GitHub at all (machine or browser): no terminal hint, no review-window notice, no Inbox Update button for a release that is only available (a binary already on disk still shows Ready), and no auto-update; an explicit `plannotator update` (or `--check`) still asks, and says so on stderr. The terminal hint: after a `review` / `annotate` / `annotate-last` / `last` / `archive` session the compiled CLI prints `Plannotator X is available (you have Y). Update: plannotator update` on stderr, only when stderr is a TTY, never with `--json` / `--hook`, never with `CLAUDECODE` or `PLANNOTATOR_SESSION_TAG` set (an agent's shell or the mod), at most once per 24h (`hintShownAt`), read from the cache (`takeUpdateHint`). `plannotator --version` is unchanged (the Inbox parses it). `plannotator update [--check]` (`apps/hook/server/update-command.ts`) forces a check, then runs the SAME wrapper as auto-update in the foreground (`beginManualUpdate` + `runInstallerForeground`: `update.lock`, pinned tag, remembered `install-flags.json`, output shown and appended to `update.log`, `update-result.json`), without auto-update's 24h window and its open-sessions gate (open sessions keep their old binary); it refuses a build that is not a release (no request at all) and a binary the install script does not manage (printing the install command with the remembered flags); exit 0 up to date / updated / `--check`, 1 failed or could not run, 2 usage, 130 interrupted. Interruption: the POSIX wrapper traps INT / TERM / HUP and the PowerShell wrapper writes its result in `finally`, so a Ctrl-C records exit 130 in `update-result.json` and removes `update.lock` at once (it never waits out the 1h stale-lock window); `runInstallerForeground` passes an INT / TERM sent only to the CLI on to the wrapper, and a wrapper killed outright still ends with the lock released and `-1` recorded (`abortLaunch`). `update-command-entry.ts` runs the same function alone, for the process tests (the full entry imports the built HTML). `uninstall --purge` also removes `update-notes/`. Review windows (design §4.5, owner's answer 3): the browser never asks GitHub any more (`useUpdateCheck` lost its fetch; `?preview-update=X` still simulates one). The compiled CLI's plan, review and annotate servers add `updateAvailable: { version, current, releaseUrl, action, command, publishedAt? }` to `/api/plan` and `/api/diff` (`getUpdateAdvert(origin)` in `packages/server/auto-update.ts`, read from the cache only), where `action` is `opencode` for an OpenCode-launched CLI, `script` for the binary the install script manages in a session only this computer reaches, `copy` otherwise (an unmanaged binary, and every remote-mode or `--tailscale` session, so a device on the LAN or tailnet is shown the command, never offered the installer), and `command` is the install command with the remembered flags (`installCommandFor`; PowerShell scriptblock form on Windows). Pi's three servers send the same field with `action: "pi"` and `command: "pi update --extensions"`, compared with the Pi package's own version (`apps/pi-extension/server/update-advert.ts`, which also schedules the release check on each payload from the vendored module; nothing for `oh-my-pi`). Known gap: OpenCode's in-process embedded plan server sends nothing (it knows no version). The toast (`useUpdateToast`, `packages/ui/hooks/`) shows at most once per version and at most once a day (24 h) across versions (`claimUpdateToast`, cookie `plannotator-update-toast` = `@`, shared by every localhost port), first looks 5 s after load (past the first-run announcements' own window), waits while a first-run dialog of this load shows or a composer / dialog has focus (re-checking every 5 s for up to 2 min, unconsumed), never for a share link, archive or a session without a server, and never while auto-update is active and can install (`isAutoUpdateActive`: auto-update on, checks on, the managed binary, and `autoUpdateCanInstall`: an install is running, or auto-update's 24 h window has elapsed and no other Plannotator session is open; auto-update installs at most once a day and skips every start that finds another session open, so without these a person was not told for up to a day, or never while a session was always open). The sessions it counts come from `listLiveSessions` (`packages/server/sessions.ts`), a read-only lister that never deletes a file, since this runs on every `/api/plan` and `/api/diff`; `registerSession` writes atomically (temp + rename), so `listSessions`' cleanup never takes a half-written file for a corrupt one. Its actions are **What's new** (the GitHub release) and, for `script`, **Update** (`POST /api/update`, `handleUpdateRoute`: `403 forbidden_host` unless the Host is loopback with this server's port (`isLoopbackHostHeader`), `403` cross-origin, `403 update_exposed` for a remote-mode or `--tailscale` session, `409 session_mismatch` for a body `serverSession` naming another session (the client sends it, `withServerSession`); then `startManualUpdateForThisBinary`, the same wrapper and `update.lock` as the Inbox's Update now; `409 update_locked` / `update_unmanaged` / `update_dev_build` / …), else **Copy command**. The Options button keeps its dot (no label shimmer any more) and its version section shows "X is available · What's new", the host's command with Copy, and Update now for `script`. Settings → General gains "Tell me when a new version is out" (`useUpdateCheckSetting`, shown when `serverConfig.updateCheck` is present; written with `POST /api/config`, deliberately NOT a settings-registry entry, because the registry is bundled into the pinned guides.show viewer and must not change its build) above "Keep Plannotator up to date", which is locked with "Needs update checks" while checks are off. | | `PLANNOTATOR_UPDATE_API_URL` | Testing / mirror knob for the update check: the URL answering like GitHub's `releases/latest` (`releaseApiUrl` in `packages/shared/update-check.ts`). Only `https:` or loopback `http:` is accepted; anything else is ignored. One release's notes by its tag go to the URL resolved against it (`tags/v` in place of its last segment, `releaseTagApiUrl`). The `plannotator update` process test serves a fake one on loopback. A loopback `http:` value also lets the Inbox fetch What's new images from that same origin (`allowedReleaseImageUrl` in `packages/server/inbox-whats-new-images.ts`); any other value adds no image host. Set alone (no `PLANNOTATOR_UPDATE_LATEST_URL`), it is the ONLY source: the release page on github.com is not asked (`releasePageUrl` answers null), so a GHE proxy or a test fake is never bypassed; the API is then asked at most once an hour. The test preload sets it to a closed loopback port, which is what keeps every bun test away from github.com. | | `PLANNOTATOR_TELEMETRY` | Hard off for usage counts of experimental features (see "Usage counts for experimental features"). `0` / `false` / `off` / `disabled` / `no` turn counts off everywhere, whatever config.json says (counting also needs the feature on in config.json and its notice shown, config.json `usageCountsNotice`): nothing is recorded, nothing is sent, and the counts on disk that were not sent are deleted the next time the recorder or sender runs. No value turns counts on (`1` is the same as unset). Read on each record and each send (`usageCountsEnvBlock` in `packages/shared/usage-counts.ts`). | @@ -50,7 +50,7 @@ | `PLANNOTATOR_UPDATE_LATEST_URL` | Testing / mirror knob for the update check's primary source: a URL answering like GitHub's `https://github.com///releases/latest`, i.e. `302` to `…/releases/tag/vX.Y.Z` on the same origin (`releasePageUrl` / `fetchLatestFromPage` in `packages/shared/update-check.ts`). Only `https:` or loopback `http:` is accepted; anything else is ignored. Used by `apps/hook/server/update-notices-cli.test.ts` and `tests/e2e/inbox-update-notice.spec.ts`. | | `PLANNOTATOR_INSTALL_SCRIPT_URL` | Testing / mirror knob: the install script an update downloads (`installScriptUrl` in `packages/server/auto-update.ts`; default `https://plannotator.ai/install.sh`, `install.ps1` on Windows), for `plannotator update`, Update now and auto-update alike. On macOS and Linux `update-prepare` downloads it with Plannotator's own fetch and keeps it only when it starts with `#!` and contains `REPO="backnotprop/plannotator"` (under 4 MB); on Windows the wrapper downloads it. Only `https:` or loopback `http:` is accepted. | | `PLANNOTATOR_RELEASE_DOWNLOAD_URL` | Testing / mirror knob: the base `update-prepare` downloads each binary's `/vX.Y.Z/plannotator--.sha256` from (`releaseDownloadUrl` in `packages/server/update-prepare.ts`; default `https://github.com/backnotprop/plannotator/releases/download`). Only `https:` or loopback `http:` is accepted. The update tests serve a fake one on loopback. | -| `PLANNOTATOR_EXPECTED_SHA256` | Set by Plannotator's update wrapper, not by people: `plannotator--arm64= plannotator--x64=`, the checksums `update-prepare` fetched with its own connection. `install.sh` uses the entry for the binary it picked as the expected checksum (and does not fetch the `.sha256` again); on a mismatch it prints "Stopped: …", replaces nothing and exits `3`. A missing or malformed entry prints a note and falls back to the `.sha256` check. Unset (a person running the script, an older Plannotator), nothing changes. `plannotator update` and the Inbox's failure dialog never offer the `curl \| bash` line after exit `3` or `75`. Internal wrapper variables alongside it: `PLANNOTATOR_UPDATE_BINARY` / `PLANNOTATOR_UPDATE_ENTRY` (who runs `update-prepare`; a source run passes its script). | +| `PLANNOTATOR_EXPECTED_SHA256` | Set by Plannotator's update wrapper, not by people: `plannotator--=` for this machine's CPU type, then the other one's when it was available, the checksums `update-prepare` fetched with its own connection. `install.sh` uses the entry for the binary it picked as the expected checksum (and does not fetch the `.sha256` again); on a mismatch it prints "Stopped: …", replaces nothing, touches the file the wrapper names in `PLANNOTATOR_UPDATE_MISMATCH_FILE` (only an absolute path ending `/checksum-mismatch`), and exits `113`; the wrapper records `113` only when that marker exists and records any other `113` as `1`, so "nothing was changed" is never claimed for another error. A missing or malformed entry prints a note and falls back to the `.sha256` check. Unset (a person running the script, an older Plannotator), nothing changes. `plannotator update` and the Inbox's failure dialog never offer the `curl \| bash` line after exit `113` or `75`. Internal wrapper variables alongside it: `PLANNOTATOR_UPDATE_BINARY` / `PLANNOTATOR_UPDATE_ENTRY` (who runs `update-prepare`; a source run passes its script). | | `PLANNOTATOR_CURSOR_SANDBOX` | Set to `0` / `false` / `disabled` to stop passing `--sandbox enabled` when launching Cursor's CLI for review jobs (`cursor-agent`, else `agent` only when that is Cursor's own CLI: `resolveMarkerBinary` over `packages/shared/agent-program.ts`, vendored to Pi, the install-layout path rule then an asynchronous 2 s `--version` probe, so another program named `agent` such as Grok Build's is never started and Cursor reads as unavailable in `/api/agents/capabilities`) — the flag pair is omitted entirely, deferring to the user's own Cursor Agent sandbox configuration. For systems where Cursor's sandbox cannot start (NixOS, AppArmor-restricted Linux). Default: enabled (`--sandbox enabled` is passed). Can also be set via `~/.plannotator/config.json` (`{ "cursorSandbox": false }`); the env var takes precedence. Note: opting out means the review job's write protection relies on `--mode ask` plus the user's own Cursor configuration. | | `PLANNOTATOR_CLAUDE_SANDBOX` | Set to `0` / `false` / `disabled` to turn Claude Code's own sandbox OFF for Claude-engine agent jobs (code review, Code Tour, Guided Review) by passing `--settings '{"sandbox":{"enabled":false}}'` (#1627). For Linux systems where the sandbox cannot start (no bubblewrap/socat, AppArmor-restricted user namespaces) while `~/.claude/settings.json` enables it: jobs run with `--permission-mode dontAsk`, so Claude's unsandboxed fallback is refused and every Bash call fails. Default: enabled, meaning Plannotator passes nothing and the user's own Claude Code sandbox setting applies. Opting out runs the job's commands without OS containment, the same as for any user who never enabled Claude's sandbox (Claude Code's default). `--tools`, the `--allowedTools` prefixes, the `--disallowedTools` list and `dontAsk` are unchanged, but the command rules limit what the model can run rather than containing it (Claude Code matches rule text, so a deny rule covers the spellings a model normally writes, not every spelling a program accepts), so treat the opt-out as removing the only OS-level boundary. Managed (enterprise) settings still outrank `--settings`. When a Claude job finishes with no shell command that ran and at least one refused command its own `--allowedTools` admits outright — a plain invocation with no operators, substitutions or variables, which `dontAsk` never refuses unless the shell itself cannot run (`detectClaudeShellBlocked`), the job carries a `warning` pointing at this knob, shown in the Agents tab and the job detail panel; the sandbox is never turned off automatically. Independently of this knob, every Claude agent job passes `--strict-mcp-config` (no `--mcp-config`, so none of the user's MCP servers load into a job) and an `--append-system-prompt` telling the model to run one simple command at a time, since `dontAsk` refuses compound commands (`$(...)`, `&&`, pipes) as a whole (`claudeJobIsolationArgs` in `packages/server/claude-review.ts`, vendored to Pi), plus `--setting-sources user`, so the user's own settings (auth, env, sandbox preference) and managed policy apply but the job directory's project/local `.claude/settings*.json` (a PR checkout is someone else's branch) cannot add allow rules or hooks to a job. Side effects: Claude auth or provider env kept only in a repo's `.claude/settings.json` / `.claude/settings.local.json` does not apply to jobs, and neither does a sandbox setting or `permissions.deny` set only in project settings; managed settings still apply. All three job kinds share ONE command allow/deny definition (`CLAUDE_JOB_ALLOWED_TOOLS` / `CLAUDE_JOB_DISALLOWED_TOOLS` via `claudeJobToolArgs`, same file): exactly the read-only commands the prompts instruct (git status/diff/log/show/blame/merge-base/rev-parse/ls-files/ls-tree, `git branch --show-current`, `git remote -v` / `get-url`, the read-only jj commands, `gh pr view|diff` / `gh issue view` and the glab equivalents, `wc`), with deny rules for git `--output` and `-c` / `--config-env`, jj `--config*` / `--tool`, and `git grep`, `git ls-remote`, `gh api`, `glab api`. There is no `git -C`: workspace prompts send the combined diff inline and point the model at the folder-prefixed files. `claude-job-isolation.test.ts` reads the commands out of the real prompt text and fails if the allowlist stops admitting one. `detectClaudeShellBlocked` also takes the job's `--disallowedTools`, so a refusal a deny rule issues by design never reads as a blocked shell. Can also be set via `~/.plannotator/config.json` (`{ "claudeSandbox": false }`); the env var takes precedence. | | `PLANNOTATOR_GIT_REMOTE_CHECK` | Set to `0` / `false` / `disabled` to stop code review contacting the git remote (issue #1553). Default: enabled, which runs `git ls-remote --symref origin HEAD` to discover the remote default branch and to compare the remote tip against the local tracking ref for the "Baseline is behind GitHub" banner. That probe is **not** on a timer: it runs at startup (the compare-target detection plus the staleness probe), on `/api/diff` page load, on `/api/diff/switch` (the diff-type/base pickers and the "Diff out of date · Refresh" button), and on the explicit `/api/fetch-base`, each rate limited to once per 60s per session, with the interval DOUBLING on failure up to a 15-minute cap and resetting to 60s on the next success (`nextRemoteBaseCheckInterval` in `packages/shared/review-core.ts`, shared by both runtimes). It is deliberately absent from `/api/diff/fresh`, which the client polls every 5s for as long as the page is open — hanging the probe there made an idle review query the remote once a minute forever, which on a smartcard-backed SSH setup is one hardware-key touch prompt per minute with nothing on screen to explain it. With the check off the session makes **zero** `ls-remote` calls, startup probes included: the compare target stays whatever LOCAL ref discovery resolved (`getDefaultBranch`: `origin/HEAD` → `origin/main` → `main` → …), `baseBehindRemote` never rides any payload so the banner never shows, and `POST /api/fetch-base` stays available because an explicit Fetch is the user asking for the network — note that with the banner gone so is its one-click Fetch button, which lives in that banner, so the endpoint matters to API callers rather than to the reviewer, and its post-fetch `refreshRemoteBaseInfo()` is a no-op under the opt-out. Only plain local git sessions are affected (PR, jj, GitButler, P4, workspace and static-patch sessions never ran the check). Can also be set via `~/.plannotator/config.json` (`{ "gitRemoteCheck": false }`); precedence is `review --no-git-remote-check` flag > env var > config (`resolveGitRemoteCheck` in `packages/shared/config.ts`). The flag is parsed by the shared `parseReviewArgs`, so it works on every host that forwards review arguments — Claude Code, OpenCode, and Pi — not just the Bun CLI. | diff --git a/docs/agent-guides/inbox-foundation.md b/docs/agent-guides/inbox-foundation.md index 705e22145d..6d309ef6d6 100644 --- a/docs/agent-guides/inbox-foundation.md +++ b/docs/agent-guides/inbox-foundation.md @@ -73,7 +73,7 @@ One long-lived local server per machine. Agents leave messages, `:::question` bl - Callers re-read the registry on every call. - An Inbox whose data is gone exits: every 5 s (`INBOX_DATA_CHECK_MS`) it reads `inbox.json` (`probeInboxData`). It is a miss ONLY when the file or its folder does not exist (ENOENT, ENOTDIR) or the file names another `serverSession`; any other error (EACCES, EIO, ESTALE, a file that does not parse) is unknown and leaves the count alone, and the folder's device and inode are not looked at (a remount or a moved copy must not end it). Two misses in a row stop it cleanly, then it says why on stderr (plus the manual `tailscale serve --https= off` when the tailnet take-down failed) and the process exits 0 (`onDataGone`). Nothing could reach it (agents find an Inbox through the file) and it has no store, so it must not run on headless (an e2e once removed its temp folder under a live `--tailscale` Inbox, which ran for two days at 100% CPU, cause unknown). It is a timer, so it cannot end an Inbox stuck in a synchronous loop. After it starts, the Inbox rewrites `inbox.json` only while the file names its own `serverSession` (`updateOwnInboxRegistry`, used by the tailnet and Wi-Fi saves; read then write, so a start overlapping a stop can still lose its entry, and then exits by the same check), so a stopping Inbox never rewrites its successor's entry and never makes a removed data folder again. - The detached Inbox runs with `inbox/` as its working directory and `windowsHide`. -- The update control (`packages/server/inbox-updates.ts`; design record, not in this repo): health, the list model and the event stream's `status` carry `update: { available: true, state, version, current, action, releaseUrl, publishedAt? } | null` (`InboxUpdate` in `packages/core/inbox-types.ts`; `available` and `version` keep the older shape). `ready`: on the health tick (60 s, every 2 s while `update.lock` is held), a compiled Inbox stats the binary on disk and, only when the file's identity (device, inode, size, mtime) changed, runs it with `--version`; a different version is ready (the old restart-to-update signal). `updating`: `update.lock` is held. `failed`: `update-result.json` records a non-zero exit for an install the PERSON started (`pending.by: "person"`; a background auto-update failure stays out of the sidebar). `available`: a release build (X.Y.Z, so never `dev` or `0.0.0-dev`) runs the shared release check on start and on each page load (when the answer is over a minute old) and on each tick (when it is over 10 minutes old, and 10 minutes after a failure; never while `PLANNOTATOR_UPDATE_CHECK` is off; a new release therefore shows within 10 minutes, at once on a restart or a reload) and shows a newer stable release at once, before anything is downloaded (owner's answer 2), unless "Remind me tomorrow" snoozed that version (`update-state.json` `snooze: { version, until }`, 24 h, shared by every tab; a newer release clears it). `action`: `script` when the running binary is the install script's (`isManagedBinary`), else `copy`. The list model also carries `updated`: the version this run started on when it is newer than the one the Inbox ran before (`inboxVersion` in `update-state.json`), for the sidebar's quiet "What's new in X" link this run only. That start also keeps the version it found as `inboxPreviousVersion` (additive; kept across restarts on the same version, replaced at the next update, removed on a downgrade), so the What's new page of a release this Inbox already runs still lists the releases skipped since the version run before the update (the answer's additive `since`, which `skipped` counts from; a newer release counts from the running version, and `current` stays the running version). The release check saves notes only for the `releases/latest` it fetched, so a version installed outside the Inbox (the install script run by hand) before a check saw it has none: `fetchReleaseNotesFor` (`packages/shared/update-check.ts`) then asks for that release by its tag (`releases/tags/vX`, `releaseTagApiUrl`, the same client rules: the `plannotator-update-check` user agent, nothing while `PLANNOTATOR_UPDATE_CHECK` is off, claimed in `update-state.json` `notesFetch[]` before the request, then 6 h after a failure and 24 h after an answer with no "What's New" part, never a loop) and saves `update-notes/.json`. A stored file with an empty "What's New" part (saved before the release body was edited) counts as missing and is fetched again under the same throttle. The Inbox runs it on its tick for `updated` and from `GET /api/inbox/whats-new`, only for a version it has reason to show (the one it runs, the newest known release, the binary on disk, an install in progress); the route waits up to 3 s (`WHATS_NEW_WAIT_MS`), then answers `503 { code: "notes_loading" }` with `Retry-After: 1` and the page asks again (up to 15 times). `updated` is null while its notes are neither saved nor fetchable (checks off, or a fetch in its back-off), so no link points at a page with nothing on it; the event stream's `status` carries `updated` too, so the link appears or goes once the fetch settles. Update now is `POST /api/inbox/update` (`startManualUpdate`: the same wrapper, lock, log, pinned tag and remembered flags as auto-update, without its 24 h window, its open-sessions gate or its mismatch back-off; a second press while it runs is `409 update_locked`); the page that pressed it restarts the Inbox itself once `ready` arrives. On macOS and Linux that wrapper downloads the install script and checksums with the Inbox binary's own fetch (`update-prepare`, environment.md `PLANNOTATOR_AUTO_UPDATE`). The failure dialog (`FailedDialog` in `packages/inbox/components/Updates.tsx`) reads `failure.exit_code`: `3` (the download did not match the checksum Plannotator fetched) and `75` (Plannotator could not download the update) say nothing changed and the old version still works, show the log and Try Again, and never the `curl | bash` line; any other code keeps "Or run the install script yourself". A tailnet page reaches none of the update routes. +- The update control (`packages/server/inbox-updates.ts`; design record, not in this repo): health, the list model and the event stream's `status` carry `update: { available: true, state, version, current, action, releaseUrl, publishedAt? } | null` (`InboxUpdate` in `packages/core/inbox-types.ts`; `available` and `version` keep the older shape). `ready`: on the health tick (60 s, every 2 s while `update.lock` is held), a compiled Inbox stats the binary on disk and, only when the file's identity (device, inode, size, mtime) changed, runs it with `--version`; a different version is ready (the old restart-to-update signal). `updating`: `update.lock` is held. `failed`: `update-result.json` records a non-zero exit for an install the PERSON started (`pending.by: "person"`; a background auto-update failure stays out of the sidebar). `available`: a release build (X.Y.Z, so never `dev` or `0.0.0-dev`) runs the shared release check on start and on each page load (when the answer is over a minute old) and on each tick (when it is over 10 minutes old, and 10 minutes after a failure; never while `PLANNOTATOR_UPDATE_CHECK` is off; a new release therefore shows within 10 minutes, at once on a restart or a reload) and shows a newer stable release at once, before anything is downloaded (owner's answer 2), unless "Remind me tomorrow" snoozed that version (`update-state.json` `snooze: { version, until }`, 24 h, shared by every tab; a newer release clears it). `action`: `script` when the running binary is the install script's (`isManagedBinary`), else `copy`. The list model also carries `updated`: the version this run started on when it is newer than the one the Inbox ran before (`inboxVersion` in `update-state.json`), for the sidebar's quiet "What's new in X" link this run only. That start also keeps the version it found as `inboxPreviousVersion` (additive; kept across restarts on the same version, replaced at the next update, removed on a downgrade), so the What's new page of a release this Inbox already runs still lists the releases skipped since the version run before the update (the answer's additive `since`, which `skipped` counts from; a newer release counts from the running version, and `current` stays the running version). The release check saves notes only for the `releases/latest` it fetched, so a version installed outside the Inbox (the install script run by hand) before a check saw it has none: `fetchReleaseNotesFor` (`packages/shared/update-check.ts`) then asks for that release by its tag (`releases/tags/vX`, `releaseTagApiUrl`, the same client rules: the `plannotator-update-check` user agent, nothing while `PLANNOTATOR_UPDATE_CHECK` is off, claimed in `update-state.json` `notesFetch[]` before the request, then 6 h after a failure and 24 h after an answer with no "What's New" part, never a loop) and saves `update-notes/.json`. A stored file with an empty "What's New" part (saved before the release body was edited) counts as missing and is fetched again under the same throttle. The Inbox runs it on its tick for `updated` and from `GET /api/inbox/whats-new`, only for a version it has reason to show (the one it runs, the newest known release, the binary on disk, an install in progress); the route waits up to 3 s (`WHATS_NEW_WAIT_MS`), then answers `503 { code: "notes_loading" }` with `Retry-After: 1` and the page asks again (up to 15 times). `updated` is null while its notes are neither saved nor fetchable (checks off, or a fetch in its back-off), so no link points at a page with nothing on it; the event stream's `status` carries `updated` too, so the link appears or goes once the fetch settles. Update now is `POST /api/inbox/update` (`startManualUpdate`: the same wrapper, lock, log, pinned tag and remembered flags as auto-update, without its 24 h window, its open-sessions gate or its mismatch back-off; a second press while it runs is `409 update_locked`); the page that pressed it restarts the Inbox itself once `ready` arrives. On macOS and Linux that wrapper downloads the install script and checksums with the Inbox binary's own fetch (`update-prepare`, environment.md `PLANNOTATOR_AUTO_UPDATE`). The failure dialog (`FailedDialog` in `packages/inbox/components/Updates.tsx`) reads `failure.exit_code` (codes in `packages/core/update-exit-codes.ts`): `113` (the download did not match the checksum Plannotator fetched) and `75` (Plannotator could not download the update) say nothing changed and the old version still works, show the log and Try Again, and never the `curl | bash` line; any other code keeps "Or run the install script yourself". A tailnet page reaches none of the update routes. - Plain `uninstall` and `--purge` both remove the `plannotator-inbox` MCP entries the connect step added (see "Connecting the agents on this computer"; `removeInboxAgentConnections` in `packages/server/uninstall.ts`): with the binary gone, an entry would fail to start at every agent launch. A plain uninstall then forgets those records, so a reinstall connects them again. - `uninstall --purge` stops a running Inbox first (its token-guarded stop route, then SIGTERM only while the port still answers with that `serverSession` and pid) and removes `inbox/` (`inbox` is in `PURGE_OWNED_TOP_LEVEL`, `packages/server/uninstall.ts`). An Inbox that is still running after that blocks the data purge: uninstall says to quit it and run `uninstall --purge` again. It also takes down a tailnet mapping a crashed Inbox left (`takeDownInboxTailnet`, only when `inbox.json` has `tailnet`; `takeDownInboxTailscale` for the window's, only when `tailscale` keeps its ports). `--dry-run` lists the stop and the take-down. A plain uninstall keeps the data. diff --git a/packages/core/package.json b/packages/core/package.json index 1c1ea3527f..68fef094e1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,6 +15,7 @@ "./guide-builder": "./guide-builder.ts", "./guide-builder-tools": "./guide-builder-tools.ts", "./diff-type": "./diff-type.ts", + "./update-exit-codes": "./update-exit-codes.ts", "./review-prompt": "./review-prompt.ts", "./annotatable": "./annotatable.ts", "./annotation-threads": "./annotation-threads.ts", diff --git a/packages/core/update-exit-codes.ts b/packages/core/update-exit-codes.ts new file mode 100644 index 0000000000..322fd6fe7c --- /dev/null +++ b/packages/core/update-exit-codes.ts @@ -0,0 +1,16 @@ +/** + * The update results Plannotator tells apart (safe updates, step 1). One + * definition for the server (packages/server/auto-update.ts), `plannotator + * update`, and the Inbox's failure dialog, so the three never drift. + */ + +/** + * install.sh stopped because the binary did not match the checksum Plannotator + * fetched, before it replaced anything. Recorded only when install.sh also + * wrote the update wrapper's marker file; any other exit with this number is + * recorded as 1. + */ +export const UPDATE_EXIT_CHECKSUM_MISMATCH = 113; + +/** Plannotator could not download the install script or the checksum: nothing ran (sysexits EX_TEMPFAIL). */ +export const UPDATE_EXIT_DOWNLOAD_FAILED = 75; diff --git a/packages/inbox/components/Updates.tsx b/packages/inbox/components/Updates.tsx index 2d4a08adfd..3af22edb9f 100644 --- a/packages/inbox/components/Updates.tsx +++ b/packages/inbox/components/Updates.tsx @@ -6,6 +6,7 @@ * restart and failure dialogs; the What's new page; Settings → About. */ +import { UPDATE_EXIT_CHECKSUM_MISMATCH, UPDATE_EXIT_DOWNLOAD_FAILED } from '@plannotator/core/update-exit-codes'; import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react'; import { Viewer } from '@plannotator/ui/components/Viewer'; import { ImageResolverContext, type ImageResolution } from '@plannotator/ui/components/ImageResolverContext'; @@ -383,11 +384,6 @@ export function RestartDialog({ update, subject, onRestart, onCancel }: { update ); } -/** install.sh's exit when the binary didn't match the checksum Plannotator fetched (packages/server/auto-update.ts). */ -const UPDATE_EXIT_MISMATCH = 3; -/** The update wrapper's exit when Plannotator couldn't download the update (packages/server/auto-update.ts). */ -const UPDATE_EXIT_DOWNLOAD_FAILED = 75; - /** Failed → press: the plain reason, the log, Try Again. */ export function FailedDialog({ update, @@ -403,11 +399,11 @@ export function FailedDialog({ onClose: () => void; }) { const code = details?.failure?.exit_code; - // Safe updates (step 1): 3 = the download didn't match the checksum - // Plannotator fetched; 75 = Plannotator couldn't download the update. + // Safe updates (step 1): the download didn't match the checksum Plannotator + // fetched, or Plannotator couldn't download the update. // Neither offers the `curl | bash` line: it would go through the same // connection that just failed. - const mismatch = code === UPDATE_EXIT_MISMATCH; + const mismatch = code === UPDATE_EXIT_CHECKSUM_MISMATCH; const couldNotDownload = code === UPDATE_EXIT_DOWNLOAD_FAILED; const sub = mismatch ? `The download didn't match the checksum Plannotator fetched for it. Nothing was changed; Plannotator ${update.current} still works. Try again later, or on another network.` diff --git a/packages/inbox/updates.test.tsx b/packages/inbox/updates.test.tsx index f236f0b068..457cb1bd19 100644 --- a/packages/inbox/updates.test.tsx +++ b/packages/inbox/updates.test.tsx @@ -9,6 +9,7 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { InboxUpdate } from '@plannotator/core/inbox-types'; import type { UpdateDetails } from './api'; +import { UPDATE_EXIT_CHECKSUM_MISMATCH, UPDATE_EXIT_DOWNLOAD_FAILED } from '@plannotator/core/update-exit-codes'; const hasDom = typeof document !== 'undefined'; // Updates.tsx draws What's new with ui's Viewer, which reads `window` at module-eval time. @@ -195,12 +196,12 @@ test.skipIf(!hasDom)('the failure dialog names the exit code and the log, and of expect(retried).toBe(1); }); -// Safe updates (step 1): after a mismatch (install.sh exit 3) or a failed +// Safe updates (step 1): after a mismatch (install.sh's pin check) or a failed // download (the wrapper's 75) the dialog must not hand the person the // `curl | bash` line: it would go through the connection that just failed. for (const [code, heading] of [ - [3, 'The update stopped'], - [75, "The update didn't install"], + [UPDATE_EXIT_CHECKSUM_MISMATCH, 'The update stopped'], + [UPDATE_EXIT_DOWNLOAD_FAILED, "The update didn't install"], ] as const) { test.skipIf(!hasDom)(`exit ${code}: no install-script line, the old version still works, Try Again stays`, () => { let retried = 0; diff --git a/packages/server/auto-update.test.ts b/packages/server/auto-update.test.ts index d062a30770..f111904892 100644 --- a/packages/server/auto-update.test.ts +++ b/packages/server/auto-update.test.ts @@ -30,6 +30,7 @@ import { } from "./auto-update"; import { RELEASE_API_FALLBACK_INTERVAL_MS, RELEASE_CHECK_RETRY_MS } from "@plannotator/shared/update-check"; import { sweepStaleUpdateDirs } from "./update-prepare"; +import { UPDATE_EXIT_CHECKSUM_MISMATCH as MISMATCH } from "@plannotator/core/update-exit-codes"; import type { SessionInfo } from "./sessions"; const NOW = 1_800_000_000_000; @@ -232,9 +233,9 @@ describe("safe-update failures in the notice", () => { const bg = { version: "0.28.0", fromVersion: "0.27.22", startedAt: NOW }; const person = { ...bg, by: "person" as const }; - test("a mismatch (exit 3) is shown once per version, whatever the attempt", () => { - const a = deriveAutoUpdateNotice({ pending: bg }, { version: "v0.28.0", exitCode: 3 }, "0.27.22", "/d/update.log"); - const b = deriveAutoUpdateNotice({ pending: { ...bg, startedAt: NOW + 5 } }, { version: "v0.28.0", exitCode: 3 }, "0.27.22", "/d/update.log"); + test("a mismatch is shown once per version, whatever the attempt", () => { + const a = deriveAutoUpdateNotice({ pending: bg }, { version: "v0.28.0", exitCode: MISMATCH }, "0.27.22", "/d/update.log"); + const b = deriveAutoUpdateNotice({ pending: { ...bg, startedAt: NOW + 5 } }, { version: "v0.28.0", exitCode: MISMATCH }, "0.27.22", "/d/update.log"); expect(a).toMatchObject({ kind: "failed", reason: "mismatch" }); expect(a!.id).toBe(b!.id); }); @@ -262,28 +263,28 @@ describe("mismatch back-off (1, 3, then 7 days)", () => { } test("each mismatch waits longer; the window is spent while it waits, so nothing loops", async () => { - expect(await attempt(NOW, 3)).toBe("launched"); + expect(await attempt(NOW, MISMATCH)).toBe("launched"); // 1 day: the next daily check tries again. - expect(await attempt(NOW + DAY, 3)).toBe("launched"); + expect(await attempt(NOW + DAY, MISMATCH)).toBe("launched"); expect(readAutoUpdateState(dataDir).mismatchBackoff).toEqual({ version: "0.28.0", attempts: 1, lastAttemptAt: NOW }); // Second mismatch: 3 days from it. - expect(await attempt(NOW + 2 * DAY, 3)).toBe("backed-off"); + expect(await attempt(NOW + 2 * DAY, MISMATCH)).toBe("backed-off"); // A back-off spends the 24 h window: the same day asks nothing more. - expect(await attempt(NOW + 2 * DAY + 60_000, 3)).toBe("throttled"); - expect(await attempt(NOW + 3 * DAY, 3)).toBe("backed-off"); - expect(await attempt(NOW + 4 * DAY, 3)).toBe("launched"); + expect(await attempt(NOW + 2 * DAY + 60_000, MISMATCH)).toBe("throttled"); + expect(await attempt(NOW + 3 * DAY, MISMATCH)).toBe("backed-off"); + expect(await attempt(NOW + 4 * DAY, MISMATCH)).toBe("launched"); // Third: 7 days, and 7 after that. - expect(await attempt(NOW + 10 * DAY, 3)).toBe("backed-off"); - expect(await attempt(NOW + 11 * DAY, 3)).toBe("launched"); - expect(await attempt(NOW + 17 * DAY, 3)).toBe("backed-off"); - expect(await attempt(NOW + 18 * DAY, 3)).toBe("launched"); + expect(await attempt(NOW + 10 * DAY, MISMATCH)).toBe("backed-off"); + expect(await attempt(NOW + 11 * DAY, MISMATCH)).toBe("launched"); + expect(await attempt(NOW + 17 * DAY, MISMATCH)).toBe("backed-off"); + expect(await attempt(NOW + 18 * DAY, MISMATCH)).toBe("launched"); expect(readAutoUpdateState(dataDir).mismatchBackoff?.attempts).toBe(4); expect(launches).toHaveLength(5); }); test("a newer release is tried at once, and other failures never back off", async () => { - expect(await attempt(NOW, 3)).toBe("launched"); - expect(await attempt(NOW + DAY, 3)).toBe("launched"); + expect(await attempt(NOW, MISMATCH)).toBe("launched"); + expect(await attempt(NOW + DAY, MISMATCH)).toBe("launched"); // v0.28.1 ships: the hold on 0.28.0 does not apply to it. const newer = await runAutoUpdateCheck(deps({ now: () => NOW + 2 * DAY, fetchLatestTag: async () => "v0.28.1" })); expect(newer).toBe("launched"); @@ -296,15 +297,15 @@ describe("mismatch back-off (1, 3, then 7 days)", () => { test("the same attempt is counted once, however many checks read its result", () => { const state = { pending: { version: "0.28.0", fromVersion: "0.27.22", startedAt: NOW } }; - const first = noteMismatchResult(state, { version: "v0.28.0", exitCode: 3 }); + const first = noteMismatchResult(state, { version: "v0.28.0", exitCode: MISMATCH }); expect(first).toEqual({ version: "0.28.0", attempts: 1, lastAttemptAt: NOW }); - expect(noteMismatchResult({ ...state, mismatchBackoff: first }, { version: "v0.28.0", exitCode: 3 })).toBe(first); + expect(noteMismatchResult({ ...state, mismatchBackoff: first }, { version: "v0.28.0", exitCode: MISMATCH })).toBe(first); }); test("Update now ignores the back-off", async () => { - expect(await attempt(NOW, 3)).toBe("launched"); - expect(await attempt(NOW + DAY, 3)).toBe("launched"); - expect(await attempt(NOW + 2 * DAY, 3)).toBe("backed-off"); + expect(await attempt(NOW, MISMATCH)).toBe("launched"); + expect(await attempt(NOW + DAY, MISMATCH)).toBe("launched"); + expect(await attempt(NOW + 2 * DAY, MISMATCH)).toBe("backed-off"); const r = startManualUpdate({ currentVersion: "0.27.22", dataDir, diff --git a/packages/server/auto-update.ts b/packages/server/auto-update.ts index a3cecaaaf3..a29e60c971 100644 --- a/packages/server/auto-update.ts +++ b/packages/server/auto-update.ts @@ -29,6 +29,7 @@ * immediately and every failure ends in a log line, never a thrown error. */ +import { UPDATE_EXIT_CHECKSUM_MISMATCH, UPDATE_EXIT_DOWNLOAD_FAILED } from "@plannotator/core/update-exit-codes"; import { spawn } from "node:child_process"; import { closeSync, @@ -99,9 +100,16 @@ export const AUTO_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000; * changed, try again later (sysexits EX_TEMPFAIL; curl, whose exit codes * install.sh can pass on, never uses it). */ -export const UPDATE_PREPARE_FAILED = 75; -/** install.sh's exit code when the binary did not match PLANNOTATOR_EXPECTED_SHA256 (the old binary is kept). */ -export const UPDATE_CHECKSUM_MISMATCH = 3; +export const UPDATE_PREPARE_FAILED = UPDATE_EXIT_DOWNLOAD_FAILED; +/** + * The recorded result when install.sh stopped because the binary did not + * match PLANNOTATOR_EXPECTED_SHA256, before it replaced anything. install.sh + * exits with it AND first writes the wrapper's marker file; the wrapper + * records it only when that marker exists and turns any other exit with this + * number into 1, so "nothing was changed" is never claimed for another error + * (install.sh runs under `set -e`; curl's own codes stay below 100). + */ +export const UPDATE_CHECKSUM_MISMATCH = UPDATE_EXIT_CHECKSUM_MISMATCH; /** Auto-update's wait before retrying a version whose download did not match: 1, 3, then 7 days. */ export const MISMATCH_BACKOFF_DAYS: readonly number[] = [1, 3, 7]; /** A lock older than this is from a run that died without cleaning up. */ @@ -197,7 +205,7 @@ export function mismatchBackoffMs(attempts: number): number { } /** - * Count a finished install whose download did not match (exit 3) into + * Count a finished install whose download did not match (UPDATE_CHECKSUM_MISMATCH) into * `mismatchBackoff`, once per install attempt (`pending.startedAt`). Returns * the back-off as it stands. Pure. */ @@ -593,7 +601,7 @@ export function startManualUpdateForThisBinary(currentVersion: string | undefine * checksums into a private temp dir, then runs that script for exactly * `version` without prompts plus the remembered install flags, with * PLANNOTATOR_EXPECTED_SHA256 so the script refuses a binary that does not - * match (exit 3, before it replaces anything). It records the exit code, + * match (UPDATE_CHECKSUM_MISMATCH, before it replaces anything). It records the exit code, * removes the temp dir and releases the lock. When the download step fails * nothing runs and the result is UPDATE_PREPARE_FAILED (75). * @@ -639,8 +647,14 @@ case "$prepared" in esac ;; esac if [ -n "$update_dir" ] && [ -f "$update_dir/install.sh" ]; then - PLANNOTATOR_EXPECTED_SHA256=$(cat "$update_dir/expected-sha256" 2>/dev/null) bash "$update_dir/install.sh" --version "$PLANNOTATOR_UPDATE_VERSION" --non-interactive "$@" /dev/null) PLANNOTATOR_UPDATE_MISMATCH_FILE="$update_dir/checksum-mismatch" bash "$update_dir/install.sh" --version "$PLANNOTATOR_UPDATE_VERSION" --non-interactive "$@" { return new Response("not found", { status: 404 }); }; const ENV = { PLANNOTATOR_INSTALL_SCRIPT_URL: "http://127.0.0.1:9/install.sh", PLANNOTATOR_RELEASE_DOWNLOAD_URL: "http://127.0.0.1:9/dl" }; -const prepare = (route: Route, version = "1.2.3", platform: NodeJS.Platform = "linux") => - prepareUpdate({ version, platform, env: ENV, tmpRoot: root, fetchImpl: fakeFetch(route) }); +const prepare = (route: Route, version = "1.2.3", platform: NodeJS.Platform = "linux", arch = "arm64") => + prepareUpdate({ version, platform, arch, env: ENV, tmpRoot: root, fetchImpl: fakeFetch(route) }); describe("prepareUpdate", () => { - test("stages the script and both CPU types' checksums in a private dir", async () => { + test("stages the script and both CPU types' checksums (this one's first) in a private dir", async () => { const r = await prepare(genuine); expect(r.ok).toBe(true); if (!r.ok) return; @@ -81,11 +82,31 @@ describe("prepareUpdate", () => { } }); - test("the install script picks the CPU type itself, so both are pinned; macOS asks for the darwin pair", () => { + test("this machine's CPU type is the one install.sh picks; macOS asks for the darwin pair", async () => { expect(candidateAssets("darwin")).toEqual(["plannotator-darwin-arm64", "plannotator-darwin-x64"]); - expect(candidateAssets("linux")).toEqual(["plannotator-linux-arm64", "plannotator-linux-x64"]); + expect(nativeAsset("darwin", "x64")).toBe("plannotator-darwin-x64"); + expect(nativeAsset("linux", "arm64")).toBe("plannotator-linux-arm64"); + expect(nativeAsset("linux", "ia32")).toBeNull(); + const x64 = await prepare(genuine, "1.2.3", "linux", "x64"); + expect(x64.ok && x64.expected).toBe(`plannotator-linux-x64=${X64} plannotator-linux-arm64=${ARM}`); + expect(await prepare(genuine, "1.2.3", "linux", "ia32")).toMatchObject({ ok: false }); }); + // A release that stops shipping one CPU type must never stop updates for the other. + for (const [name, route] of [ + ["missing", (p: string) => (p.endsWith("x64.sha256") ? new Response("", { status: 404 }) : genuine(p))], + ["unreadable", (p: string) => (p.endsWith("x64.sha256") ? new Response("") : genuine(p))], + ["unreachable", (p: string) => (p.endsWith("x64.sha256") ? Promise.reject(new Error("reset")) : genuine(p))], + ] as const) { + test(`the other CPU type's .sha256 ${name}: staged with this machine's pin only, and a note`, async () => { + const r = await prepare(route); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.expected).toBe(`plannotator-linux-arm64=${ARM}`); + expect(r.notes.join("\n")).toContain("plannotator-linux-x64.sha256"); + }); + } + // Never stuck: each of these is "couldn't download", nothing is staged, and the caller retries later. const failures: Array<[string, Route]> = [ ["offline", () => Promise.reject(new TypeError("Unable to connect. Is the computer able to access the url?"))], @@ -94,7 +115,8 @@ describe("prepareUpdate", () => { ["a missing .sha256", (p) => (p.endsWith(".sha256") ? new Response("", { status: 404 }) : genuine(p))], ["a .sha256 that is a web page", (p) => (p.endsWith(".sha256") ? new Response("sign in to the wifi") : genuine(p))], ["a short .sha256", (p) => (p.endsWith(".sha256") ? new Response("abc123 plannotator\n") : genuine(p))], - ["only one CPU type's .sha256", (p) => (p.endsWith("x64.sha256") ? new Response("", { status: 404 }) : genuine(p))], + ["this CPU type's .sha256 missing", (p) => (p.endsWith("arm64.sha256") ? new Response("", { status: 404 }) : genuine(p))], + ["this CPU type's .sha256 a web page", (p) => (p.endsWith("arm64.sha256") ? new Response("") : genuine(p))], ["a captive portal instead of the script", (p) => (p === "/install.sh" ? new Response("login") : genuine(p))], ["a script that is not Plannotator's", (p) => (p === "/install.sh" ? new Response("#!/bin/bash\necho hi\n") : genuine(p))], ["an oversized script", (p) => (p === "/install.sh" ? new Response(SCRIPT_HEAD + "#".repeat(5 * 1024 * 1024)) : genuine(p))], @@ -292,6 +314,8 @@ async function runWrapper(box: Box, port: number, extra: Record const sha = (s: string) => createHash("sha256").update(s).digest("hex"); const pins = (arm: string, x64: string) => ({ [`plannotator-${OS}-arm64`]: `${arm} x\n`, [`plannotator-${OS}-x64`]: `${x64} x\n` }); +/** The binary install.sh picks here (its `uname -m`), the same one `update-prepare` requires. */ +const NATIVE = `plannotator-${OS}-${process.arch === "x64" ? "x64" : "arm64"}`; describe.skipIf(process.platform === "win32")("POSIX update wrapper (safe updates)", () => { test("never-stuck: offline, nothing runs; 75 recorded, lock released, nothing staged, binary untouched", async () => { @@ -339,7 +363,7 @@ describe.skipIf(process.platform === "win32")("POSIX update wrapper (safe update } }, 30_000); - test("tamper, through the real install.sh: the network swaps the binary and its .sha256; exit 3, old binary kept", async () => { + test("tamper, through the real install.sh: the network swaps the binary and its .sha256; a mismatch, old binary kept", async () => { // The attacker controls install.sh's own curl (the stub serves an altered // binary with a matching .sha256), but not Plannotator's fetch, which got // the genuine checksums. @@ -379,6 +403,32 @@ describe.skipIf(process.platform === "win32")("POSIX update wrapper (safe update } }, 120_000); + test("a release missing the other CPU type's .sha256 still updates, through the real install.sh", async () => { + const binary = "#!/bin/sh\necho plannotator-new\n"; + const box = sandbox(binary); + const { server } = serve({ script: readFileSync(INSTALL_SH, "utf-8"), sums: { [NATIVE]: `${sha(binary)} x\n` } }); + try { + const r = await runWrapper(box, server.port!); + expect(r.result).toEqual({ version: "v1.2.3", exitCode: 0 }); + expect(r.out).toContain("Checksum matches the one Plannotator fetched for v1.2.3."); + expect(readFileSync(box.installed, "utf-8")).toBe(binary); + } finally { + server.stop(true); + } + }, 120_000); + + test("an exit with the mismatch number that install.sh's pin check did not mark is recorded as a plain failure", async () => { + const box = sandbox("new"); + const { server } = serve({ script: `${SCRIPT_HEAD}# PLANNOTATOR_EXPECTED_SHA256\nexit ${UPDATE_CHECKSUM_MISMATCH}\n`, sums: pins(ARM, X64) }); + try { + const r = await runWrapper(box, server.port!); + expect(r.result).toEqual({ version: "v1.2.3", exitCode: 1 }); + expect(r.lockHeld).toBe(false); + } finally { + server.stop(true); + } + }, 30_000); + test("compatibility: an older install.sh (no pin check) runs exactly as before and its exit code is recorded", async () => { const box = sandbox("new"); const argsFile = join(box.home, "args"); diff --git a/packages/server/update-prepare.ts b/packages/server/update-prepare.ts index 61f45424c8..97f26e89de 100644 --- a/packages/server/update-prepare.ts +++ b/packages/server/update-prepare.ts @@ -9,17 +9,16 @@ * * 1. download the install script (https://plannotator.ai/install.sh, or the * PLANNOTATOR_INSTALL_SCRIPT_URL test/mirror knob) and the `.sha256` of - * both binaries this OS's install script may pick (it picks the CPU type - * itself, with `uname -m`, which can differ from this process's arch - * under Rosetta); + * the binary install.sh will pick on this machine (required), plus the + * other CPU type's when it can be read (optional); * 2. write the script and `expected-sha256` into a private temp dir (0700, * files 0600) and print that dir. * * The POSIX update wrapper (auto-update.ts) runs it as `plannotator * update-prepare `, then runs the saved script with * PLANNOTATOR_EXPECTED_SHA256, which install.sh compares its own download - * against before it replaces anything (exit 3 on a mismatch, the old binary - * kept). Any failure here means nothing runs and nothing changes: exit + * against before it replaces anything (exit UPDATE_CHECKSUM_MISMATCH on a + * mismatch, the old binary kept). Any failure here means nothing runs and nothing changes: exit * UPDATE_PREPARE_FAILED, an `update:` line on stderr (update.log), and a * later retry. There is never a fallback to downloading the script with curl. * @@ -55,6 +54,17 @@ export function candidateAssets(platform: NodeJS.Platform): string[] { return [`plannotator-${os}-arm64`, `plannotator-${os}-x64`]; } +/** + * The binary install.sh will pick on this machine. It reads `uname -m` with + * no Rosetta detection, and a child of this process sees the same CPU type + * this process runs as (an x64 build under Rosetta: x86_64), so it is + * `process.arch`. Null for a CPU type install.sh does not ship. + */ +export function nativeAsset(platform: NodeJS.Platform, arch: string): string | null { + if (arch !== "arm64" && arch !== "x64") return null; + return `plannotator-${platform === "darwin" ? "darwin" : "linux"}-${arch}`; +} + /** Where release assets download from: PLANNOTATOR_RELEASE_DOWNLOAD_URL (`https:` or loopback `http:`), else GitHub. */ export function releaseDownloadUrl(env: NodeJS.ProcessEnv = process.env): string { const raw = env.PLANNOTATOR_RELEASE_DOWNLOAD_URL?.trim().replace(/\/+$/, ""); @@ -62,12 +72,15 @@ export function releaseDownloadUrl(env: NodeJS.ProcessEnv = process.env): string } export type PrepareResult = - | { ok: true; dir: string; expected: string; scriptChecksPin: boolean } + /** `notes`: the other CPU type's checksum, when it could not be read (optional; never a failure). */ + | { ok: true; dir: string; expected: string; scriptChecksPin: boolean; notes: string[] } | { ok: false; error: string }; export interface PrepareDeps { version: string; platform: NodeJS.Platform; + /** This process's CPU type (default `process.arch`): its checksum is required, the other one's is not. */ + arch?: string; env?: NodeJS.ProcessEnv; tmpRoot?: string; fetchImpl?: typeof fetch; @@ -104,7 +117,12 @@ export async function prepareUpdate(deps: PrepareDeps): Promise { if (!tag) return { ok: false, error: `not a release version: ${deps.version}` }; if (deps.platform === "win32") return { ok: false, error: "Windows updates do not use this step" }; - const assets = candidateAssets(deps.platform); + const native = nativeAsset(deps.platform, deps.arch ?? process.arch); + if (!native) return { ok: false, error: `no Plannotator build for this CPU type (${deps.arch ?? process.arch})` }; + // The native first: it is required. The other CPU type's checksum is only + // pinned when it can be read, so a release that stops shipping one CPU type + // never stops updates for the other. + const assets = [native, ...candidateAssets(deps.platform).filter((a) => a !== native)]; const base = releaseDownloadUrl(env); const [script, ...sums] = await Promise.all([ fetchText(installScriptUrl(deps.platform, env), f, timeoutMs), @@ -116,12 +134,17 @@ export async function prepareUpdate(deps: PrepareDeps): Promise { return { ok: false, error: "install script: the download is not Plannotator's install script" }; } const pairs: string[] = []; + const notes: string[] = []; for (let i = 0; i < assets.length; i++) { const r = sums[i]!; - if (!r.ok) return { ok: false, error: `${assets[i]}.sha256: ${r.error}` }; - const m = /^([0-9a-fA-F]{64})(?:\s|$)/.exec(r.text); - if (!m) return { ok: false, error: `${assets[i]}.sha256: not a SHA-256 checksum` }; - pairs.push(`${assets[i]}=${m[1]!.toLowerCase()}`); + const m = r.ok ? /^([0-9a-fA-F]{64})(?:\s|$)/.exec(r.text) : null; + if (m) { + pairs.push(`${assets[i]}=${m[1]!.toLowerCase()}`); + continue; + } + const reason = `${assets[i]}.sha256: ${r.ok ? "not a SHA-256 checksum" : r.error}`; + if (assets[i] === native) return { ok: false, error: reason }; + notes.push(`${reason} (not this machine's CPU type; skipped)`); } let dir: string | null = null; @@ -130,7 +153,7 @@ export async function prepareUpdate(deps: PrepareDeps): Promise { chmodSync(dir, 0o700); writeFileSync(join(dir, "install.sh"), body, { mode: 0o600 }); writeFileSync(join(dir, "expected-sha256"), pairs.join(" "), { mode: 0o600 }); - return { ok: true, dir, expected: pairs.join(" "), scriptChecksPin: body.includes("PLANNOTATOR_EXPECTED_SHA256") }; + return { ok: true, dir, expected: pairs.join(" "), scriptChecksPin: body.includes("PLANNOTATOR_EXPECTED_SHA256"), notes }; } catch (err) { if (dir) rmSync(dir, { recursive: true, force: true }); return { ok: false, error: `could not stage the update: ${err instanceof Error ? err.message : String(err)}` }; @@ -188,6 +211,7 @@ export async function runUpdatePrepareCommand(args: string[], deps: UpdatePrepar err(`update: could not download the update (${result.error}); nothing was changed\n`); return UPDATE_PREPARE_FAILED; } + for (const note of result.notes) err(`update: ${note}\n`); if (!result.scriptChecksPin) { err("update: this install script does not check the pinned checksum yet; it runs as before\n"); } diff --git a/scripts/install.sh b/scripts/install.sh index 84d6e18eb2..f905ee20a7 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -806,10 +806,12 @@ curl -fsSL -o "$tmp_file" "$binary_url" # this script after the running Plannotator downloaded it, and the .sha256 of # both binaries this OS may use, with its OWN connection (not this script's # curl, which on macOS still trusts the leaked eMudhra root keys). They arrive -# as PLANNOTATOR_EXPECTED_SHA256="= =": both CPU types, -# because this script picks the arch itself (uname -m). A valid value for -# ${binary_name} is the expected checksum, and the .sha256 is not fetched again; -# a mismatch exits 3 before anything is replaced. Without the variable (a +# as PLANNOTATOR_EXPECTED_SHA256="= [=]" (this machine's +# CPU type, and the other one when it was available). A valid value for +# ${binary_name} is the expected checksum, and the .sha256 is not fetched again. +# A mismatch exits 113 before anything is replaced, after touching the marker +# file the updater named in PLANNOTATOR_UPDATE_MISMATCH_FILE (the updater only +# reports a mismatch when that marker exists). Without the variables (a # person running the script, an older Plannotator) nothing changes. pinned_checksum="" for _pin_pair in ${PLANNOTATOR_EXPECTED_SHA256:-}; do @@ -845,7 +847,10 @@ if [ "$actual_checksum" != "$expected_checksum" ]; then echo "Stopped: the ${latest_tag} download doesn't match the checksum Plannotator fetched for it." >&2 echo "Nothing was changed; the Plannotator you have still works." >&2 echo "Something on the network may have changed the download. Try again later, or on another network." >&2 - exit 3 + case "${PLANNOTATOR_UPDATE_MISMATCH_FILE:-}" in + /*/checksum-mismatch) : > "$PLANNOTATOR_UPDATE_MISMATCH_FILE" 2>/dev/null || true ;; + esac + exit 113 fi echo "Checksum verification failed!" >&2 exit 1 diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 1c3ab6eda3..c65ed45b70 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -14,6 +14,7 @@ import { mkdtempSync, readFileSync, readdirSync, + rmSync, symlinkSync, writeFileSync, } from "node:fs"; @@ -2468,8 +2469,9 @@ describe.skipIf(process.platform === "win32" || !Bun.which("node"))( // Safe updates (step 1): Plannotator's own updater downloads this script and // the .sha256 of both binaries with its own connection, and hands them over in // PLANNOTATOR_EXPECTED_SHA256. The script must compare its download against -// that value before anything is replaced, exit 3 on a mismatch, and keep the -// installed binary. Without the variable it behaves exactly as before. +// that value before anything is replaced, exit 113 on a mismatch (after +// marking it in the updater's PLANNOTATOR_UPDATE_MISMATCH_FILE), and keep the +// installed binary. Without the variables it behaves exactly as before. describe.skipIf(process.platform === "win32" || !Bun.which("node"))( "install.sh PLANNOTATOR_EXPECTED_SHA256 (safe updates)", () => { @@ -2482,7 +2484,7 @@ describe.skipIf(process.platform === "win32" || !Bun.which("node"))( const other = `plannotator-${os}-${arch === "x64" ? "arm64" : "x64"}`; const OLD = "#!/bin/sh\necho plannotator-old\n"; - function run(pin: string | undefined, opts: { servedChecksum?: string } = {}) { + function run(pin: string | undefined, opts: { servedChecksum?: string; marker?: string } = {}) { const sandbox = setupInstallSandbox({ gh: "pass-all", binary }); const installed = join(sandbox.home, ".local", "bin", "plannotator"); mkdirSync(join(sandbox.home, ".local", "bin"), { recursive: true }); @@ -2491,6 +2493,7 @@ describe.skipIf(process.platform === "win32" || !Bun.which("node"))( const env: Record = { STUB_CURL_LOG: log }; if (pin !== undefined) env.PLANNOTATOR_EXPECTED_SHA256 = pin; if (opts.servedChecksum) env.STUB_CHECKSUM = opts.servedChecksum; + if (opts.marker) env.PLANNOTATOR_UPDATE_MISMATCH_FILE = opts.marker; const result = runInstallSh(sandbox, args, env); const calls = existsSync(log) ? readFileSync(log, "utf8").trim().split("\n") : []; return { ...result, calls, installed: readFileSync(installed, "utf8"), checksum: sandbox.binaryChecksum! }; @@ -2507,16 +2510,31 @@ describe.skipIf(process.platform === "win32" || !Bun.which("node"))( }); test("the pin is read for this machine's CPU type only, and in any letter case", () => { - expect(run(`${mine}=${sha(binary).toUpperCase()} ${other}=${"f".repeat(64)}`).code).toBe(0); + // Upper case is the pin itself (no fallback to the .sha256): it says so. + const upper = run(`${mine}=${sha(binary).toUpperCase()} ${other}=${"f".repeat(64)}`); + expect(upper.code).toBe(0); + expect(upper.out).toContain("Checksum matches the one Plannotator fetched for v99.9.9."); + expect(fetchedSha256(upper.calls)).toBe(false); // Only the other CPU type matches: this one is a mismatch. const r = run(`${mine}=${"f".repeat(64)} ${other}=${sha(binary)}`); - expect(r.code).toBe(3); + expect(r.code).toBe(113); }); - test("tamper: a download that differs from the pin exits 3 and keeps the installed binary", () => { + test("only this machine's pin is enough (the other CPU type's may be missing)", () => { + const r = run(`${mine}=${sha(binary)}`); + expect(r.code).toBe(0); + expect(r.out).toContain("Checksum matches the one Plannotator fetched for v99.9.9."); + expect(r.installed).toBe(binary); + }); + + test("tamper: a download that differs from the pin exits 113, marks it, and keeps the installed binary", () => { // The network serves a .sha256 that matches the altered binary: only the pin catches it. - const r = run(`${mine}=${sha("the genuine binary")} ${other}=${sha("the genuine binary")}`, { servedChecksum: sha(binary) }); - expect(r.code).toBe(3); + const markerDir = mkdtempSync(join(tmpdir(), "plannotator-mismatch-mark-")); + const marker = join(markerDir, "checksum-mismatch"); + const r = run(`${mine}=${sha("the genuine binary")} ${other}=${sha("the genuine binary")}`, { servedChecksum: sha(binary), marker }); + expect(r.code).toBe(113); + expect(existsSync(marker)).toBe(true); + rmSync(markerDir, { recursive: true, force: true }); expect(r.out).toContain("Stopped: the v99.9.9 download doesn't match the checksum Plannotator fetched for it."); expect(r.out).toContain("Nothing was changed; the Plannotator you have still works."); expect(r.out).not.toContain("curl -fsSL"); @@ -2524,6 +2542,15 @@ describe.skipIf(process.platform === "win32" || !Bun.which("node"))( expect(fetchedSha256(r.calls)).toBe(false); }); + test("the marker is only ever a file named checksum-mismatch at an absolute path", () => { + const markerDir = mkdtempSync(join(tmpdir(), "plannotator-mismatch-mark-")); + const elsewhere = join(markerDir, "some-other-file"); + const r = run(`${mine}=${"f".repeat(64)}`, { marker: elsewhere }); + expect(r.code).toBe(113); + expect(existsSync(elsewhere)).toBe(false); + rmSync(markerDir, { recursive: true, force: true }); + }); + test("a pin with no entry for this binary, or a malformed one, falls back to the .sha256 check", () => { for (const pin of [`${other}=${sha(binary)}`, `${mine}=nothex`, `${mine}=${"a".repeat(63)}`, "garbage"]) { const r = run(pin); diff --git a/scripts/smoke-update-prepare.sh b/scripts/smoke-update-prepare.sh new file mode 100755 index 0000000000..3efb0c3ec1 --- /dev/null +++ b/scripts/smoke-update-prepare.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Smoke test for safe updates (step 1) against a COMPILED binary, run by +# release.yml's smoke-binaries job on the artifacts it built. +# +# scripts/smoke-update-prepare.sh +# +# 1. ` update-prepare v999.0.0` against a loopback mirror prints +# exactly one line, the staged plannotator-update-* dir, holding the script +# and this machine's pin. Losing the dispatch in index.ts, or any stdout +# before it, fails here. +# 2. When the binary has a release version: a full `plannotator update`, run +# as the install-script-managed copy in a temp HOME, goes through the +# detached-wrapper path: the wrapper calls the binary back for +# `update-prepare` (compiled-binary detection: no script argument), then +# runs the mirror's stand-in install script with PLANNOTATOR_EXPECTED_SHA256. +# +# Everything is loopback and temp dirs: a temp HOME, TMPDIR and data dir, a +# stand-in install script written here. Nothing reaches the network and the +# real install script never runs. +set -euo pipefail + +BINARY="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +[ -x "$BINARY" ] || { echo "FAIL: $BINARY is not executable" >&2; exit 1; } + +case "$(uname -s)" in Darwin) os=darwin ;; *) os=linux ;; esac +case "$(uname -m)" in x86_64|amd64) arch=x64 ;; *) arch=arm64 ;; esac +native="plannotator-${os}-${arch}" +pin="$(printf 'e%.0s' $(seq 1 64))" + +work="$(mktemp -d)" +server_pid="" +cleanup() { + [ -n "$server_pid" ] && kill "$server_pid" 2>/dev/null || true + rm -rf "$work" +} +trap cleanup EXIT + +served="$work/served" +mkdir -p "$served/dl/v999.0.0" "$served/api" "$work/home" "$work/tmp" "$work/data" "$work/bin" +cat > "$served/install.sh" <<'SCRIPT' +#!/bin/bash +REPO="backnotprop/plannotator" +# Stand-in for install.sh (it names PLANNOTATOR_EXPECTED_SHA256, as the real one does). +printf '%s\n' "$@" > "$HOME/installer-args" +printf '%s' "${PLANNOTATOR_EXPECTED_SHA256:-}" > "$HOME/installer-pins" +echo "stand-in installer ran" +SCRIPT +printf '%s %s\n' "$pin" "$native" > "$served/dl/v999.0.0/${native}.sha256" +printf '{"tag_name":"v999.0.0","published_at":"2026-01-01T00:00:00Z","body":"## What'"'"'s New in v999.0.0\\n\\nSmoke."}' > "$served/api/latest" + +cat > "$work/bin/serve.mjs" <<'NODE' +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { writeFileSync } from "node:fs"; +import { join, normalize } from "node:path"; +const [root, portFile] = process.argv.slice(2); +const server = createServer(async (req, res) => { + const path = normalize(decodeURIComponent(new URL(req.url, "http://x").pathname)); + try { + res.end(await readFile(join(root, path))); + } catch { + res.statusCode = 404; + res.end("not found"); + } +}); +server.listen(0, "127.0.0.1", () => writeFileSync(portFile, String(server.address().port))); +NODE +node "$work/bin/serve.mjs" "$served" "$work/port" & +server_pid=$! +for _ in $(seq 1 100); do [ -s "$work/port" ] && break; sleep 0.1; done +port="$(cat "$work/port")" +base="http://127.0.0.1:${port}" + +smoke_env=( + env -i + PATH="/usr/bin:/bin" + HOME="$work/home" + TMPDIR="$work/tmp" + PLANNOTATOR_DATA_DIR="$work/data" + PLANNOTATOR_UPDATE_CHECK=0 + PLANNOTATOR_INSTALL_SCRIPT_URL="$base/install.sh" + PLANNOTATOR_RELEASE_DOWNLOAD_URL="$base/dl" + PLANNOTATOR_UPDATE_API_URL="$base/api/latest" +) + +# 1. update-prepare prints exactly the staged dir. +out="$("${smoke_env[@]}" "$BINARY" update-prepare v999.0.0)" +lines="$(printf '%s\n' "$out" | wc -l | tr -d ' ')" +case "$out" in + "$work/tmp"/plannotator-update-*) ;; + *) echo "FAIL: update-prepare printed '$out', expected one $work/tmp/plannotator-update-* path" >&2; exit 1 ;; +esac +[ "$lines" = "1" ] || { echo "FAIL: update-prepare printed $lines lines" >&2; exit 1; } +[ -f "$out/install.sh" ] || { echo "FAIL: no install.sh in $out" >&2; exit 1; } +grep -q "^${native}=${pin}" "$out/expected-sha256" || { echo "FAIL: expected-sha256 lacks ${native}" >&2; cat "$out/expected-sha256" >&2; exit 1; } +rm -rf "$out" +echo "OK: update-prepare staged the install script and the ${native} pin" + +# 2. A full update through the wrapper, when this is a release build. +version="$("${smoke_env[@]}" "$BINARY" --version | awk '{print $2}')" +if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "SKIP: ${version} is not a release version, so plannotator update does not run" + exit 0 +fi +mkdir -p "$work/home/.local/bin" +cp "$BINARY" "$work/home/.local/bin/plannotator" +chmod +x "$work/home/.local/bin/plannotator" +status=0 +"${smoke_env[@]}" "$work/home/.local/bin/plannotator" update > "$work/update.out" 2>&1 || status=$? +cat "$work/update.out" +[ "$status" = "0" ] || { echo "FAIL: plannotator update exited $status" >&2; exit 1; } +grep -q "Updated to 999.0.0" "$work/update.out" || { echo "FAIL: no 'Updated to 999.0.0'" >&2; exit 1; } +grep -qx -- "v999.0.0" "$work/home/installer-args" || { echo "FAIL: the stand-in installer did not get --version v999.0.0" >&2; exit 1; } +grep -q "${native}=${pin}" "$work/home/installer-pins" || { echo "FAIL: the installer did not get PLANNOTATOR_EXPECTED_SHA256 with ${native}" >&2; exit 1; } +if ls "$work/tmp" | grep -q '^plannotator-update-'; then echo "FAIL: the staged dir was left behind" >&2; exit 1; fi +[ ! -e "$work/data/update.lock" ] || { echo "FAIL: update.lock left behind" >&2; exit 1; } +echo "OK: plannotator update ran update-prepare from the compiled binary and passed the pin to the installer" From 511fa52a750cbdb95c6c3a4b1881b23ceb9f8f66 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 27 Oct 2026 15:55:23 -0400 Subject: [PATCH 3/8] Safe updates: fix the two CI failures the update tests had - update-prepare.test.ts "a bad .sha256 means the script never runs" served the bad checksum for arm64 only. On an x64 runner that is the other CPU type's, which update-prepare rightly skips with a note, so the script ran. The bad one is now this machine's CPU type. - inbox-updates.spec.ts served only a bare script. Update now goes through update-prepare now, which needs Plannotator's install script and the .sha256 files: the fake release serves both, and the Inbox gets PLANNOTATOR_RELEASE_DOWNLOAD_URL. - The test preload and inbox-world point PLANNOTATOR_RELEASE_DOWNLOAD_URL at a closed loopback port, next to PLANNOTATOR_INSTALL_SCRIPT_URL, so an unintended update-prepare never asks GitHub. --- packages/server/update-prepare.test.ts | 4 +++- tests/e2e/inbox-updates.spec.ts | 17 ++++++++++++++--- tests/helpers/inbox-world.ts | 1 + tests/setup/feedback-archive-off.ts | 2 ++ 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/server/update-prepare.test.ts b/packages/server/update-prepare.test.ts index c606a6887b..5fe6103510 100644 --- a/packages/server/update-prepare.test.ts +++ b/packages/server/update-prepare.test.ts @@ -351,7 +351,9 @@ describe.skipIf(process.platform === "win32")("POSIX update wrapper (safe update test("never-stuck: a bad .sha256 means the script never runs", async () => { const box = sandbox("new"); const marker = join(box.home, "script-ran"); - const { server } = serve({ script: `${SCRIPT_HEAD}touch "${marker}"\n`, sums: pins("not-a-checksum", X64) }); + // This machine's CPU type: a bad .sha256 for the other type only drops + // that pin (staged, with a note), so it would pass on arm64 and not on x64. + const { server } = serve({ script: `${SCRIPT_HEAD}touch "${marker}"\n`, sums: { ...pins(ARM, X64), [NATIVE]: "not-a-checksum x\n" } }); try { const r = await runWrapper(box, server.port!); expect(r.result?.exitCode).toBe(UPDATE_PREPARE_FAILED); diff --git a/tests/e2e/inbox-updates.spec.ts b/tests/e2e/inbox-updates.spec.ts index bc89752a7b..dcdf12a1fe 100644 --- a/tests/e2e/inbox-updates.spec.ts +++ b/tests/e2e/inbox-updates.spec.ts @@ -54,9 +54,19 @@ test.beforeAll(async () => { ); writeFileSync(join(dataDir, 'install-flags.json'), '{"v":1,"flags":["skip-gemini"]}'); - // The fake install script: records its arguments, then takes a moment so Updating… shows. - installer = createServer((_req, res) => { - res.end(`printf '%s\\n' "$@" > "$HOME/installer-args"\nsleep 3\nexit 0\n`); + // The fake release (safe updates): the install script, which records its + // arguments and takes a moment so Updating… shows, and the .sha256 files the + // running Plannotator downloads itself before it runs the script. + installer = createServer((req, res) => { + const path = new URL(req.url ?? '/', 'http://127.0.0.1').pathname; + if (path === '/install.sh') { + res.end(`#!/bin/bash\nREPO="backnotprop/plannotator"\nprintf '%s\\n' "$@" > "$HOME/installer-args"\nsleep 3\nexit 0\n`); + } else if (/^\/download\/v0\.28\.12\/plannotator-(darwin|linux)-(arm64|x64)\.sha256$/.test(path)) { + res.end(`${'c'.repeat(64)} x\n`); + } else { + res.statusCode = 404; + res.end('not found'); + } }); await new Promise((r) => installer.listen(0, '127.0.0.1', () => r())); installerPort = (installer.address() as { port: number }).port; @@ -75,6 +85,7 @@ test.beforeAll(async () => { // Never GitHub (the seeded cache answers), never the real install script. PLANNOTATOR_UPDATE_API_URL: 'http://127.0.0.1:9/releases/latest', PLANNOTATOR_INSTALL_SCRIPT_URL: `http://127.0.0.1:${installerPort}/install.sh`, + PLANNOTATOR_RELEASE_DOWNLOAD_URL: `http://127.0.0.1:${installerPort}/download`, }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/tests/helpers/inbox-world.ts b/tests/helpers/inbox-world.ts index 0c67ac8d62..de7ba0a4d8 100644 --- a/tests/helpers/inbox-world.ts +++ b/tests/helpers/inbox-world.ts @@ -111,6 +111,7 @@ export function worldEnv(w: InboxWorld): Record { PLANNOTATOR_UPDATE_API_URL: process.env.PLANNOTATOR_UPDATE_API_URL || "http://127.0.0.1:9/releases/latest", // Never the real install script: Update now in a world fails fast unless a test serves one. PLANNOTATOR_INSTALL_SCRIPT_URL: process.env.PLANNOTATOR_INSTALL_SCRIPT_URL || "http://127.0.0.1:9/install.sh", + PLANNOTATOR_RELEASE_DOWNLOAD_URL: process.env.PLANNOTATOR_RELEASE_DOWNLOAD_URL || "http://127.0.0.1:9/download", // Phones are hidden until the iPhone app ships; only the phone proofs turn them on. PLANNOTATOR_INBOX_PHONES: w.phones ? "1" : "0", // Never run a real agent's CLI: a world's PATH holds the person's own (packages/server/inbox-agents.ts). diff --git a/tests/setup/feedback-archive-off.ts b/tests/setup/feedback-archive-off.ts index 5deedc3a34..4d4f02be93 100644 --- a/tests/setup/feedback-archive-off.ts +++ b/tests/setup/feedback-archive-off.ts @@ -65,6 +65,8 @@ process.env.PLANNOTATOR_UPDATE_API_URL = "http://127.0.0.1:9/releases/latest"; // The install script the update wrapper downloads: never the real one. A closed // loopback port refuses at once, so an unintended Update now fails fast. process.env.PLANNOTATOR_INSTALL_SCRIPT_URL = "http://127.0.0.1:9/install.sh"; +// The .sha256 files `update-prepare` downloads next to it: never GitHub's. +process.env.PLANNOTATOR_RELEASE_DOWNLOAD_URL = "http://127.0.0.1:9/download"; // Every session a test starts, in this process or in a child that inherits // this environment, runs a program that exits at once instead of a browser. From 2705a76cc90feef0950f65911333fbea5be5d029 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 27 Oct 2026 17:42:01 -0400 Subject: [PATCH 4/8] Safe updates: never stuck behind a network that inspects HTTPS Final review of #2218: - A certificate error from Bun's own roots is retried once with Bun's roots plus this computer's (tls.getCACertificates("system"): the macOS keychains' trusted certificates, Linux's /etc/ssl/certs), never emSign ECC Root CA - G3. A company root that curl trusted on main now works here too. Bun still refuses the compromised eMudhra keys whatever the roots (CERT_REVOKED, never retried), so there is no curl fallback: an attacker could force one, and macOS curl accepts their chain. - Exit 75 now shows the way out in `plannotator update` and the Inbox dialog: the install command and the release page, plus NODE_EXTRA_CA_CERTS when the certificate was not trusted. A revoked certificate never gets the curl line ("try another network"). 113 is unchanged. - A relative TMPDIR no longer fails every update (resolve()). - A test pins the .sha256 names release.yml publishes to candidateAssets, and the release skill says they never change. - Comment: the mismatch code is 113, not 3. --- .agents/skills/release/SKILL.md | 1 + apps/hook/server/update-command.test.ts | 70 +++++++- apps/hook/server/update-command.ts | 25 ++- docs/agent-guides/environment.md | 4 +- docs/agent-guides/inbox-foundation.md | 2 +- packages/inbox/api.ts | 3 +- packages/inbox/components/Updates.tsx | 36 ++++- packages/inbox/updates.test.tsx | 40 ++++- packages/server/inbox-updates.test.ts | 20 +++ packages/server/inbox-updates.ts | 18 ++- packages/server/update-prepare.test.ts | 203 +++++++++++++++++++++++- packages/server/update-prepare.ts | 154 ++++++++++++++++-- packages/shared/update-check.ts | 2 +- tests/helpers/self-signed-tls.ts | 37 +++++ 14 files changed, 574 insertions(+), 41 deletions(-) create mode 100644 tests/helpers/self-signed-tls.ts diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index f7eb2ba6ce..3507f9d6e4 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -268,6 +268,7 @@ Before tagging, verify: - [ ] No scanner binary, database, generated SBOM/report, credential, or `DO_NOT_COMMIT` content is staged - [ ] For the first SBOM-enabled release, the canonical Mintlify install/verification page contains the README's SBOM scope, policy, checksum, SLSA, and CycloneDX commands (do not edit the deprecated Astro docs instead) - [ ] If `scripts/install.sh` (or `install.ps1` / `install.cmd`) changed since the last release, the owner syncs it to S3 by hand so `https://plannotator.ai/install.sh` serves the new copy. Safe updates (from 0.28.33) depend on it: an update checks the binary against the checksums Plannotator fetched only when plannotator.ai serves an `install.sh` that reads `PLANNOTATOR_EXPECTED_SHA256`. Until it does, updates still work exactly as before (an older script ignores the variable), but without that check. Confirm with `curl -fsSL https://plannotator.ai/install.sh | grep -c PLANNOTATOR_EXPECTED_SHA256` (non-zero). +- [ ] The release publishes `plannotator-darwin-arm64.sha256`, `plannotator-darwin-x64.sha256`, `plannotator-linux-arm64.sha256` and `plannotator-linux-x64.sha256` under exactly these names, with the binaries. Never rename, merge (for example into one `checksums.txt`), drop or delay them: every binary from 0.28.33 on downloads these names before it updates, and fails to update without its own (`packages/server/update-prepare.ts` `candidateAssets`; guarded by `update-prepare.test.ts`). After tagging, verify: - [ ] Release workflow completed with `release-security`, `attest`, `release`, and `npm-publish` green diff --git a/apps/hook/server/update-command.test.ts b/apps/hook/server/update-command.test.ts index 49e303852d..d4a4835a81 100644 --- a/apps/hook/server/update-command.test.ts +++ b/apps/hook/server/update-command.test.ts @@ -11,6 +11,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { runUpdateCommand, type UpdateCommandDeps } from "./update-command"; import { UPDATE_EXIT_CHECKSUM_MISMATCH } from "@plannotator/core/update-exit-codes"; +import { UPDATE_TLS_HINT } from "@plannotator/server/update-prepare"; import type { FetchReleaseResult } from "@plannotator/shared/update-check"; let dataDir: string; @@ -116,11 +117,32 @@ describe("plannotator update (seams)", () => { expect(err).not.toContain("exited with code"); }); - test("an update that couldn't be downloaded: the old version still works, no install command", async () => { + // A failed download can be lasting (a proxy, a network that inspects HTTPS): + // the person always gets a way out, except over a network that presented a + // revoked certificate (a blocked key), where curl would trust it. + test("an update that couldn't be downloaded: the old version still works, and the way out is shown", async () => { expect(await run([], { runInstaller: async () => 75 })).toBe(1); expect(err).toContain("Couldn't download the update. 0.28.10 is still installed and works. Try again later: plannotator update"); + expect(err).toContain("If this keeps happening, install it yourself:\n curl -fsSL https://plannotator.ai/install.sh | bash\n"); + expect(err).toContain("or download it from https://github.com/backnotprop/plannotator/releases/tag/v0.28.11"); expect(err).toContain("update.log"); + expect(err).not.toContain("NODE_EXTRA_CA_CERTS"); + }); + + test("a certificate neither Plannotator nor this computer trusts: also how to add the network's certificate", async () => { + const runInstaller = async (_: unknown, write: (t: string) => void) => (write(`${UPDATE_TLS_HINT.untrusted}\n`), 75); + expect(await run([], { runInstaller })).toBe(1); + expect(err).toContain(" NODE_EXTRA_CA_CERTS=/path/to/its-root.pem plannotator update\n"); + expect(err).toContain("If this keeps happening, install it yourself:\n curl -fsSL"); + }); + + test("a revoked certificate: never the install command, try another network", async () => { + const runInstaller = async (_: unknown, write: (t: string) => void) => (write(`${UPDATE_TLS_HINT.revoked}\n`), 75); + expect(await run([], { runInstaller })).toBe(1); + expect(err).toContain("the connection presented a revoked certificate. 0.28.10 is still installed and works."); + expect(err).toContain("Try again on another network: plannotator update"); expect(err).not.toContain("curl"); + expect(err).not.toContain("NODE_EXTRA_CA_CERTS"); }); test("another update holding the lock: exit 1, no second install", async () => { @@ -192,6 +214,9 @@ describe.skipIf(process.platform === "win32")("plannotator update (process)", () // Async: the fake API and installer are served from this process, so a // spawnSync would block the server it is waiting on. async function cli(...args: string[]) { + return cliWith({}, ...args); + } + async function cliWith(extraEnv: Record, ...args: string[]) { const proc = Bun.spawn(["bun", "--define", '__CLI_VERSION__="0.1.0"', entry, ...args], { env: { PATH: process.env.PATH ?? "/usr/bin:/bin", @@ -201,6 +226,7 @@ describe.skipIf(process.platform === "win32")("plannotator update (process)", () PLANNOTATOR_UPDATE_API_URL: `http://127.0.0.1:${server.port}/releases/latest`, PLANNOTATOR_INSTALL_SCRIPT_URL: `http://127.0.0.1:${server.port}/install.sh`, PLANNOTATOR_RELEASE_DOWNLOAD_URL: `http://127.0.0.1:${server.port}/download`, + ...extraEnv, }, stdout: "pipe", stderr: "pipe", @@ -237,12 +263,12 @@ describe.skipIf(process.platform === "win32")("plannotator update (process)", () expect(readdirSync(home).filter((n) => n.startsWith("plannotator-update-"))).toEqual([]); }, 60_000); - test("a checksum Plannotator can't read means nothing runs: exit 1, the old version kept, no install command", async () => { + test("a checksum Plannotator can't read means nothing runs: exit 1, the old version kept, the way out shown", async () => { sumsBroken = true; const r = await cli(); expect(r.exitCode).toBe(1); expect(r.stderr).toContain("Couldn't download the update. 0.1.0 is still installed and works."); - expect(r.stderr).not.toContain("curl"); + expect(r.stderr).toContain("If this keeps happening, install it yourself:\n curl -fsSL https://plannotator.ai/install.sh | bash\n"); expect(existsSync(join(home, "installer-args"))).toBe(false); expect(existsSync(join(dataDir, "update.lock"))).toBe(false); expect(JSON.parse(readFileSync(join(dataDir, "update-result.json"), "utf-8"))).toEqual({ version: "v9.9.9", exitCode: 75 }); @@ -250,6 +276,44 @@ describe.skipIf(process.platform === "win32")("plannotator update (process)", () expect(readdirSync(home).filter((n) => n.startsWith("plannotator-update-"))).toEqual([]); }, 60_000); + // A network that inspects HTTPS with its own root, which neither Bun's roots + // nor this test's computer store hold: the stand-in serves the same release + // over HTTPS with a certificate from a private root. The update must either + // succeed or fail with the way out, and the way out it names must work. + test("a network that inspects HTTPS: the way out is shown, and NODE_EXTRA_CA_CERTS gets the update through", async () => { + const { makeSelfSignedTls } = await import("../../../tests/helpers/self-signed-tls"); + const certs = join(home, "certs"); + mkdirSync(certs); + const tls = makeSelfSignedTls(certs); + const inspected = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + tls: { cert: tls.cert, key: tls.key }, + fetch: (req) => fetch(`http://127.0.0.1:${server.port}${new URL(req.url).pathname}`), + }); + try { + const overHttps = { + PLANNOTATOR_INSTALL_SCRIPT_URL: `https://127.0.0.1:${inspected.port}/install.sh`, + PLANNOTATOR_RELEASE_DOWNLOAD_URL: `https://127.0.0.1:${inspected.port}/download`, + }; + const refused = await cliWith(overHttps); + expect(refused.exitCode).toBe(1); + expect(existsSync(join(home, "installer-args"))).toBe(false); + expect(JSON.parse(readFileSync(join(dataDir, "update-result.json"), "utf-8"))).toEqual({ version: "v9.9.9", exitCode: 75 }); + expect(refused.stderr).toContain("If this keeps happening, install it yourself:\n curl -fsSL https://plannotator.ai/install.sh | bash\n"); + expect(refused.stderr).toContain("or download it from https://github.com/backnotprop/plannotator/releases/tag/v9.9.9\n"); + expect(refused.stderr).toContain(" NODE_EXTRA_CA_CERTS=/path/to/its-root.pem plannotator update\n"); + expect(existsSync(join(dataDir, "update.lock"))).toBe(false); + + const through = await cliWith({ ...overHttps, NODE_EXTRA_CA_CERTS: tls.caFile }); + expect(through.exitCode, through.stderr).toBe(0); + expect(through.stdout).toContain("Updated to 9.9.9."); + expect(readFileSync(join(home, "installer-pins"), "utf-8")).toContain(`=${"a".repeat(64)}`); + } finally { + inspected.stop(true); + } + }, 90_000); + test("a failing install script exits 1 and says where the log is", async () => { installerExit = 4; const r = await cli(); diff --git a/apps/hook/server/update-command.ts b/apps/hook/server/update-command.ts index dca33b1c28..68f30290b9 100644 --- a/apps/hook/server/update-command.ts +++ b/apps/hook/server/update-command.ts @@ -42,6 +42,7 @@ import { UPDATE_PREPARE_FAILED, type InstallerLaunch, } from "@plannotator/server/auto-update"; +import { tlsFailureIn } from "@plannotator/server/update-prepare"; import { UPDATE_COMMAND_USAGE } from "./cli"; export interface UpdateCommandDeps { @@ -158,7 +159,11 @@ export async function runUpdateCommand(args: string[], deps: UpdateCommandDeps): : `Installing v${latest}.\n`, ); const run = deps.runInstaller ?? runInstallerForeground; - const code = await run(begun.launch, out); + let printed = ""; + const code = await run(begun.launch, (text) => { + printed += text; + out(text); + }); if (code === 130) { err( `The update was interrupted. Plannotator is still ${current}; the lock is released, so run plannotator update again when you like.\n`, @@ -176,8 +181,26 @@ export async function runUpdateCommand(args: string[], deps: UpdateCommandDeps): return 1; } if (code === UPDATE_PREPARE_FAILED) { + const tls = tlsFailureIn(printed); + if (tls === "revoked") { + // A blocked key: the network is the problem, and curl would trust it. + err( + `Couldn't download the update: the connection presented a revoked certificate. ${current} is still installed and works.\n` + + `Don't install Plannotator over this network. Try again on another network: plannotator update\n` + + `Details: ${autoUpdatePaths(dataDir).log}\n`, + ); + return 1; + } + // A failed download can be a lasting local condition (a proxy, a network + // that inspects HTTPS), so the way out is always shown. err( `Couldn't download the update. ${current} is still installed and works. Try again later: plannotator update\n` + + `If this keeps happening, install it yourself:\n ${command}\n` + + `or download it from ${releaseUrlFor(latest)}\n` + + (tls === "untrusted" + ? "If your network inspects HTTPS with its own certificate, point NODE_EXTRA_CA_CERTS at that certificate:\n" + + " NODE_EXTRA_CA_CERTS=/path/to/its-root.pem plannotator update\n" + : "") + `Details: ${autoUpdatePaths(dataDir).log}\n`, ); return 1; diff --git a/docs/agent-guides/environment.md b/docs/agent-guides/environment.md index 4910a15a66..ae5ffff050 100644 --- a/docs/agent-guides/environment.md +++ b/docs/agent-guides/environment.md @@ -41,7 +41,7 @@ | `PLANNOTATOR_BITBUCKET_API_URL` | Testing / proxy knob: base URL of the Bitbucket Cloud REST API (default `https://api.bitbucket.org/2.0`). The fake API in `tests/test-fixtures/bitbucket/fake-bitbucket.ts` is reached through it. Only `https:` or loopback `http:` is accepted (credentials are sent there); anything else warns once and uses the default. Redirects (the PR `/diff` and `/diffstat` endpoints answer with one) and pagination `next` links are followed only within this origin. | | `PLANNOTATOR_GUIDE_HISTORY` | Set to `0` / `false` to disable persisting successful Guided Reviews (no guide copies are written to the data dir; the "Previous guides" list is then never populated, though already-saved guides remain readable and listed). **Note that a persisted guide includes a full copy of the diff it was generated against** — `history/.../guides/{id}.patch` beside the `{id}.json` envelope, uncapped, as large as the diff — because that patch is what a later portable export or share link renders (the diff is captured when the guide job launches, never re-read from the working tree). Deleting a guide removes both files; nothing prunes the directory otherwise. Turning this flag off skips the patch copy too, at the cost of exports and share links for guides from that session once the server exits. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "guideHistory": false }`); the env var takes precedence. | | `PLANNOTATOR_REVIEW_PROGRESS` | Set to `0` / `false` (also `off` / `disabled`) to stop code review remembering which files you marked viewed across review sessions. Default: enabled, which writes one small JSON record per file you mark viewed or unviewed under `review-progress/{scopeHash}/{pathHash}.json` in the data dir (`~/.plannotator` or `PLANNOTATOR_DATA_DIR`), for local Git and PR reviews. **Each record carries the repo-relative file path in plain text** (plus a content fingerprint and the viewed flag; the scope directory name is a hash of the worktree root, branch and comparison, or of the PR URL). Nothing prunes the directory; delete `review-progress/` to forget, and `plannotator uninstall --purge` removes it (a plain uninstall keeps it). With the knob off the review server neither reads nor writes these records: `/api/review-progress` answers `{ available: false }` and viewed marks ride the annotation draft instead, exactly as in workspace, non-Git and piped-patch reviews. Records already on disk are left alone. Can also be set via `~/.plannotator/config.json` (`{ "reviewProgress": false }`); the env var takes precedence, and an empty or unrecognized env value counts as unset. Resolved by `resolveReviewProgress` in `packages/shared/config.ts`, read on every review snapshot in both runtimes. | -| `PLANNOTATOR_AUTO_UPDATE` | Opt-in background auto-update (#1634). Set to `1` / `true` / `on` to enable, `0` / `false` / `off` / `disabled` to force off; empty or unrecognized counts as unset. **Default: off**; nothing changes for anyone who has not opted in. Can also be set via `~/.plannotator/config.json` (`{ "autoUpdate": true }`) or Settings → General → "Keep Plannotator up to date" (allowlisted on `POST /api/config` in both runtimes; `getServerConfig` always sends the config-file value plus `autoUpdateEnv` when this env var overrides it, and the toggle is disabled with a note in that case); the env var takes precedence (`resolveAutoUpdate` in `packages/shared/config.ts`). The whole mechanism is "run the normal install script": the compiled CLI (`apps/hook/server/index.ts`, `registerCliSession`) calls `scheduleAutoUpdateCheck` (`packages/server/auto-update.ts`) each time a session registers, which returns immediately and runs on a later, unref'd tick, so it is never awaited and adds no latency to any hook, review, annotate or CLI command. That tick first runs the shared release check (`PLANNOTATOR_UPDATE_CHECK` row: at most one request every 10 minutes per data dir, to the release page), then auto-update, which answers from the same `latestRelease` cache and never runs while update checks are off (outcome `checks-off`). The check is skipped for dev/source runs (no `__CLI_VERSION__`), when the running executable is not the file the install script manages (`~/.local/bin/plannotator`, `%LOCALAPPDATA%\plannotator\plannotator.exe`), and is throttled to once per 24h (`lastCheckAt` in `update-state.json` in the data dir). It reads the newest stable release from the shared cache (asking GitHub `releases/latest` only when the cache is stale; strictly newer, never a downgrade); if another Plannotator session is open (a live pid other than ours in the `sessions/` registry) it logs, does NOT spend the 24h window, and retries on the next session start. Otherwise it takes `update.lock` (stale after 1h) and spawns a detached wrapper that runs the install script with `--version vX.Y.Z --non-interactive` (`-Version … -NonInteractive`), all output to `update.log`; the wrapper writes the exit code to `update-result.json` and releases the lock. On macOS and Linux (safe updates, step 1) the wrapper never downloads with `curl`: it runs the same binary's internal `update-prepare vX.Y.Z` (`packages/server/update-prepare.ts`), whose own fetch downloads `https://plannotator.ai/install.sh` and the `.sha256` of the binary install.sh will pick on this machine (`process.arch`; required) plus the other CPU type's when it can be read (optional, so a release that drops one CPU type never stops updates for the other) into a private `plannotator-update-*` temp dir (0700; removed on exit, Ctrl-C and TERM; a killed run's dir is swept by the next update after 1h), then runs that saved script with `PLANNOTATOR_EXPECTED_SHA256` (row below). If that download fails (offline, a proxy, a bad `.sha256`, a page that is not the script) nothing runs and the result is exit `75`. Windows downloads `install.ps1` itself, as before. A download that did not match (`install.sh` exit `113` plus the wrapper's `checksum-mismatch` marker, nothing replaced; codes in `packages/core/update-exit-codes.ts`) is retried by auto-update after 1, then 3, then 7 days (`mismatchBackoff: { version, attempts, lastAttemptAt }` in `update-state.json`, outcome `backed-off`; the 24h window is still spent, a newer release is tried at once, Update now and `plannotator update` ignore it). The script applies the user's own `skipInstall` config and saved install prefs as a manual run would, and install flags are remembered: after a successful run each installer (`install.sh`, `install.ps1`, `install.cmd`, including the `--minimal` early exit) writes the install-affecting choices it took from its COMMAND LINE to `install-flags.json` in the data dir as neutral ids (`{"v":1,"flags":["minimal","skip-codex"]}`; the set is `minimal`, `no-minimal`, `verify-attestation`, `skip-attestation`, `with-call-flow`, `skip-codex`, `skip-gemini`, `skip-kiro`, `skip-vibe`, `skip-opencode`, `skip-skills`, `skip-claude-plugin`; never raw argv, paths or secrets), and a manual run with none of them overwrites it with an empty set, i.e. back to defaults. Auto-update reads it (`readInstallFlags`), keeps only allowlisted ids (unknown entries and a mutually-exclusive pair are dropped and logged), and passes them re-spelled per installer (`--skip-codex` / `-SkipCodex`): as argv after the `sh -c` wrapper (`"$@"`) on POSIX, and on Windows as the `PLANNOTATOR_UPDATE_FLAGS` env var that the `-EncodedCommand` wrapper re-checks against the same allowlist and splats as an array. A missing or malformed file means no flags (today's behavior) plus an `update.log` line. Inside the script the flag still beats the env var, which beats config. The OpenCode and Pi runtimes never arm the trigger. The next UI load (plan, annotate, review) shows a one-time toast from `autoUpdateNotice` on `/api/plan` / `/api/diff` (Bun servers only, and only when the compiled CLI enabled it): "Updated to vX" once the running binary is that version, or "Auto-update failed" with the log path when the script exited non-zero (exit `113`: "Update to vX stopped", once per version, `reason: "mismatch"`; exit `75` from a background run shows nothing, since nothing changed); "seen" is the `plannotator-auto-update-notice-seen` cookie. Install scripts replace the binary safely for this: `install.sh` stages next to the target and renames over it (a running process keeps its inode), `install.ps1` / `install.cmd` rename a running `plannotator.exe` aside to `plannotator.exe.old*` and sweep those on a later run. `plannotator uninstall --purge` removes the `update*` files (`update.log.1` included) and `install-flags.json`. | +| `PLANNOTATOR_AUTO_UPDATE` | Opt-in background auto-update (#1634). Set to `1` / `true` / `on` to enable, `0` / `false` / `off` / `disabled` to force off; empty or unrecognized counts as unset. **Default: off**; nothing changes for anyone who has not opted in. Can also be set via `~/.plannotator/config.json` (`{ "autoUpdate": true }`) or Settings → General → "Keep Plannotator up to date" (allowlisted on `POST /api/config` in both runtimes; `getServerConfig` always sends the config-file value plus `autoUpdateEnv` when this env var overrides it, and the toggle is disabled with a note in that case); the env var takes precedence (`resolveAutoUpdate` in `packages/shared/config.ts`). The whole mechanism is "run the normal install script": the compiled CLI (`apps/hook/server/index.ts`, `registerCliSession`) calls `scheduleAutoUpdateCheck` (`packages/server/auto-update.ts`) each time a session registers, which returns immediately and runs on a later, unref'd tick, so it is never awaited and adds no latency to any hook, review, annotate or CLI command. That tick first runs the shared release check (`PLANNOTATOR_UPDATE_CHECK` row: at most one request every 10 minutes per data dir, to the release page), then auto-update, which answers from the same `latestRelease` cache and never runs while update checks are off (outcome `checks-off`). The check is skipped for dev/source runs (no `__CLI_VERSION__`), when the running executable is not the file the install script manages (`~/.local/bin/plannotator`, `%LOCALAPPDATA%\plannotator\plannotator.exe`), and is throttled to once per 24h (`lastCheckAt` in `update-state.json` in the data dir). It reads the newest stable release from the shared cache (asking GitHub `releases/latest` only when the cache is stale; strictly newer, never a downgrade); if another Plannotator session is open (a live pid other than ours in the `sessions/` registry) it logs, does NOT spend the 24h window, and retries on the next session start. Otherwise it takes `update.lock` (stale after 1h) and spawns a detached wrapper that runs the install script with `--version vX.Y.Z --non-interactive` (`-Version … -NonInteractive`), all output to `update.log`; the wrapper writes the exit code to `update-result.json` and releases the lock. On macOS and Linux (safe updates, step 1) the wrapper never downloads with `curl`: it runs the same binary's internal `update-prepare vX.Y.Z` (`packages/server/update-prepare.ts`), whose own fetch downloads `https://plannotator.ai/install.sh` and the `.sha256` of the binary install.sh will pick on this machine (`process.arch`; required) plus the other CPU type's when it can be read (optional, so a release that drops one CPU type never stops updates for the other) into a private `plannotator-update-*` temp dir (0700; removed on exit, Ctrl-C and TERM; a killed run's dir is swept by the next update after 1h), then runs that saved script with `PLANNOTATOR_EXPECTED_SHA256` (row below). If that download fails (offline, a proxy, a bad `.sha256`, a page that is not the script) nothing runs and the result is exit `75`. A certificate error from Bun's own roots (a network that inspects HTTPS with a company root, which curl trusted through the system store) is retried once with Bun's roots plus the system's (`systemTrustedCa`: `tls.getCACertificates("system")`, the macOS keychains' trusted certificates or Linux's `/etc/ssl/certs`, never emSign ECC Root CA - G3); Bun refuses the compromised eMudhra keys whatever the roots (`CERT_REVOKED`, never retried), and there is never a fallback to `curl`, which on macOS accepts them. When both fail, `update-prepare` prints `UPDATE_TLS_HINT` (`untrusted`: run `NODE_EXTRA_CA_CERTS=/path/to/its-root.pem plannotator update`; `revoked`: try another network), which `plannotator update` and the Inbox read back. Windows downloads `install.ps1` itself, as before. A download that did not match (`install.sh` exit `113` plus the wrapper's `checksum-mismatch` marker, nothing replaced; codes in `packages/core/update-exit-codes.ts`) is retried by auto-update after 1, then 3, then 7 days (`mismatchBackoff: { version, attempts, lastAttemptAt }` in `update-state.json`, outcome `backed-off`; the 24h window is still spent, a newer release is tried at once, Update now and `plannotator update` ignore it). The script applies the user's own `skipInstall` config and saved install prefs as a manual run would, and install flags are remembered: after a successful run each installer (`install.sh`, `install.ps1`, `install.cmd`, including the `--minimal` early exit) writes the install-affecting choices it took from its COMMAND LINE to `install-flags.json` in the data dir as neutral ids (`{"v":1,"flags":["minimal","skip-codex"]}`; the set is `minimal`, `no-minimal`, `verify-attestation`, `skip-attestation`, `with-call-flow`, `skip-codex`, `skip-gemini`, `skip-kiro`, `skip-vibe`, `skip-opencode`, `skip-skills`, `skip-claude-plugin`; never raw argv, paths or secrets), and a manual run with none of them overwrites it with an empty set, i.e. back to defaults. Auto-update reads it (`readInstallFlags`), keeps only allowlisted ids (unknown entries and a mutually-exclusive pair are dropped and logged), and passes them re-spelled per installer (`--skip-codex` / `-SkipCodex`): as argv after the `sh -c` wrapper (`"$@"`) on POSIX, and on Windows as the `PLANNOTATOR_UPDATE_FLAGS` env var that the `-EncodedCommand` wrapper re-checks against the same allowlist and splats as an array. A missing or malformed file means no flags (today's behavior) plus an `update.log` line. Inside the script the flag still beats the env var, which beats config. The OpenCode and Pi runtimes never arm the trigger. The next UI load (plan, annotate, review) shows a one-time toast from `autoUpdateNotice` on `/api/plan` / `/api/diff` (Bun servers only, and only when the compiled CLI enabled it): "Updated to vX" once the running binary is that version, or "Auto-update failed" with the log path when the script exited non-zero (exit `113`: "Update to vX stopped", once per version, `reason: "mismatch"`; exit `75` from a background run shows nothing, since nothing changed); "seen" is the `plannotator-auto-update-notice-seen` cookie. Install scripts replace the binary safely for this: `install.sh` stages next to the target and renames over it (a running process keeps its inode), `install.ps1` / `install.cmd` rename a running `plannotator.exe` aside to `plannotator.exe.old*` and sweep those on a later run. `plannotator uninstall --purge` removes the `update*` files (`update.log.1` included) and `install-flags.json`. | | `PLANNOTATOR_UPDATE_CHECK` | Update notices (owner-approved design; design record, not in this repo). **Default: on.** `0` / `false` / `off` / `disabled` turn it off, `1` / `true` / `on` force it on; empty or unrecognized counts as unset. Also `~/.plannotator/config.json` `{ "updateCheck": false }` (allowlisted on `POST /api/config` in all six servers; `getServerConfig` always sends `updateCheck`, the config-file value, plus `updateCheckEnv` when this env var overrides it); the env var wins (`resolveUpdateCheck` in `packages/shared/config.ts`). The machine asks, never the browser (the review windows' own GitHub fetch is gone, below): `runReleaseCheck` in `packages/shared/update-check.ts` (vendored to Pi) reads the newest release from GitHub's "latest release" link: one unauthenticated `HEAD https://github.com/backnotprop/plannotator/releases/latest` with `user-agent: plannotator-update-check` (no version, no cookies), read from its `302` to `…/releases/tag/vX.Y.Z` without following it (`fetchLatestFromPage`; the `Location` counts only on the same origin at exactly the request path with `latest` replaced by `tag/v`, `versionFromLatestRedirect`; a runtime that followed it anyway is read from `response.url`). That link is not under the API's 60 requests per hour per IP, is right the moment a release is published (drafts and pre-releases are never "latest", nor is a tag with no release), carries no body, and needs nothing from the release pipeline. It asks at most once every 10 minutes per data dir (`RELEASE_CHECK_INTERVAL_MS`; owner's decision 2026-10-24, so a new release shows within minutes), and an Inbox start or an Inbox page load asks when the answer is over a minute old (`RELEASE_CHECK_ON_START_MS`); it claims the attempt in `update-state.json` (`releaseCheck.attemptedAt`) before the request so concurrent starts ask once, and after a failure (offline, a 5xx, a proxy) waits 10 minutes whatever triggers the next check (a start or a reload included), never retrying in a loop (`RELEASE_CHECK_RETRY_MS`). When the page itself refuses for a rate limit (429, or 403 with `Retry-After` / `x-ratelimit-remaining: 0`: an office NAT can meet github.com's limits too), the next check waits the time it names, kept between 10 minutes and an hour, an hour when it names none (`RELEASE_PAGE_RATE_LIMITED_RETRY_MS`, `releaseCheck.pageRetryAt`, cleared by a success). When the page fails, the API is the fallback (`GET https://api.github.com/repos/backnotprop/plannotator/releases/latest`, `fetchLatestRelease`), asked at most once an hour (`RELEASE_API_FALLBACK_INTERVAL_MS`, `releaseCheck.apiAttemptedAt`); a rate-limited API answer (403/429) holds back only the API, until GitHub's `Retry-After` or `x-ratelimit-reset`, kept between 1 h and 6 h, else 6 h (`RELEASE_CHECK_RATE_LIMITED_RETRY_MS`, `releaseCheck.apiRetryAt`; a `retryAt` written by 0.28.30 is read the same way), never the page: before this change the API was the only source, and one 403 hid every notice for hours. A failure keeps the last known `latestRelease` and writes an `update.log` line only when the errors change (`releaseCheck.logged`, `failures` counts the run; the line after a change and the first success say how many failed before), so an offline machine does not add a line every 10 minutes; `update.log` moves to `update.log.1` once it passes 1 MiB (`UPDATE_LOG_MAX_BYTES`, `uninstall --purge` removes both); `releaseCheck.source` says which source answered (`page` or `api`). The tag is reduced to a canonical `X.Y.Z` before it is stored (pre-releases and junk are failures). The redirect carries no notes or publish date: the API answer does, and the Inbox fetches a newer release's notes by its tag (`fetchReleaseNotesFor`, throttled per version) when it learns of it, which also fills in `latestRelease.publishedAt`. Besides those requests, the Inbox (and only the Inbox) downloads, in the background whether or not What's new is opened, up to 12 images per release that the saved notes show, from GitHub's release-attachment image hosts (`github.com/user-attachments`, `private-user-images.githubusercontent.com`, `user-images.githubusercontent.com`, this repository on `raw.githubusercontent.com`), with the same user agent and a per-version claim and back-off (`releaseImagesFetch`); only while update checks are on (see "Images in the notes" under the Inbox's update control). The release notes (the body from `## What's New in` up to `## Install / Update`, 64 KB cap, plus the "Missed recent releases?" table) go to `update-notes/.json`. Who asks: the compiled CLI at `registerCliSession` (the same unref'd tick as auto-update; never in a build that is not a release, whose `__CLI_VERSION__` is absent or not `X.Y.Z`, e.g. `0.0.0-dev`); and the Plannotator Inbox of a release build, on start, on each page load (`GET /`, `updates.nudge()`) and on each health tick (`packages/server/inbox-updates.ts`; each answers from the cache within its window); and the Pi extension, on each review-window payload (due-gated like the rest) (`apps/pi-extension/server/update-advert.ts`). Everything else reads the cache only. Off means no automatic request to GitHub at all (machine or browser): no terminal hint, no review-window notice, no Inbox Update button for a release that is only available (a binary already on disk still shows Ready), and no auto-update; an explicit `plannotator update` (or `--check`) still asks, and says so on stderr. The terminal hint: after a `review` / `annotate` / `annotate-last` / `last` / `archive` session the compiled CLI prints `Plannotator X is available (you have Y). Update: plannotator update` on stderr, only when stderr is a TTY, never with `--json` / `--hook`, never with `CLAUDECODE` or `PLANNOTATOR_SESSION_TAG` set (an agent's shell or the mod), at most once per 24h (`hintShownAt`), read from the cache (`takeUpdateHint`). `plannotator --version` is unchanged (the Inbox parses it). `plannotator update [--check]` (`apps/hook/server/update-command.ts`) forces a check, then runs the SAME wrapper as auto-update in the foreground (`beginManualUpdate` + `runInstallerForeground`: `update.lock`, pinned tag, remembered `install-flags.json`, output shown and appended to `update.log`, `update-result.json`), without auto-update's 24h window and its open-sessions gate (open sessions keep their old binary); it refuses a build that is not a release (no request at all) and a binary the install script does not manage (printing the install command with the remembered flags); exit 0 up to date / updated / `--check`, 1 failed or could not run, 2 usage, 130 interrupted. Interruption: the POSIX wrapper traps INT / TERM / HUP and the PowerShell wrapper writes its result in `finally`, so a Ctrl-C records exit 130 in `update-result.json` and removes `update.lock` at once (it never waits out the 1h stale-lock window); `runInstallerForeground` passes an INT / TERM sent only to the CLI on to the wrapper, and a wrapper killed outright still ends with the lock released and `-1` recorded (`abortLaunch`). `update-command-entry.ts` runs the same function alone, for the process tests (the full entry imports the built HTML). `uninstall --purge` also removes `update-notes/`. Review windows (design §4.5, owner's answer 3): the browser never asks GitHub any more (`useUpdateCheck` lost its fetch; `?preview-update=X` still simulates one). The compiled CLI's plan, review and annotate servers add `updateAvailable: { version, current, releaseUrl, action, command, publishedAt? }` to `/api/plan` and `/api/diff` (`getUpdateAdvert(origin)` in `packages/server/auto-update.ts`, read from the cache only), where `action` is `opencode` for an OpenCode-launched CLI, `script` for the binary the install script manages in a session only this computer reaches, `copy` otherwise (an unmanaged binary, and every remote-mode or `--tailscale` session, so a device on the LAN or tailnet is shown the command, never offered the installer), and `command` is the install command with the remembered flags (`installCommandFor`; PowerShell scriptblock form on Windows). Pi's three servers send the same field with `action: "pi"` and `command: "pi update --extensions"`, compared with the Pi package's own version (`apps/pi-extension/server/update-advert.ts`, which also schedules the release check on each payload from the vendored module; nothing for `oh-my-pi`). Known gap: OpenCode's in-process embedded plan server sends nothing (it knows no version). The toast (`useUpdateToast`, `packages/ui/hooks/`) shows at most once per version and at most once a day (24 h) across versions (`claimUpdateToast`, cookie `plannotator-update-toast` = `@`, shared by every localhost port), first looks 5 s after load (past the first-run announcements' own window), waits while a first-run dialog of this load shows or a composer / dialog has focus (re-checking every 5 s for up to 2 min, unconsumed), never for a share link, archive or a session without a server, and never while auto-update is active and can install (`isAutoUpdateActive`: auto-update on, checks on, the managed binary, and `autoUpdateCanInstall`: an install is running, or auto-update's 24 h window has elapsed and no other Plannotator session is open; auto-update installs at most once a day and skips every start that finds another session open, so without these a person was not told for up to a day, or never while a session was always open). The sessions it counts come from `listLiveSessions` (`packages/server/sessions.ts`), a read-only lister that never deletes a file, since this runs on every `/api/plan` and `/api/diff`; `registerSession` writes atomically (temp + rename), so `listSessions`' cleanup never takes a half-written file for a corrupt one. Its actions are **What's new** (the GitHub release) and, for `script`, **Update** (`POST /api/update`, `handleUpdateRoute`: `403 forbidden_host` unless the Host is loopback with this server's port (`isLoopbackHostHeader`), `403` cross-origin, `403 update_exposed` for a remote-mode or `--tailscale` session, `409 session_mismatch` for a body `serverSession` naming another session (the client sends it, `withServerSession`); then `startManualUpdateForThisBinary`, the same wrapper and `update.lock` as the Inbox's Update now; `409 update_locked` / `update_unmanaged` / `update_dev_build` / …), else **Copy command**. The Options button keeps its dot (no label shimmer any more) and its version section shows "X is available · What's new", the host's command with Copy, and Update now for `script`. Settings → General gains "Tell me when a new version is out" (`useUpdateCheckSetting`, shown when `serverConfig.updateCheck` is present; written with `POST /api/config`, deliberately NOT a settings-registry entry, because the registry is bundled into the pinned guides.show viewer and must not change its build) above "Keep Plannotator up to date", which is locked with "Needs update checks" while checks are off. When checking fails: with checks on, a check that has failed for 3 hours with no success (`releaseCheck.failingSince`, `releaseCheckFailing`) is shown plainly with the install command, in the Inbox (sidebar row, Settings → General and About), the review windows' Options menu (`updateCheckFailing`, Bun and Pi), the terminal hint and `plannotator update`; off, nothing is shown (Check now and `plannotator update` still ask). See "No one stuck on a version" in `inbox-foundation.md`. | | `PLANNOTATOR_UPDATE_API_URL` | Testing / mirror knob for the update check: the URL answering like GitHub's `releases/latest` (`releaseApiUrl` in `packages/shared/update-check.ts`). Only `https:` or loopback `http:` is accepted; anything else is ignored. One release's notes by its tag go to the URL resolved against it (`tags/v` in place of its last segment, `releaseTagApiUrl`). The `plannotator update` process test serves a fake one on loopback. A loopback `http:` value also lets the Inbox fetch What's new images from that same origin (`allowedReleaseImageUrl` in `packages/server/inbox-whats-new-images.ts`); any other value adds no image host. Set alone (no `PLANNOTATOR_UPDATE_LATEST_URL`), it is the ONLY source: the release page on github.com is not asked (`releasePageUrl` answers null), so a GHE proxy or a test fake is never bypassed; the API is then asked at most once an hour. The test preload sets it to a closed loopback port, which is what keeps every bun test away from github.com. | | `PLANNOTATOR_TELEMETRY` | Hard off for usage counts of experimental features (see "Usage counts for experimental features"). `0` / `false` / `off` / `disabled` / `no` turn counts off everywhere, whatever config.json says (counting also needs the feature on in config.json and its notice shown, config.json `usageCountsNotice`): nothing is recorded, nothing is sent, and the counts on disk that were not sent are deleted the next time the recorder or sender runs. No value turns counts on (`1` is the same as unset). Read on each record and each send (`usageCountsEnvBlock` in `packages/shared/usage-counts.ts`). | @@ -50,7 +50,7 @@ | `PLANNOTATOR_UPDATE_LATEST_URL` | Testing / mirror knob for the update check's primary source: a URL answering like GitHub's `https://github.com///releases/latest`, i.e. `302` to `…/releases/tag/vX.Y.Z` on the same origin (`releasePageUrl` / `fetchLatestFromPage` in `packages/shared/update-check.ts`). Only `https:` or loopback `http:` is accepted; anything else is ignored. Used by `apps/hook/server/update-notices-cli.test.ts` and `tests/e2e/inbox-update-notice.spec.ts`. | | `PLANNOTATOR_INSTALL_SCRIPT_URL` | Testing / mirror knob: the install script an update downloads (`installScriptUrl` in `packages/server/auto-update.ts`; default `https://plannotator.ai/install.sh`, `install.ps1` on Windows), for `plannotator update`, Update now and auto-update alike. On macOS and Linux `update-prepare` downloads it with Plannotator's own fetch and keeps it only when it starts with `#!` and contains `REPO="backnotprop/plannotator"` (under 4 MB); on Windows the wrapper downloads it. Only `https:` or loopback `http:` is accepted. | | `PLANNOTATOR_RELEASE_DOWNLOAD_URL` | Testing / mirror knob: the base `update-prepare` downloads each binary's `/vX.Y.Z/plannotator--.sha256` from (`releaseDownloadUrl` in `packages/server/update-prepare.ts`; default `https://github.com/backnotprop/plannotator/releases/download`). Only `https:` or loopback `http:` is accepted. The update tests serve a fake one on loopback. | -| `PLANNOTATOR_EXPECTED_SHA256` | Set by Plannotator's update wrapper, not by people: `plannotator--=` for this machine's CPU type, then the other one's when it was available, the checksums `update-prepare` fetched with its own connection. `install.sh` uses the entry for the binary it picked as the expected checksum (and does not fetch the `.sha256` again); on a mismatch it prints "Stopped: …", replaces nothing, touches the file the wrapper names in `PLANNOTATOR_UPDATE_MISMATCH_FILE` (only an absolute path ending `/checksum-mismatch`), and exits `113`; the wrapper records `113` only when that marker exists and records any other `113` as `1`, so "nothing was changed" is never claimed for another error. A missing or malformed entry prints a note and falls back to the `.sha256` check. Unset (a person running the script, an older Plannotator), nothing changes. `plannotator update` and the Inbox's failure dialog never offer the `curl \| bash` line after exit `113` or `75`. Internal wrapper variables alongside it: `PLANNOTATOR_UPDATE_BINARY` / `PLANNOTATOR_UPDATE_ENTRY` (who runs `update-prepare`; a source run passes its script). | +| `PLANNOTATOR_EXPECTED_SHA256` | Set by Plannotator's update wrapper, not by people: `plannotator--=` for this machine's CPU type, then the other one's when it was available, the checksums `update-prepare` fetched with its own connection. `install.sh` uses the entry for the binary it picked as the expected checksum (and does not fetch the `.sha256` again); on a mismatch it prints "Stopped: …", replaces nothing, touches the file the wrapper names in `PLANNOTATOR_UPDATE_MISMATCH_FILE` (only an absolute path ending `/checksum-mismatch`), and exits `113`; the wrapper records `113` only when that marker exists and records any other `113` as `1`, so "nothing was changed" is never claimed for another error. A missing or malformed entry prints a note and falls back to the `.sha256` check. Unset (a person running the script, an older Plannotator), nothing changes. `plannotator update` and the Inbox's failure dialog never offer the `curl \| bash` line after exit `113`, or after a `75` whose connection presented a revoked certificate; any other `75` shows it with the release page, since a failed download can be lasting. Internal wrapper variables alongside it: `PLANNOTATOR_UPDATE_BINARY` / `PLANNOTATOR_UPDATE_ENTRY` (who runs `update-prepare`; a source run passes its script). | | `PLANNOTATOR_CURSOR_SANDBOX` | Set to `0` / `false` / `disabled` to stop passing `--sandbox enabled` when launching Cursor's CLI for review jobs (`cursor-agent`, else `agent` only when that is Cursor's own CLI: `resolveMarkerBinary` over `packages/shared/agent-program.ts`, vendored to Pi, the install-layout path rule then an asynchronous 2 s `--version` probe, so another program named `agent` such as Grok Build's is never started and Cursor reads as unavailable in `/api/agents/capabilities`) — the flag pair is omitted entirely, deferring to the user's own Cursor Agent sandbox configuration. For systems where Cursor's sandbox cannot start (NixOS, AppArmor-restricted Linux). Default: enabled (`--sandbox enabled` is passed). Can also be set via `~/.plannotator/config.json` (`{ "cursorSandbox": false }`); the env var takes precedence. Note: opting out means the review job's write protection relies on `--mode ask` plus the user's own Cursor configuration. | | `PLANNOTATOR_CLAUDE_SANDBOX` | Set to `0` / `false` / `disabled` to turn Claude Code's own sandbox OFF for Claude-engine agent jobs (code review, Code Tour, Guided Review) by passing `--settings '{"sandbox":{"enabled":false}}'` (#1627). For Linux systems where the sandbox cannot start (no bubblewrap/socat, AppArmor-restricted user namespaces) while `~/.claude/settings.json` enables it: jobs run with `--permission-mode dontAsk`, so Claude's unsandboxed fallback is refused and every Bash call fails. Default: enabled, meaning Plannotator passes nothing and the user's own Claude Code sandbox setting applies. Opting out runs the job's commands without OS containment, the same as for any user who never enabled Claude's sandbox (Claude Code's default). `--tools`, the `--allowedTools` prefixes, the `--disallowedTools` list and `dontAsk` are unchanged, but the command rules limit what the model can run rather than containing it (Claude Code matches rule text, so a deny rule covers the spellings a model normally writes, not every spelling a program accepts), so treat the opt-out as removing the only OS-level boundary. Managed (enterprise) settings still outrank `--settings`. When a Claude job finishes with no shell command that ran and at least one refused command its own `--allowedTools` admits outright — a plain invocation with no operators, substitutions or variables, which `dontAsk` never refuses unless the shell itself cannot run (`detectClaudeShellBlocked`), the job carries a `warning` pointing at this knob, shown in the Agents tab and the job detail panel; the sandbox is never turned off automatically. Independently of this knob, every Claude agent job passes `--strict-mcp-config` (no `--mcp-config`, so none of the user's MCP servers load into a job) and an `--append-system-prompt` telling the model to run one simple command at a time, since `dontAsk` refuses compound commands (`$(...)`, `&&`, pipes) as a whole (`claudeJobIsolationArgs` in `packages/server/claude-review.ts`, vendored to Pi), plus `--setting-sources user`, so the user's own settings (auth, env, sandbox preference) and managed policy apply but the job directory's project/local `.claude/settings*.json` (a PR checkout is someone else's branch) cannot add allow rules or hooks to a job. Side effects: Claude auth or provider env kept only in a repo's `.claude/settings.json` / `.claude/settings.local.json` does not apply to jobs, and neither does a sandbox setting or `permissions.deny` set only in project settings; managed settings still apply. All three job kinds share ONE command allow/deny definition (`CLAUDE_JOB_ALLOWED_TOOLS` / `CLAUDE_JOB_DISALLOWED_TOOLS` via `claudeJobToolArgs`, same file): exactly the read-only commands the prompts instruct (git status/diff/log/show/blame/merge-base/rev-parse/ls-files/ls-tree, `git branch --show-current`, `git remote -v` / `get-url`, the read-only jj commands, `gh pr view|diff` / `gh issue view` and the glab equivalents, `wc`), with deny rules for git `--output` and `-c` / `--config-env`, jj `--config*` / `--tool`, and `git grep`, `git ls-remote`, `gh api`, `glab api`. There is no `git -C`: workspace prompts send the combined diff inline and point the model at the folder-prefixed files. `claude-job-isolation.test.ts` reads the commands out of the real prompt text and fails if the allowlist stops admitting one. `detectClaudeShellBlocked` also takes the job's `--disallowedTools`, so a refusal a deny rule issues by design never reads as a blocked shell. Can also be set via `~/.plannotator/config.json` (`{ "claudeSandbox": false }`); the env var takes precedence. | | `PLANNOTATOR_GIT_REMOTE_CHECK` | Set to `0` / `false` / `disabled` to stop code review contacting the git remote (issue #1553). Default: enabled, which runs `git ls-remote --symref origin HEAD` to discover the remote default branch and to compare the remote tip against the local tracking ref for the "Baseline is behind GitHub" banner. That probe is **not** on a timer: it runs at startup (the compare-target detection plus the staleness probe), on `/api/diff` page load, on `/api/diff/switch` (the diff-type/base pickers and the "Diff out of date · Refresh" button), and on the explicit `/api/fetch-base`, each rate limited to once per 60s per session, with the interval DOUBLING on failure up to a 15-minute cap and resetting to 60s on the next success (`nextRemoteBaseCheckInterval` in `packages/shared/review-core.ts`, shared by both runtimes). It is deliberately absent from `/api/diff/fresh`, which the client polls every 5s for as long as the page is open — hanging the probe there made an idle review query the remote once a minute forever, which on a smartcard-backed SSH setup is one hardware-key touch prompt per minute with nothing on screen to explain it. With the check off the session makes **zero** `ls-remote` calls, startup probes included: the compare target stays whatever LOCAL ref discovery resolved (`getDefaultBranch`: `origin/HEAD` → `origin/main` → `main` → …), `baseBehindRemote` never rides any payload so the banner never shows, and `POST /api/fetch-base` stays available because an explicit Fetch is the user asking for the network — note that with the banner gone so is its one-click Fetch button, which lives in that banner, so the endpoint matters to API callers rather than to the reviewer, and its post-fetch `refreshRemoteBaseInfo()` is a no-op under the opt-out. Only plain local git sessions are affected (PR, jj, GitButler, P4, workspace and static-patch sessions never ran the check). Can also be set via `~/.plannotator/config.json` (`{ "gitRemoteCheck": false }`); precedence is `review --no-git-remote-check` flag > env var > config (`resolveGitRemoteCheck` in `packages/shared/config.ts`). The flag is parsed by the shared `parseReviewArgs`, so it works on every host that forwards review arguments — Claude Code, OpenCode, and Pi — not just the Bun CLI. | diff --git a/docs/agent-guides/inbox-foundation.md b/docs/agent-guides/inbox-foundation.md index 066ec486be..42583a7839 100644 --- a/docs/agent-guides/inbox-foundation.md +++ b/docs/agent-guides/inbox-foundation.md @@ -74,7 +74,7 @@ One long-lived local server per machine. Agents leave messages, `:::question` bl - Callers re-read the registry on every call. - An Inbox whose data is gone exits: every 5 s (`INBOX_DATA_CHECK_MS`) it reads `inbox.json` (`probeInboxData`). It is a miss ONLY when the file or its folder does not exist (ENOENT, ENOTDIR) or the file names another `serverSession`; any other error (EACCES, EIO, ESTALE, a file that does not parse) is unknown and leaves the count alone, and the folder's device and inode are not looked at (a remount or a moved copy must not end it). Two misses in a row stop it cleanly, then it says why on stderr (plus the manual `tailscale serve --https= off` when the tailnet take-down failed) and the process exits 0 (`onDataGone`). Nothing could reach it (agents find an Inbox through the file) and it has no store, so it must not run on headless (an e2e once removed its temp folder under a live `--tailscale` Inbox, which ran for two days at 100% CPU, cause unknown). It is a timer, so it cannot end an Inbox stuck in a synchronous loop. After it starts, the Inbox rewrites `inbox.json` only while the file names its own `serverSession` (`updateOwnInboxRegistry`, used by the tailnet and Wi-Fi saves; read then write, so a start overlapping a stop can still lose its entry, and then exits by the same check), so a stopping Inbox never rewrites its successor's entry and never makes a removed data folder again. - The detached Inbox runs with `inbox/` as its working directory and `windowsHide`. -- The update control (`packages/server/inbox-updates.ts`; design record, not in this repo): health, the list model and the event stream's `status` carry `update: { available: true, state, version, current, action, releaseUrl, publishedAt? } | null` (`InboxUpdate` in `packages/core/inbox-types.ts`; `available` and `version` keep the older shape). `ready`: on the health tick (60 s, every 2 s while `update.lock` is held), a compiled Inbox stats the binary on disk and, only when the file's identity (device, inode, size, mtime) changed, runs it with `--version`; a different version is ready (the old restart-to-update signal). `updating`: `update.lock` is held. `failed`: `update-result.json` records a non-zero exit for an install the PERSON started (`pending.by: "person"`; a background auto-update failure stays out of the sidebar). `available`: a release build (X.Y.Z, so never `dev` or `0.0.0-dev`) runs the shared release check on start and on each page load (when the answer is over a minute old) and on each tick (when it is over 10 minutes old, and 10 minutes after a failure; never while `PLANNOTATOR_UPDATE_CHECK` is off; a new release therefore shows within 10 minutes, at once on a restart or a reload) and shows a newer stable release at once, before anything is downloaded (owner's answer 2), unless "Remind me tomorrow" snoozed that version (`update-state.json` `snooze: { version, until }`, 24 h, shared by every tab; a newer release clears it). `action`: `script` when the running binary is the install script's (`isManagedBinary`), else `copy`. The list model also carries `updated`: the version this run started on when it is newer than the one the Inbox ran before (`inboxVersion` in `update-state.json`), for the sidebar's quiet "What's new in X" link this run only. That start also keeps the version it found as `inboxPreviousVersion` (additive; kept across restarts on the same version, replaced at the next update, removed on a downgrade), so the What's new page of a release this Inbox already runs still lists the releases skipped since the version run before the update (the answer's additive `since`, which `skipped` counts from; a newer release counts from the running version, and `current` stays the running version). The release check saves notes only for the `releases/latest` it fetched, so a version installed outside the Inbox (the install script run by hand) before a check saw it has none: `fetchReleaseNotesFor` (`packages/shared/update-check.ts`) then asks for that release by its tag (`releases/tags/vX`, `releaseTagApiUrl`, the same client rules: the `plannotator-update-check` user agent, nothing while `PLANNOTATOR_UPDATE_CHECK` is off, claimed in `update-state.json` `notesFetch[]` before the request, then 6 h after a failure and 24 h after an answer with no "What's New" part, never a loop) and saves `update-notes/.json`. A stored file with an empty "What's New" part (saved before the release body was edited) counts as missing and is fetched again under the same throttle. The Inbox runs it on its tick for `updated` and from `GET /api/inbox/whats-new`, only for a version it has reason to show (the one it runs, the newest known release, the binary on disk, an install in progress); the route waits up to 3 s (`WHATS_NEW_WAIT_MS`), then answers `503 { code: "notes_loading" }` with `Retry-After: 1` and the page asks again (up to 15 times). `updated` is null while its notes are neither saved nor fetchable (checks off, or a fetch in its back-off), so no link points at a page with nothing on it; the event stream's `status` carries `updated` too, so the link appears or goes once the fetch settles. Update now is `POST /api/inbox/update` (`startManualUpdate`: the same wrapper, lock, log, pinned tag and remembered flags as auto-update, without its 24 h window, its open-sessions gate or its mismatch back-off; a second press while it runs is `409 update_locked`); the page that pressed it restarts the Inbox itself once `ready` arrives. On macOS and Linux that wrapper downloads the install script and checksums with the Inbox binary's own fetch (`update-prepare`, environment.md `PLANNOTATOR_AUTO_UPDATE`). The failure dialog (`FailedDialog` in `packages/inbox/components/Updates.tsx`) reads `failure.exit_code` (codes in `packages/core/update-exit-codes.ts`): `113` (the download did not match the checksum Plannotator fetched) and `75` (Plannotator could not download the update) say nothing changed and the old version still works, show the log and Try Again, and never the `curl | bash` line; any other code keeps "Or run the install script yourself". A tailnet page reaches none of the update routes. +- The update control (`packages/server/inbox-updates.ts`; design record, not in this repo): health, the list model and the event stream's `status` carry `update: { available: true, state, version, current, action, releaseUrl, publishedAt? } | null` (`InboxUpdate` in `packages/core/inbox-types.ts`; `available` and `version` keep the older shape). `ready`: on the health tick (60 s, every 2 s while `update.lock` is held), a compiled Inbox stats the binary on disk and, only when the file's identity (device, inode, size, mtime) changed, runs it with `--version`; a different version is ready (the old restart-to-update signal). `updating`: `update.lock` is held. `failed`: `update-result.json` records a non-zero exit for an install the PERSON started (`pending.by: "person"`; a background auto-update failure stays out of the sidebar). `available`: a release build (X.Y.Z, so never `dev` or `0.0.0-dev`) runs the shared release check on start and on each page load (when the answer is over a minute old) and on each tick (when it is over 10 minutes old, and 10 minutes after a failure; never while `PLANNOTATOR_UPDATE_CHECK` is off; a new release therefore shows within 10 minutes, at once on a restart or a reload) and shows a newer stable release at once, before anything is downloaded (owner's answer 2), unless "Remind me tomorrow" snoozed that version (`update-state.json` `snooze: { version, until }`, 24 h, shared by every tab; a newer release clears it). `action`: `script` when the running binary is the install script's (`isManagedBinary`), else `copy`. The list model also carries `updated`: the version this run started on when it is newer than the one the Inbox ran before (`inboxVersion` in `update-state.json`), for the sidebar's quiet "What's new in X" link this run only. That start also keeps the version it found as `inboxPreviousVersion` (additive; kept across restarts on the same version, replaced at the next update, removed on a downgrade), so the What's new page of a release this Inbox already runs still lists the releases skipped since the version run before the update (the answer's additive `since`, which `skipped` counts from; a newer release counts from the running version, and `current` stays the running version). The release check saves notes only for the `releases/latest` it fetched, so a version installed outside the Inbox (the install script run by hand) before a check saw it has none: `fetchReleaseNotesFor` (`packages/shared/update-check.ts`) then asks for that release by its tag (`releases/tags/vX`, `releaseTagApiUrl`, the same client rules: the `plannotator-update-check` user agent, nothing while `PLANNOTATOR_UPDATE_CHECK` is off, claimed in `update-state.json` `notesFetch[]` before the request, then 6 h after a failure and 24 h after an answer with no "What's New" part, never a loop) and saves `update-notes/.json`. A stored file with an empty "What's New" part (saved before the release body was edited) counts as missing and is fetched again under the same throttle. The Inbox runs it on its tick for `updated` and from `GET /api/inbox/whats-new`, only for a version it has reason to show (the one it runs, the newest known release, the binary on disk, an install in progress); the route waits up to 3 s (`WHATS_NEW_WAIT_MS`), then answers `503 { code: "notes_loading" }` with `Retry-After: 1` and the page asks again (up to 15 times). `updated` is null while its notes are neither saved nor fetchable (checks off, or a fetch in its back-off), so no link points at a page with nothing on it; the event stream's `status` carries `updated` too, so the link appears or goes once the fetch settles. Update now is `POST /api/inbox/update` (`startManualUpdate`: the same wrapper, lock, log, pinned tag and remembered flags as auto-update, without its 24 h window, its open-sessions gate or its mismatch back-off; a second press while it runs is `409 update_locked`); the page that pressed it restarts the Inbox itself once `ready` arrives. On macOS and Linux that wrapper downloads the install script and checksums with the Inbox binary's own fetch (`update-prepare`, environment.md `PLANNOTATOR_AUTO_UPDATE`). The failure dialog (`FailedDialog` in `packages/inbox/components/Updates.tsx`) reads `failure.exit_code` (codes in `packages/core/update-exit-codes.ts`) and, for `75`, the additive `failure.tls` (`failureDetails`: the last run's lines in `update.log` hold `update-prepare`'s `UPDATE_TLS_HINT`). `113` (the download did not match the checksum Plannotator fetched) and `75` (Plannotator could not download the update) say nothing changed and the old version still works, and show the log and Try Again. `113`, and `75` with `tls: "revoked"` (a blocked eMudhra key), never show the `curl | bash` line: "try again on another network". Any other `75` can be a lasting local condition (a proxy, a network that inspects HTTPS), so it keeps the way out: "If this keeps happening, install it yourself" with the command and the release page, plus `NODE_EXTRA_CA_CERTS=/path/to/its-root.pem plannotator update` for `tls: "untrusted"`. Any other code keeps "Or run the install script yourself". A tailnet page reaches none of the update routes. - **No one stuck on a version** (owner, 2026-10-26, QA item 30; audit of every shipped generation in the PR that added this). Every path ends with the update shown within a stated bound, or the person told plainly that checking or installing fails, with the command that always works (the install script finds the newest release by itself: the API, else the release page). - **Checking that fails for hours.** `releaseCheck.failingSince` in `update-state.json` marks when the current run of failed checks began (kept with its last `failedAt` across the claim of each next attempt and across any gap between checks, cleared only by a success), and `failingFor` how long it has failed as of that last failure, each gap between two failures counted as at most 90 min (`RELEASE_CHECK_FAILURE_RECENT_MS`). `releaseCheckFailing` (`packages/shared/update-check.ts`, vendored to Pi) answers since when and why once that time reaches 3 h (`RELEASE_CHECK_FAILING_AFTER_MS`), its latest failure is under 90 min old (an attempt claimed since, in flight or abandoned by a process that exited mid-request, changes nothing until it answers), and no process succeeded since. So checks hours apart (a review window now and then, a laptop that sleeps a lot) say so by their third failure; a machine that slept, or checks turned back on after weeks, shows nothing until a check fails again; one failure right after a week asleep stays quiet. An API-only setup (`PLANNOTATOR_UPDATE_API_URL`) waiting out the API's back-off keeps its run current. A state file that cannot be written never stops the check: `writeUpdateState` keeps this process's own changes in memory (`unwrittenStates`: the fields that differ from the file as it read when the write failed; one `update.log` line, no temp file left), `readUpdateState` reads the file and lays those over it, and every later write tries the file again, so it still checks every 10 minutes at most, shows what it learns, reaches the failing state if GitHub fails too, and never writes a stale copy over other processes' `pending`, `lastCheckAt`, `snooze` or `latestRelease` once the file can be written. It shows only while update checks are on and nothing better can be offered: the list model and the event stream's `status` carry the additive `update_check_failing: { since, error } | null`; the sidebar draws a quiet row "Can't check for updates" (`CheckFailingRow`, opening Settings → About) where the Update button would be, the toggle's dot is neutral, About's nav has its dot, and Settings → General's update row and About say "Can't check for updates since