diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 9c4ba93bba..3507f9d6e4 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -267,6 +267,8 @@ 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). +- [ ] 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/.github/workflows/release.yml b/.github/workflows/release.yml index af2436d69b..6ade52d671 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -541,6 +541,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/index.ts b/apps/hook/server/index.ts index 330ac2c5eb..0972283652 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, @@ -746,6 +747,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..0d32b11ef3 100644 --- a/apps/hook/server/update-command.test.ts +++ b/apps/hook/server/update-command.test.ts @@ -5,11 +5,13 @@ * 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"; import { runUpdateCommand, type UpdateCommandDeps } from "./update-command"; +import { UPDATE_EXIT_CHECKSUM_MISMATCH } from "@plannotator/core/update-exit-codes"; +import { UPDATE_OLD_SCRIPT_NOTE, UPDATE_STAGING_PREFIX, UPDATE_TLS_HINT } from "@plannotator/server/update-log"; import type { FetchReleaseResult } from "@plannotator/shared/update-check"; let dataDir: string; @@ -96,10 +98,104 @@ describe("plannotator update (seams)", () => { expect(err).toContain("HTTP 429"); }); - test("a failed install says so, with the log (exit 1)", async () => { - expect(await run([], { runInstaller: async () => 3 })).toBe(1); - expect(err).toContain("exited with code 3"); + 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'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 () => 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"); + expect(err).not.toContain("curl"); + expect(err).not.toContain("exited with code"); + }); + + // 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"); + }); + + // macOS curl still trusts emSign G3: there, an untrusted certificate puts + // the release page before the `curl | bash` line. Elsewhere the order stays. + test("an untrusted certificate on macOS: the release page comes before the install command", async () => { + const runInstaller = async (_: unknown, write: (t: string) => void) => (write(`${UPDATE_TLS_HINT.untrusted}\n`), 75); + expect(await run([], { runInstaller, platform: "darwin" })).toBe(1); + const release = err.indexOf("download it from https://github.com/backnotprop/plannotator/releases/tag/v0.28.11"); + const curl = err.indexOf("curl -fsSL https://plannotator.ai/install.sh"); + expect(release).toBeGreaterThan(-1); + expect(curl).toBeGreaterThan(release); + expect(err).toContain("NODE_EXTRA_CA_CERTS=/path/to/its-root.pem plannotator update"); + err = ""; + rmSync(join(dataDir, "update.lock"), { force: true }); + expect(await run([], { runInstaller, platform: "linux" })).toBe(1); + expect(err.indexOf("curl -fsSL")).toBeLessThan(err.indexOf("download it from")); + }); + + test("a temp folder that can't hold the update: its own words, not \"couldn't download\", no install command", async () => { + const runInstaller = async (_: unknown, write: (t: string) => void) => ( + write(`${UPDATE_STAGING_PREFIX}the temp folder /tmp/x is full (ENOSPC); nothing was changed\n`), 75 + ); + expect(await run([], { runInstaller })).toBe(1); + expect(err).toContain("Couldn't prepare the update: the temp folder /tmp/x is full (ENOSPC). 0.28.10 is still installed and works."); + expect(err).toContain("Free some space there, then: plannotator update"); + expect(err).toContain("TMPDIR=/another/folder plannotator update"); + expect(err).not.toContain("Couldn't download"); + expect(err).not.toContain("curl"); + }); + + // Permissions or a missing folder are not fixed by freeing space. + for (const [reason, advice] of [ + ["the temp folder /tmp/x can't be written (EACCES)", "Make it writable, then: plannotator update"], + ["the temp folder /tmp/x does not exist (ENOENT)", "Create it, then: plannotator update"], + ] as const) { + test(`a temp folder that ${reason.includes("exist") ? "does not exist" : "can't be written"}: advice for that cause`, async () => { + const runInstaller = async (_: unknown, write: (t: string) => void) => ( + write(`${UPDATE_STAGING_PREFIX}${reason}; nothing was changed\n`), 75 + ); + expect(await run([], { runInstaller })).toBe(1); + expect(err).toContain(`Couldn't prepare the update: ${reason}.`); + expect(err).toContain(advice); + expect(err).toContain("TMPDIR=/another/folder plannotator update"); + expect(err).not.toContain("Free some space"); + }); + } + + // Before the S3 sync every update printed this; it belongs in update.log only. + test("the old install script's note stays off the terminal", async () => { + const runInstaller = async (_: unknown, write: (t: string) => void) => (write(`${UPDATE_OLD_SCRIPT_NOTE}\n`), write(" installer output\n"), 0); + expect(await run([], { runInstaller })).toBe(0); + expect(out).toContain("installer output"); + expect(out + err).not.toContain("does not check the pinned checksum yet"); + }); + + 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 () => { @@ -119,12 +215,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 +231,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 +242,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 }); }, }); @@ -163,6 +267,9 @@ describe.skipIf(process.platform === "win32" || !Bun.which("curl"))("plannotator // 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", @@ -171,6 +278,8 @@ 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`, + ...extraEnv, }, stdout: "pipe", stderr: "pipe", @@ -197,8 +306,74 @@ 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 + // the checksums it fetched (this machine's CPU type first), and the staged + // copy is gone afterwards. + const os = process.platform === "darwin" ? "darwin" : "linux"; + 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); + 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).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 }); + 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); + + // 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 }); + // macOS puts the release page first for an untrusted certificate (its curl still trusts emSign G3). + if (process.platform === "darwin") { + expect(refused.stderr).toContain( + "If this keeps happening, download it from https://github.com/backnotprop/plannotator/releases/tag/v9.9.9\nor install it yourself:\n curl -fsSL https://plannotator.ai/install.sh | bash\n", + ); + } else { + 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(); @@ -218,6 +393,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"], @@ -244,3 +420,65 @@ describe.skipIf(process.platform === "win32" || !Bun.which("curl"))("plannotator 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/apps/hook/server/update-command.ts b/apps/hook/server/update-command.ts index 5513ca4c81..5ea8d77da9 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,8 +38,12 @@ import { isManagedBinary, managedBinaryPath, runInstallerForeground, + UPDATE_CHECKSUM_MISMATCH, + UPDATE_PREPARE_FAILED, type InstallerLaunch, } from "@plannotator/server/auto-update"; +import { stagingFailureIn, tlsFailureIn, withoutLogOnlyLines } from "@plannotator/server/update-log"; +import { UPDATE_OTHER_TMPDIR_COMMAND, updateStagingAdvice } from "@plannotator/core/update-exit-codes"; import { UPDATE_COMMAND_USAGE } from "./cli"; export interface UpdateCommandDeps { @@ -155,13 +160,78 @@ 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; + // Notes meant for update.log only (the tee still writes them there). + const shown = withoutLogOnlyLines(text); + if (shown) out(shown); + }); 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`, ); 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) { + const staging = stagingFailureIn(printed); + if (staging) { + // Downloaded, but the temp folder could not hold it: not a network problem. + err( + `Couldn't prepare the update: ${staging}. ${current} is still installed and works.\n` + + `${updateStagingAdvice(staging)}, then: plannotator update\n` + + `Or use another temp folder:\n ${UPDATE_OTHER_TMPDIR_COMMAND}\n` + + `Details: ${autoUpdatePaths(dataDir).log}\n`, + ); + return 1; + } + 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. + const extraCa = + "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"; + if (tls === "untrusted" && platform === "darwin") { + // macOS curl still trusts emSign G3: should a network that holds a + // leaked key get "untrusted" instead of "revoked", the release page + // (the browser's own trust) comes before the `curl | bash` line. + err( + `Couldn't download the update. ${current} is still installed and works. Try again later: plannotator update\n` + + extraCa + + `If this keeps happening, download it from ${releaseUrlFor(latest)}\n` + + `or install it yourself:\n ${command}\n` + + `Details: ${autoUpdatePaths(dataDir).log}\n`, + ); + return 1; + } + 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" ? extraCa : "") + + `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 d31903cce0..ac31b976f0 100644 --- a/docs/agent-guides/environment.md +++ b/docs/agent-guides/environment.md @@ -41,11 +41,13 @@ | `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 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; once install.sh and the required `.sha256` are in it gets `OPTIONAL_CHECKSUM_GRACE_MS`, 2 s, then is skipped with a note, so a stall there never holds the update up) 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 temp folder that can't hold the staged files (full, read-only, missing) is also `75`, but `update-prepare` prints `UPDATE_STAGING_PREFIX` with a plain reason ("the temp folder /tmp is full (ENOSPC)"), and `plannotator update`, the Inbox dialog (`failure.prepare`) and the review-window notice (`reason: "prepare"`) say "Couldn't prepare the update", never "couldn't download" (`packages/server/update-log.ts`); the CLI and the dialog give the advice for that cause (`updateStagingAdvice` in `packages/core/update-exit-codes.ts`: free space, make it writable, create it) plus `TMPDIR=/another/folder plannotator update`. The wrapper writes its "install.sh exited" log line before `update-result.json`, so a reader that sees the result finds that run's lines. Before the S3 sync, `update-prepare`'s note that the served install.sh predates the pin check (`UPDATE_OLD_SCRIPT_NOTE`) goes to update.log only; `plannotator update` keeps it off the terminal. 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`, plus what only curl read on main: `$CURL_CA_BUNDLE` and `cacert` / `capath` in the first `.curlrc` of `$CURL_HOME`, `$XDG_CONFIG_HOME`, `$HOME` (`curlCaCertificates`; absolute paths only, files over 4 MB and a missing or unreadable path skipped silently); never emSign ECC Root CA - G3). None of this is read unless a certificate error happened, so the normal path is unchanged; 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 `NODE_EXTRA_CA_CERTS` names a readable certificate, `update-prepare` also stages `curl-ca-bundle.pem` (`curlBundleWithExtraCa`: curl's default bundle, `$SSL_CERT_FILE`, Bun's and the system's roots, curl's own CA paths and that certificate, never G3) and the wrapper passes it to install.sh as `CURL_CA_BUNDLE`, so install.sh's curl, which downloads the binary, trusts the same root (a bundle replaces curl's roots, so it only adds to them); without the variable curl is left exactly as before. When both fail, `update-prepare` prints `UPDATE_TLS_HINT` (`untrusted`: run `NODE_EXTRA_CA_CERTS=/path/to/its-root.pem plannotator update`, and on macOS the release page is listed before the `curl | bash` line, since macOS curl still trusts emSign G3; `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_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--=` 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 30b780e8a4..64afe45a88 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 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` (codes in `packages/core/update-exit-codes.ts`) and, for `75`, the additive `failure.prepare` (the update was downloaded but the temp folder could not hold it: the plain reason, shown as "Plannotator couldn't prepare the update: …" with the advice for its cause (`updateStagingAdvice`) and `TMPDIR=/another/folder plannotator update`, no install command) and `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"`; on macOS that case also carries `failure.release_first` and the release page comes before the command (macOS curl still trusts emSign G3). 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