Skip to content

Updates check the binary against checksums Plannotator downloads itself - #2218

Merged
backnotprop merged 12 commits into
mainfrom
feat/safe-updates-step1
Oct 28, 2026
Merged

backnotprop merged 12 commits into
mainfrom
feat/safe-updates-step1

Conversation

@backnotprop

@backnotprop backnotprop commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

Merge after the Bun 1.4.4 PR and after 0.28.32 is cut (target 0.28.33)

0.28.33 must be built on Bun 1.4.4 (#2215 merges before then). The protection rests on the binary's own fetch rejecting the leaked eMudhra roots, which Bun 1.4.4 does and 1.3.14 does not.

What this does

Safe updates, step 1 (no signing keys). On macOS and Linux, plannotator update, the Inbox's Update now and background auto-update no longer download anything with the system curl, which on macOS still trusts the leaked eMudhra root keys. The running Plannotator downloads the install script and the release's .sha256 files with its own connection (Bun 1.4.4 blocks those keys). Then it runs the saved script, which checks the binary against those checksums before it replaces anything.

  • plannotator update-prepare <version> (internal, packages/server/update-prepare.ts). It downloads install.sh and the .sha256 of the binary install.sh will pick on this machine (process.arch; required) into a private plannotator-update-* temp dir (0700, files 0600) and prints the dir. The other CPU type's .sha256 is pinned when readable and skipped otherwise, so a release that drops one CPU type never stops updates for the other. It keeps the script only when it looks like ours (#!, REPO="backnotprop/plannotator", under 4 MB). Each checksum must be 64 hex characters. On any failure nothing is staged and it exits 75.
  • POSIX wrapper (packages/server/auto-update.ts). It runs update-prepare, then bash <dir>/install.sh … with PLANNOTATOR_EXPECTED_SHA256. It removes the dir on exit, on Ctrl-C and on TERM, and keeps the lock, result and log contract. If the download fails, nothing runs and the result is 75. It never falls back to curl. It removes the printed dir only when that dir is an absolute path on one line whose last part is plannotator-update-*.
  • install.sh. When PLANNOTATOR_EXPECTED_SHA256 has a valid entry for the binary it picked, the script uses that entry as the expected checksum and does not fetch the .sha256 again. On a mismatch it prints a plain message, replaces nothing, touches the marker file the wrapper names (PLANNOTATOR_UPDATE_MISMATCH_FILE) and exits 113. The wrapper records 113 only when that marker exists; any other exit 113 is recorded as 1, so "nothing was changed" is never claimed for another error. The codes live in packages/core/update-exit-codes.ts, shared by the CLI, the Inbox dialog and the notice. If the entry is missing or malformed, it prints a note and falls back to today's check. Without the variable, the script behaves exactly as before.
  • sem sidecar. The v0.8.0 archive checksums are pinned in install.sh, install.ps1 and install.cmd instead of read from checksums.txt. I cross-checked the pinned values against checksums.txt and against the GitHub API's asset digest.
  • Back-off. After a mismatch, auto-update waits 1, then 3, then 7 days before it tries that version again (mismatchBackoff in update-state.json, outcome backed-off). A newer release is tried at once. Update now and plannotator update ignore the back-off.
  • Messages. After exit 113 or 75, neither plannotator update nor the Inbox's failure dialog prints the curl | bash line. The review-window toast says "Update to vX stopped" once per version. A background download failure shows nothing, because nothing changed.

Windows is unchanged: Windows blocks the leaked keys in its own certificate store, which Invoke-WebRequest uses. The Pi and OpenCode runtimes have no installer path. Pi vendors update-check.ts, where the only change is one additive optional field.

Deploy note: plannotator.ai must serve the new install.sh

The binary check only takes effect once https://plannotator.ai/install.sh serves this branch's scripts/install.sh. The owner syncs it to S3 by hand. Until then, updates behave exactly as today, because an older script ignores PLANNOTATOR_EXPECTED_SHA256. In that case update-prepare logs "this install script does not check the pinned checksum yet". The release checklist (.agents/skills/release/SKILL.md) now has this step.

Tests

  • packages/server/update-prepare.test.ts:
    • staging and permissions;
    • versions with and without the v;
    • never-stuck cases: offline, a refusing proxy in both Bun 1.3.14 and 1.4.4 behaviour, a missing, web-page or short .sha256, a captive portal, a script that is not ours, an oversized script, a stalled server;
    • the sweep;
    • end to end through the wrapper:
      • offline;
      • a refusing proxy;
      • a bad .sha256;
      • tamper through the real install.sh (mismatch recorded, old binary kept);
      • a release missing the other CPU type's .sha256 (still updates);
      • an unmarked exit 113 (recorded as 1);
      • the genuine release through the real install.sh;
      • an older install.sh (runs as before);
      • Ctrl-C;
      • the dir guard (never removes anything not named plannotator-update-*).
  • scripts/install.test.ts: a matching pin, the pin read for this CPU type only and in any letter case (asserting the pin path), a pin for this CPU type alone, tamper (exit 113 and the marker, old binary kept), the marker only at a …/checksum-mismatch path, malformed pins falling back, no variable meaning no change, and the sem pins.
  • packages/server/auto-update.test.ts: the wrapper tests moved to a loopback release server; the killed-outright test checks the sweep; there are tests for the 1/3/7-day back-off and the mismatch and download notices.
  • apps/hook/server/update-command.test.ts: exit 113 and 75 messages (no install command); the process tests check the pins passed in and a download failure; update-prepare through the real index.ts entrypoint (stdout is exactly the dir; 75 with empty stdout on failure).
  • release.yml smoke-binaries (Linux and macOS): scripts/smoke-update-prepare.sh runs the compiled binary's update-prepare against a loopback mirror, then a full plannotator update through the wrapper (compiled-binary detection) with a stand-in install script.
  • packages/inbox/updates.test.tsx, packages/ui/utils/autoUpdateNotice.test.ts: the dialog and the toast for 113 and 75.

Sandboxed: temp HOME, TMPDIR and data dir, loopback servers and stub curl. Nothing reaches the network.

…ecksums 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.
- 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).
Conflicts:
- packages/server/auto-update.ts: POSIX_WRAPPER keeps main's detached
  lock line (pid/group/host/boot/pidns) first, then this branch's
  update-prepare path in place of curl (main's curl timeouts are covered
  by update-prepare's own 30 s fetch timeout).
- docs/agent-guides/environment.md, inbox-foundation.md: both sides' lines.

packages/server/auto-update-stuck.test.ts (from main) ran the wrapper
through curl and PLANNOTATOR_UPDATE_SCRIPT_URL; it now serves a fake
release to update-prepare-entry.ts, as auto-update.test.ts does.
Conflict: packages/server/auto-update.test.ts, the group-SIGINT test. Kept
this branch's fakeRelease (install script + .sha256 server) with main's
`exec sleep 30` deflake (#2237). The same `exec` goes into
update-prepare.test.ts's Ctrl-C test, which has the same race.
- 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.
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.
…n the retry

Owner-approved follow-ups to #2218:

- On macOS an "untrusted certificate" failure lists the release page
  before the `curl | bash` line, in `plannotator update` and in the Inbox
  dialog (`failure.release_first`, set by the server on darwin only).
  macOS curl still trusts emSign G3, so a network holding a leaked key
  that reads as untrusted rather than revoked never sends people to curl
  first. Revoked is unchanged (no curl line); other platforms keep the
  order.
- The one retry after a certificate error also trusts what only curl
  read on main: $CURL_CA_BUNDLE and `cacert` / `capath` in the first
  .curlrc of $CURL_HOME, $XDG_CONFIG_HOME, $HOME. Absolute paths only,
  files capped at 4 MB (capath 16 MB in all), missing or unreadable paths
  skipped silently, emSign G3 filtered. Nothing is read unless a
  certificate error happened, so the normal path is unchanged.
…full temp folder or the old-script note

From the end-to-end friction run on #2218:

- The other CPU type's .sha256 is optional: once install.sh and this
  machine's .sha256 are in, it gets 2 s (OPTIONAL_CHECKSUM_GRACE_MS) and
  is then skipped with a note. A stall there no longer adds 30 s.
- The NODE_EXTRA_CA_CERTS hint is now complete. When it names a readable
  certificate, update-prepare stages curl-ca-bundle.pem (curl's default
  bundle, SSL_CERT_FILE, Bun's and the system's roots, curl's own CA
  paths, that certificate; never emSign G3), and the wrapper hands it to
  install.sh as CURL_CA_BUNDLE, so the binary download 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.
- A temp folder that can't hold the update (ENOSPC, read-only, missing)
  still exits 75 but says so in plain words: "Couldn't prepare the
  update: the temp folder /tmp is full (ENOSPC)" in the CLI, the Inbox
  dialog (failure.prepare) and the review-window notice
  (reason "prepare"), never "couldn't download".
- The "this install script does not check the pinned checksum yet"
  note goes to update.log only, not the person's terminal.

The log-line texts and readers move to packages/server/update-log.ts
(re-exported from update-prepare.ts), so auto-update.ts can read them
without an import cycle.
update-prepare already says why nothing ran (a download, a certificate,
a temp folder that can't hold the update). The wrapper's line after it
said "could not download the update" even for a full temp folder; it
now says only that nothing ran and nothing changed.
@backnotprop
backnotprop merged commit 6c9eacf into main Oct 28, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant