diff --git a/.github/renovate.json b/.github/renovate.json index 92232c51bd..8412955f93 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -15,17 +15,30 @@ "automerge": false }, { - "description": "Pin Bun to 1.3.14: 1.3.12 broke macOS binary codesign for bun build --compile (hotfixed in v0.17.9); 1.3.14 fixes it and is the version every workflow builds with (#1250). Unpin only after verifying signed builds work on the target version.", + "description": "Pin Bun to 1.4.4: the first release whose bundled root certificates drop the compromised eMudhra roots (NSS 3.131.1, oven-sh/bun#46300), and the version every workflow builds with. 1.3.12 broke macOS binary codesign for bun build --compile (hotfixed in v0.17.9). Unpin only after verifying signed builds work on the target version.", "matchManagers": ["github-actions"], "matchDepNames": ["oven-sh/setup-bun"], "matchDepTypes": ["action"], "enabled": true }, { - "description": "Do not bump the bun-version input passed to oven-sh/setup-bun. See v0.17.9 hotfix.", + "description": "Do not bump the bun-version input passed to oven-sh/setup-bun to a new minor or major: verify signed macOS builds first. See v0.17.9 hotfix.", "matchManagers": ["github-actions"], "matchDepNames": ["bun"], + "matchUpdateTypes": ["minor", "major"], "enabled": false + }, + { + "description": "Raise a PR for every Bun patch release: Bun ships security fixes in patches (1.4.4 dropped the compromised eMudhra root and blocked its keys), so one is never missed. Its own PR, never automerged, after the 7-day release age.", + "matchManagers": ["github-actions"], + "matchDepNames": ["bun"], + "matchUpdateTypes": ["patch"], + "enabled": true, + "automerge": false, + "groupName": "Bun runtime", + "prBodyNotes": [ + "Bun patch release. Before merging: release.yml's smoke-binaries (macOS leg: the signed binary must launch) and the Bun runtime trust gate must pass, and bump BUN_VERSION and TARGET_IMAGE (digest from the registry) in dast.yml by hand." + ] } ] } diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml index 4463dfc557..d01669bbbf 100644 --- a/.github/workflows/dast.yml +++ b/.github/workflows/dast.yml @@ -13,8 +13,8 @@ concurrency: cancel-in-progress: false env: - BUN_VERSION: 1.3.14 - TARGET_IMAGE: oven/bun:1.3.14-slim@sha256:d56a2534ffd262e92c12fd3249d3924d296d97086da773f821d7d0477435ea04 + BUN_VERSION: 1.4.4 + TARGET_IMAGE: oven/bun:1.4.4-slim@sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4 ZAP_VERSION: 2.17.0 ZAP_IMAGE: ghcr.io/zaproxy/zaproxy:stable@sha256:781a2bdaea47324e7bab583e2263f21d257b0aee61ed51521a5be45f5f5081ef DAST_NETWORK: plannotator-dast-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2feaad4523..bd3c94e73a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -188,7 +188,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile @@ -245,7 +245,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile @@ -282,7 +282,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile @@ -308,7 +308,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/guides-show-deploy.yml b/.github/workflows/guides-show-deploy.yml index e8db8242d0..ef0d4f9e80 100644 --- a/.github/workflows/guides-show-deploy.yml +++ b/.github/workflows/guides-show-deploy.yml @@ -29,7 +29,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 no-cache: true - name: Install dependencies diff --git a/.github/workflows/inbox-claude-code.yml b/.github/workflows/inbox-claude-code.yml index e8d10cdff3..80186fefa9 100644 --- a/.github/workflows/inbox-claude-code.yml +++ b/.github/workflows/inbox-claude-code.yml @@ -67,7 +67,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/inbox-e2e.yml b/.github/workflows/inbox-e2e.yml index aa4cc57b8f..1abad11fec 100644 --- a/.github/workflows/inbox-e2e.yml +++ b/.github/workflows/inbox-e2e.yml @@ -128,7 +128,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/inbox-pi.yml b/.github/workflows/inbox-pi.yml index 7e9c4defac..10312cf8ef 100644 --- a/.github/workflows/inbox-pi.yml +++ b/.github/workflows/inbox-pi.yml @@ -69,7 +69,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 # Pi needs Node 22.19+ (its engines field). - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/relay.yml b/.github/workflows/relay.yml index 02713f4c02..164b082266 100644 --- a/.github/workflows/relay.yml +++ b/.github/workflows/relay.yml @@ -74,7 +74,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0a9669f08..af2436d69b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,7 +57,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 no-cache: true - name: Install dependencies @@ -92,7 +92,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 no-cache: true - name: Install dependencies @@ -116,10 +116,12 @@ jobs: sha256sum plannotator-darwin-x64 > plannotator-darwin-x64.sha256 # Linux x64 (baseline: CPUID-dispatched SIMD, so it still runs on - # pre-AVX2 CPUs). Bun's default x64 target is x86-64-v3 and SIGILLs - # on Nehalem/Ivy Bridge class hardware. Proven by the emulated-CPU - # gate in `smoke-binaries`, which is itself proven by the - # default-target control binary compiled below. + # pre-AVX2 CPUs). Up to Bun 1.3 the default x64 target was + # x86-64-v3 and SIGILLed on Nehalem/Ivy Bridge class hardware; since + # Bun 1.4 bun-linux-x64 and bun-linux-x64-baseline are one + # byte-identical runtime, and the baseline target stays as the + # explicit ask. Proven by the emulated-CPU gate in `smoke-binaries`, + # which is itself proven by its AVX2 control. bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --target=bun-linux-x64-baseline --define "__CLI_VERSION__=\"$VERSION\"" --outfile plannotator-linux-x64 sha256sum plannotator-linux-x64 > plannotator-linux-x64.sha256 @@ -160,27 +162,23 @@ jobs: bun build apps/paste-service/targets/bun.ts --compile --no-compile-autoload-bunfig --target=bun-windows-arm64 --outfile plannotator-paste-win32-arm64.exe sha256sum plannotator-paste-win32-arm64.exe > plannotator-paste-win32-arm64.exe.sha256 - # Control subject for the emulated-CPU gate in `smoke-binaries`. Same - # source, same Bun, same flags as the shipped Linux x64 binary — only the - # target differs (bun-linux-x64 is x86-64-v3/AVX2). The gate asserts this - # one dies with SIGILL on the emulated pre-AVX2 CPU, which is what makes - # "the baseline binary ran there" a falsifiable claim. Uploaded under its - # own artifact name so it can never reach the `binaries` artifact, the - # attestation subjects, or the GitHub Release (both of which resolve - # `binaries` by name). - - name: Compile baseline-gate control binary (default x64 target) + # Every binary carries the Bun runtime it was compiled with, root + # certificates included. Fail unless each one embeds Bun >= 1.4.4, has + # the blocked-key check, and holds no emSign ECC Root CA - G3 (the + # compromised eMudhra chain, Oct 2026). Before any upload. + - name: Gate - Bun runtime trust in every binary run: | - VERSION=$(jq -r .version package.json) - mkdir -p baseline-control - bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --target=bun-linux-x64 --define "__CLI_VERSION__=\"$VERSION\"" --outfile baseline-control/control-cli-linux-x64-default - - - name: Upload baseline-gate control binary - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: baseline-gate-control - path: baseline-control/control-cli-linux-x64-default - if-no-files-found: error - retention-days: 1 + set -euo pipefail + binaries=() + for f in plannotator-*; do + [[ "$f" == *.sha256 ]] || binaries+=("$f") + done + # 6 CLI + 6 paste targets: never a gate over nothing. + if (( ${#binaries[@]} < 12 )); then + echo "FAIL: expected at least 12 binaries, found ${#binaries[@]}" >&2 + exit 1 + fi + bun scripts/check-bun-runtime-trust.ts "${binaries[@]}" - name: Upload artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -190,6 +188,17 @@ jobs: plannotator-* !*.ts + # What the binaries embed, for smoke-binaries to compare each served page + # with byte for byte. Its own artifact: never a release asset. + - name: Record embedded page digests + run: sha256sum apps/hook/dist/index.html apps/hook/dist/review.html apps/hook/dist/inbox.html apps/hook/dist/diagram-engine/*.js | tee page-digests.txt + + - name: Upload embedded page digests + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: page-digests + path: page-digests.txt + smoke-binaries: needs: build runs-on: ${{ matrix.os }} @@ -221,11 +230,43 @@ jobs: name: binaries path: artifacts + - name: Download embedded page digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: page-digests + path: page-digests + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 package-manager-cache: false + # Only for the embedded-file probe in "Embedded pages" below, compiled on + # this runner with the Bun the release binaries are built with. + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.4 + no-cache: true + + # The pages are embedded as files and read when a server starts (#2215). + # Each is served byte for byte as built (local, and gzip in remote mode), + # and a process whose binary is replaced the way this OS's installer does + # it (plannotator update, Update now, auto-update) still serves its own + # page; a probe proves an embedded file first read after the replacement + # is the running binary's own. scripts/smoke-pages.mjs. + - name: Embedded pages, as built and after a self-update + shell: bash + env: + BINARY_NAME: ${{ matrix.binary }} + BROWSER: "true" + run: | + set -euo pipefail + binary="artifacts/${BINARY_NAME}" + replacement="artifacts/${BINARY_NAME/plannotator-/plannotator-paste-}" + chmod +x "$binary" "$replacement" 2>/dev/null || true + node scripts/smoke-pages.mjs --binary "$binary" --replacement "$replacement" \ + --digests page-digests/page-digests.txt --bun bun --port-base 19560 + - name: Install QEMU for the older-CPU gate if: runner.os == 'Linux' timeout-minutes: 5 @@ -243,45 +284,42 @@ jobs: echo "FAIL: could not install qemu-user after 3 attempts" >&2 exit 1 - - name: Download baseline-gate control binary - if: runner.os == 'Linux' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: baseline-gate-control - path: control - # Control for the gate below. Without it, "the baseline binary ran under # -cpu Nehalem" is unfalsifiable: a qemu that quietly emulated AVX2, an - # ignored -cpu flag, or a gate that never really executed the artifact - # would all look exactly like a pass. This runs the SAME source built with - # Bun's default x64 target (x86-64-v3/AVX2) through the SAME qemu - # invocation and requires it to die, so a green gate means the gate can - # actually tell the two builds apart. - - name: Control - default-target x64 binary must SIGILL on the emulated CPU + # ignored -cpu flag, or a gate that never really executed anything would + # all look exactly like a pass. Up to Bun 1.3 the control was the same + # source built for Bun's default x64 target (x86-64-v3); since Bun 1.4 + # bun-linux-x64 and bun-linux-x64-baseline are one byte-identical + # runtime, so that build runs on Nehalem too. The control is now one + # AVX2 instruction: it runs on the runner and must die with SIGILL under + # the SAME qemu invocation, so a green gate means qemu enforced the CPU. + - name: Control - an AVX2 instruction must SIGILL on the emulated CPU if: runner.os == 'Linux' timeout-minutes: 5 env: - CONTROL_BINARY: control/control-cli-linux-x64-default CPU_MODEL: Nehalem RUN_TIMEOUT: 120s run: | set -euo pipefail ulimit -c 0 - chmod +x "$CONTROL_BINARY" + probe_dir="$(mktemp -d)" + printf '%s\n' 'int main(void) { __asm__ volatile("vpaddd %%ymm0, %%ymm0, %%ymm0" ::: "xmm0"); return 0; }' > "$probe_dir/avx2.c" + gcc -O0 -o "$probe_dir/avx2-probe" "$probe_dir/avx2.c" + # On a runner whose CPU has AVX2 the probe itself must run. + if grep -qw avx2 /proc/cpuinfo; then + "$probe_dir/avx2-probe" + fi status=0 - timeout --kill-after=5s "$RUN_TIMEOUT" qemu-x86_64 -cpu "$CPU_MODEL" "$CONTROL_BINARY" --version \ - > control.log 2>&1 || status=$? - cat control.log || true - + timeout --kill-after=5s "$RUN_TIMEOUT" qemu-x86_64 -cpu "$CPU_MODEL" "$probe_dir/avx2-probe" || status=$? # 132 == 128 + SIGILL(4); qemu-user re-raises the guest signal and # timeout propagates signal deaths the same way. if [ "$status" -ne 132 ]; then - echo "FAIL: control binary $CONTROL_BINARY (built with bun-linux-x64, AVX2) exited ${status} under qemu-x86_64 -cpu ${CPU_MODEL}; expected 132 (SIGILL)." >&2 - echo "The older-CPU gate cannot distinguish a baseline build from a non-baseline one on this runner, so its result would be meaningless. Fix the harness before trusting the gate." >&2 + echo "FAIL: an AVX2 instruction exited ${status} under qemu-x86_64 -cpu ${CPU_MODEL}; expected 132 (SIGILL)." >&2 + echo "The older-CPU gate cannot show that qemu enforces the CPU model on this runner, so its result would be meaningless." >&2 exit 1 fi - echo "OK: default-target control binary died with SIGILL on ${CPU_MODEL}; the gate below can fail." + echo "OK: an AVX2 instruction died with SIGILL on ${CPU_MODEL}; the gate below can fail." - name: Smoke-test Linux x64 baseline CPU compatibility if: runner.os == 'Linux' @@ -310,7 +348,7 @@ jobs: # Nehalem has SSE4.2 but no AVX, AVX2 or BMI instructions. Execute the # downloaded release artifacts, not a separately compiled probe. The - # control step above proves a non-baseline build fails right here. + # control step above proves an AVX2 instruction fails right here. timeout --kill-after=5s "$RUN_TIMEOUT" qemu-x86_64 -cpu "$CPU_MODEL" "$BINARY" --help \ || fail "CLI --help did not run on the emulated CPU" "$BINARY" @@ -867,7 +905,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 no-cache: true - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -1199,7 +1237,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 no-cache: true - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -1257,7 +1295,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 no-cache: true - name: Download binary subjects diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index fb9478489c..112d47c217 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -615,7 +615,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 - name: Materialize packages for license evidence shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75833aabf5..8b7cd03556 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile @@ -485,7 +485,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile @@ -596,7 +596,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 if: steps.gate.outputs.run == 'true' with: - bun-version: 1.3.14 + bun-version: 1.4.4 - name: Install dependencies if: steps.gate.outputs.run == 'true' @@ -651,7 +651,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile @@ -673,7 +673,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.4 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: diff --git a/apps/amp-plugin/bunfig.toml b/apps/amp-plugin/bunfig.toml index 05bf3fe701..e30033459e 100644 --- a/apps/amp-plugin/bunfig.toml +++ b/apps/amp-plugin/bunfig.toml @@ -5,10 +5,11 @@ [test] preload = ["../../tests/setup/gh-tripwire.ts", "../../tests/setup/feedback-archive-off.ts", "../../packages/ui/test-setup/happy-dom.ts"] -# Load image and video assets as files, never as JavaScript. Bun 1.3.14 has no runtime -# loader of its own for some image types: a `.webp` import worked until the test -# file importing it grew past 50 KiB and was served from Bun's runtime -# transpiler cache, then parsed as JS ("Expected ;" in tater-sidebar.webp, #2154). +# Load image and video assets as files, never as JavaScript. Bun 1.3 loses the +# file treatment of an extension it has no loader for once the file importing it +# is past 50 KiB and served from the runtime transpiler cache, then parses the +# asset as JS ("Expected ;" in tater-sidebar.webp, #2154). Bun 1.4.4 does not; +# the explicit loader still states the intent and covers an older Bun. # tests/package-bunfig.test.ts keeps this list in step with the assets the # source imports, and every package bunfig in step with this one. [loader] diff --git a/apps/guides-show/bunfig.toml b/apps/guides-show/bunfig.toml index 05bf3fe701..e30033459e 100644 --- a/apps/guides-show/bunfig.toml +++ b/apps/guides-show/bunfig.toml @@ -5,10 +5,11 @@ [test] preload = ["../../tests/setup/gh-tripwire.ts", "../../tests/setup/feedback-archive-off.ts", "../../packages/ui/test-setup/happy-dom.ts"] -# Load image and video assets as files, never as JavaScript. Bun 1.3.14 has no runtime -# loader of its own for some image types: a `.webp` import worked until the test -# file importing it grew past 50 KiB and was served from Bun's runtime -# transpiler cache, then parsed as JS ("Expected ;" in tater-sidebar.webp, #2154). +# Load image and video assets as files, never as JavaScript. Bun 1.3 loses the +# file treatment of an extension it has no loader for once the file importing it +# is past 50 KiB and served from the runtime transpiler cache, then parses the +# asset as JS ("Expected ;" in tater-sidebar.webp, #2154). Bun 1.4.4 does not; +# the explicit loader still states the intent and covers an older Bun. # tests/package-bunfig.test.ts keeps this list in step with the assets the # source imports, and every package bunfig in step with this one. [loader] diff --git a/apps/hook/bunfig.toml b/apps/hook/bunfig.toml index 42f6741f32..6d6ab97904 100644 --- a/apps/hook/bunfig.toml +++ b/apps/hook/bunfig.toml @@ -7,10 +7,11 @@ preload = ["../../tests/setup/gh-tripwire.ts", "../../tests/setup/feedback-archi # The mod's `claude plugin test` harness tests (see the root bunfig.toml). pathIgnorePatterns = ["tests/**"] -# Load image and video assets as files, never as JavaScript. Bun 1.3.14 has no runtime -# loader of its own for some image types: a `.webp` import worked until the test -# file importing it grew past 50 KiB and was served from Bun's runtime -# transpiler cache, then parsed as JS ("Expected ;" in tater-sidebar.webp, #2154). +# Load image and video assets as files, never as JavaScript. Bun 1.3 loses the +# file treatment of an extension it has no loader for once the file importing it +# is past 50 KiB and served from the runtime transpiler cache, then parses the +# asset as JS ("Expected ;" in tater-sidebar.webp, #2154). Bun 1.4.4 does not; +# the explicit loader still states the intent and covers an older Bun. # tests/package-bunfig.test.ts keeps this list in step with the assets the # source imports, and every package bunfig in step with this one. [loader] diff --git a/apps/hook/server/bunfig.toml b/apps/hook/server/bunfig.toml index 3d09adad8b..d3072f7f29 100644 --- a/apps/hook/server/bunfig.toml +++ b/apps/hook/server/bunfig.toml @@ -6,10 +6,11 @@ [test] preload = ["../../../tests/setup/gh-tripwire.ts", "../../../tests/setup/feedback-archive-off.ts", "../../../packages/ui/test-setup/happy-dom.ts"] -# Load image and video assets as files, never as JavaScript. Bun 1.3.14 has no runtime -# loader of its own for some image types: a `.webp` import worked until the test -# file importing it grew past 50 KiB and was served from Bun's runtime -# transpiler cache, then parsed as JS ("Expected ;" in tater-sidebar.webp, #2154). +# Load image and video assets as files, never as JavaScript. Bun 1.3 loses the +# file treatment of an extension it has no loader for once the file importing it +# is past 50 KiB and served from the runtime transpiler cache, then parses the +# asset as JS ("Expected ;" in tater-sidebar.webp, #2154). Bun 1.4.4 does not; +# the explicit loader still states the intent and covers an older Bun. # tests/package-bunfig.test.ts keeps this list in step with the assets the # source imports, and every package bunfig in step with this one. [loader] diff --git a/apps/hook/server/diagram-engine.ts b/apps/hook/server/diagram-engine.ts index cae28fe249..f53ce79d2a 100644 --- a/apps/hook/server/diagram-engine.ts +++ b/apps/hook/server/diagram-engine.ts @@ -8,8 +8,11 @@ * embedded file's path; the text is read from it on first need, off the * request path (the server's `warm()`). Measured on bun 1.3.14: a * `type: "text"` dynamic import of the 4.8 MB Mermaid file took ~2.4 s in a - * compiled binary, this takes ~5 ms. A source checkout without the files - * keeps working: the import fails, the server advertises no engine, and + * compiled binary, this takes ~5 ms. On bun 1.4.4 both take ~2 ms, but Bun + * 1.4 stores a `type: "text"` import that is not pure ASCII as UTF-16, twice + * its size, so `type: "file"` keeps the binary ~5 MB smaller (the pages in + * index.ts are embedded as files for the same reason). A source checkout + * without the files keeps working: the import fails, the server advertises no engine, and * diagram fences show as code. */ import type { DiagramEngineSources } from "@plannotator/shared/diagram-engine-assets"; diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 868b29d3d7..5178aafe92 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -153,7 +153,7 @@ import { BRIDGE_SCRIPT, LIVE_BRIDGE_BOOTSTRAP, } from "@plannotator/ui/components/html-viewer/bridge-script"; -import { rmSync, realpathSync, existsSync } from "fs"; +import { rmSync, realpathSync, existsSync, readFileSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; import { composePlanDeniedMessage, @@ -246,18 +246,28 @@ import { supportsAnnotateClientLease, } from "./annotate-output"; -// Embed the built HTML at compile time -// @ts-ignore - Bun import attribute for text -import planHtml from "../dist/index.html" with { type: "text" }; -const planHtmlContent = planHtml as unknown as string; - -// @ts-ignore - Bun import attribute for text -import reviewHtml from "../dist/review.html" with { type: "text" }; -const reviewHtmlContent = reviewHtml as unknown as string; - -// @ts-ignore - Bun import attribute for text -import inboxHtml from "../dist/inbox.html" with { type: "text" }; -const inboxHtmlContent = inboxHtml as unknown as string; +// The built pages, embedded at compile time as files and read into a string +// when a server first needs one (then kept). Not `type: "text"`: from Bun 1.4 +// that stores each page as its own embedded file, in UTF-16 when it holds any +// character past Latin-1 (these do), so the three ~20 MB pages cost twice +// their size in the binary (+64 MB, #2215). As files they are stored as built. +// The read is from the binary's embedded files, which Bun holds from startup: +// replacing the binary (the installer renames a new one over it) does not +// change what a running process reads (smoke-binaries checks this). +// @ts-ignore - Bun import attribute for file +import planHtmlPath from "../dist/index.html" with { type: "file" }; +// @ts-ignore - Bun import attribute for file +import reviewHtmlPath from "../dist/review.html" with { type: "file" }; +// @ts-ignore - Bun import attribute for file +import inboxHtmlPath from "../dist/inbox.html" with { type: "file" }; + +function embeddedPage(path: unknown): () => string { + let html: string | undefined; + return () => (html ??= readFileSync(path as string, "utf8")); +} +const planHtmlContent = embeddedPage(planHtmlPath); +const reviewHtmlContent = embeddedPage(reviewHtmlPath); +const inboxHtmlContent = embeddedPage(inboxHtmlPath); // Code review's diagram engine: embedded too, read on first need. import { loadEmbeddedDiagramEngine } from "./diagram-engine"; @@ -759,7 +769,7 @@ if (args[0] === "install-runtime") { // (inbox/inbox.json). Dispatched before the session registry and the // auto-update trigger below, which belong to review sessions. if (args[0] === "inbox") { - await runInboxCommand(args.slice(1), { htmlContent: inboxHtmlContent }); + await runInboxCommand(args.slice(1), { htmlContent: inboxHtmlContent() }); } if (isInteractiveNoArgInvocation(args, process.stdin.isTTY)) { @@ -1051,7 +1061,7 @@ if (args[0] === "sessions") { const server = await startGoalSetupServer({ bundle, origin: detectedOrigin, - htmlContent: planHtmlContent, + htmlContent: planHtmlContent(), onReady: (url, isRemote, port) => { handleGoalSetupServerReady(url, isRemote, port); }, @@ -1481,7 +1491,7 @@ if (args[0] === "sessions") { // The approved branch below prints result.feedback after the prompt, so // this CLI's origins may see approve-carrying menu items (spec §6.4). approvalNotesSupported: supportsReviewApprovalNotes(detectedOrigin), - htmlContent: reviewHtmlContent, + htmlContent: reviewHtmlContent(), diagramEngine: loadEmbeddedDiagramEngine, tailnetPublished: tailscaleFlag, onCleanup: worktreeCleanup, @@ -1776,7 +1786,7 @@ if (args[0] === "sessions") { convertHtml: renderMarkdownFlag, agentCwd: projectRoot, project: annotateProject, - htmlContent: planHtmlContent, + htmlContent: planHtmlContent(), tailnetPublished: tailscaleFlag, onReady: async (url, isRemote, port) => { if (tailscaleFlag) { @@ -2058,7 +2068,7 @@ if (args[0] === "sessions") { hook: hookFlag, isRemote: isRemoteSession(), }), - htmlContent: planHtmlContent, + htmlContent: planHtmlContent(), recentMessages: pickerMessages, tailnetPublished: tailscaleFlag, onReady: async (url, isRemote, port) => { @@ -2119,7 +2129,7 @@ if (args[0] === "sessions") { mode: "archive", sharingEnabled, shareBaseUrl, - htmlContent: planHtmlContent, + htmlContent: planHtmlContent(), onReady: (url, isRemote, port) => { handleServerReady(url, isRemote, port); }, @@ -2180,7 +2190,7 @@ if (args[0] === "sessions") { sharingEnabled: bridgeSharingEnabled, shareBaseUrl: bridgeShareBaseUrl, pasteApiUrl: bridgePasteApiUrl, - htmlContent: planHtmlContent, + htmlContent: planHtmlContent(), opencodeClient: makeOpenCodeBridgeClient(input.agents), onReady: async (url, isRemote, port) => { await handleServerReady(url, isRemote, port); @@ -2418,7 +2428,7 @@ if (args[0] === "sessions") { // never trick an old bridge into dropping a reviewer's note. approvalNotesSupported: supportsReviewApprovalNotes("opencode") && input.supportsApprovalNotes === true, - htmlContent: reviewHtmlContent, + htmlContent: reviewHtmlContent(), diagramEngine: loadEmbeddedDiagramEngine, opencodeClient: makeOpenCodeBridgeClient(input.agents), onReady: (url, isRemote, port) => { @@ -2532,7 +2542,7 @@ if (args[0] === "sessions") { hook: false, isRemote: isRemoteSession(), }), - htmlContent: planHtmlContent, + htmlContent: planHtmlContent(), onReady: (url, isRemote, port) => { handleAnnotateServerReady(url, isRemote, port); }, @@ -2595,7 +2605,7 @@ if (args[0] === "sessions") { sharingEnabled, shareBaseUrl, pasteApiUrl, - htmlContent: planHtmlContent, + htmlContent: planHtmlContent(), onReady: async (url, isRemote, port) => { handleServerReady(url, isRemote, port); @@ -2700,7 +2710,7 @@ if (args[0] === "sessions") { hook: hookFlag, isRemote: isRemoteSession(), }), - htmlContent: planHtmlContent, + htmlContent: planHtmlContent(), onReady: async (url, isRemote, port) => { handleAnnotateServerReady(url, isRemote, port); @@ -2791,7 +2801,7 @@ if (args[0] === "sessions") { sharingEnabled, shareBaseUrl, pasteApiUrl, - htmlContent: planHtmlContent, + htmlContent: planHtmlContent(), onReady: async (url, isRemote, port) => { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { @@ -2909,7 +2919,7 @@ if (args[0] === "sessions") { sharingEnabled, shareBaseUrl, pasteApiUrl, - htmlContent: planHtmlContent, + htmlContent: planHtmlContent(), onReady: async (url, isRemote, port) => { handleServerReady(url, isRemote, port); @@ -2979,7 +2989,7 @@ if (args[0] === "sessions") { sharingEnabled, shareBaseUrl, pasteApiUrl, - htmlContent: planHtmlContent, + htmlContent: planHtmlContent(), onReady: async (url, isRemote, port) => { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { @@ -3073,7 +3083,7 @@ if (args[0] === "sessions") { sharingEnabled, shareBaseUrl, pasteApiUrl, - htmlContent: planHtmlContent, + htmlContent: planHtmlContent(), onReady: async (url, isRemote, port) => { handleServerReady(url, isRemote, port); diff --git a/apps/marketing/bunfig.toml b/apps/marketing/bunfig.toml index 05bf3fe701..e30033459e 100644 --- a/apps/marketing/bunfig.toml +++ b/apps/marketing/bunfig.toml @@ -5,10 +5,11 @@ [test] preload = ["../../tests/setup/gh-tripwire.ts", "../../tests/setup/feedback-archive-off.ts", "../../packages/ui/test-setup/happy-dom.ts"] -# Load image and video assets as files, never as JavaScript. Bun 1.3.14 has no runtime -# loader of its own for some image types: a `.webp` import worked until the test -# file importing it grew past 50 KiB and was served from Bun's runtime -# transpiler cache, then parsed as JS ("Expected ;" in tater-sidebar.webp, #2154). +# Load image and video assets as files, never as JavaScript. Bun 1.3 loses the +# file treatment of an extension it has no loader for once the file importing it +# is past 50 KiB and served from the runtime transpiler cache, then parses the +# asset as JS ("Expected ;" in tater-sidebar.webp, #2154). Bun 1.4.4 does not; +# the explicit loader still states the intent and covers an older Bun. # tests/package-bunfig.test.ts keeps this list in step with the assets the # source imports, and every package bunfig in step with this one. [loader] diff --git a/apps/opencode-plugin/README.md b/apps/opencode-plugin/README.md index f239dc8b1f..df313d58bd 100644 --- a/apps/opencode-plugin/README.md +++ b/apps/opencode-plugin/README.md @@ -64,6 +64,8 @@ Add to your `opencode.json`: Restart OpenCode. By default, the `submit_plan` tool is available to OpenCode's `plan` agent, not to `build` or other primary agents. +In Bun-hosted OpenCode the embedded server runs on OpenCode's own Bun. Use an OpenCode build on **Bun 1.4.4 or later**: older Bun still trusts the compromised eMudhra certificate chain (October 2026). The package's `engines` field states this floor; it warns, it does not block. + > **OpenCode 1 slash commands:** Run the install script to get `/plannotator-review`, `/plannotator-annotate`, and `/plannotator-last`: > ```bash > curl -fsSL https://plannotator.ai/install.sh | bash diff --git a/apps/opencode-plugin/bunfig.toml b/apps/opencode-plugin/bunfig.toml index 05bf3fe701..e30033459e 100644 --- a/apps/opencode-plugin/bunfig.toml +++ b/apps/opencode-plugin/bunfig.toml @@ -5,10 +5,11 @@ [test] preload = ["../../tests/setup/gh-tripwire.ts", "../../tests/setup/feedback-archive-off.ts", "../../packages/ui/test-setup/happy-dom.ts"] -# Load image and video assets as files, never as JavaScript. Bun 1.3.14 has no runtime -# loader of its own for some image types: a `.webp` import worked until the test -# file importing it grew past 50 KiB and was served from Bun's runtime -# transpiler cache, then parsed as JS ("Expected ;" in tater-sidebar.webp, #2154). +# Load image and video assets as files, never as JavaScript. Bun 1.3 loses the +# file treatment of an extension it has no loader for once the file importing it +# is past 50 KiB and served from the runtime transpiler cache, then parses the +# asset as JS ("Expected ;" in tater-sidebar.webp, #2154). Bun 1.4.4 does not; +# the explicit loader still states the intent and covers an older Bun. # tests/package-bunfig.test.ts keeps this list in step with the assets the # source imports, and every package bunfig in step with this one. [loader] diff --git a/apps/opencode-plugin/package.json b/apps/opencode-plugin/package.json index 8126ce75cb..fa4637e04f 100644 --- a/apps/opencode-plugin/package.json +++ b/apps/opencode-plugin/package.json @@ -54,6 +54,6 @@ "@plannotator/shared": "workspace:*" }, "engines": { - "bun": ">=1.0.0" + "bun": ">=1.4.4" } } diff --git a/apps/paste-service/bunfig.toml b/apps/paste-service/bunfig.toml index 05bf3fe701..e30033459e 100644 --- a/apps/paste-service/bunfig.toml +++ b/apps/paste-service/bunfig.toml @@ -5,10 +5,11 @@ [test] preload = ["../../tests/setup/gh-tripwire.ts", "../../tests/setup/feedback-archive-off.ts", "../../packages/ui/test-setup/happy-dom.ts"] -# Load image and video assets as files, never as JavaScript. Bun 1.3.14 has no runtime -# loader of its own for some image types: a `.webp` import worked until the test -# file importing it grew past 50 KiB and was served from Bun's runtime -# transpiler cache, then parsed as JS ("Expected ;" in tater-sidebar.webp, #2154). +# Load image and video assets as files, never as JavaScript. Bun 1.3 loses the +# file treatment of an extension it has no loader for once the file importing it +# is past 50 KiB and served from the runtime transpiler cache, then parses the +# asset as JS ("Expected ;" in tater-sidebar.webp, #2154). Bun 1.4.4 does not; +# the explicit loader still states the intent and covers an older Bun. # tests/package-bunfig.test.ts keeps this list in step with the assets the # source imports, and every package bunfig in step with this one. [loader] diff --git a/apps/pi-extension/README.md b/apps/pi-extension/README.md index 26b3bb7309..c6389131e6 100644 --- a/apps/pi-extension/README.md +++ b/apps/pi-extension/README.md @@ -39,6 +39,11 @@ Plannotator requires **Pi 0.79.1 or newer**. Updating only the Plannotator extension does not repair the security behavior of an older Pi host; update Pi itself before loading the extension. +Run Pi on **Node 22.23.4+, 24.22.1+ or 26.12.1+**. These October 2026 security +releases stop trusting the compromised eMudhra certificate chain. On an older +Node, HTTPS calls made inside Pi still trust it. The package's `engines` field +states this floor; npm warns but does not block the install. + Pi 0.79 introduced project trust for repository-local inputs. In interactive sessions, Pi asks before loading project settings, instructions, resources, and packages, and can save the decision for that working directory. Plannotator diff --git a/apps/pi-extension/bunfig.toml b/apps/pi-extension/bunfig.toml index 05bf3fe701..e30033459e 100644 --- a/apps/pi-extension/bunfig.toml +++ b/apps/pi-extension/bunfig.toml @@ -5,10 +5,11 @@ [test] preload = ["../../tests/setup/gh-tripwire.ts", "../../tests/setup/feedback-archive-off.ts", "../../packages/ui/test-setup/happy-dom.ts"] -# Load image and video assets as files, never as JavaScript. Bun 1.3.14 has no runtime -# loader of its own for some image types: a `.webp` import worked until the test -# file importing it grew past 50 KiB and was served from Bun's runtime -# transpiler cache, then parsed as JS ("Expected ;" in tater-sidebar.webp, #2154). +# Load image and video assets as files, never as JavaScript. Bun 1.3 loses the +# file treatment of an extension it has no loader for once the file importing it +# is past 50 KiB and served from the runtime transpiler cache, then parses the +# asset as JS ("Expected ;" in tater-sidebar.webp, #2154). Bun 1.4.4 does not; +# the explicit loader still states the intent and covers an older Bun. # tests/package-bunfig.test.ts keeps this list in step with the assets the # source imports, and every package bunfig in step with this one. [loader] diff --git a/apps/pi-extension/package.json b/apps/pi-extension/package.json index f791b2938d..ba2ee81ef1 100644 --- a/apps/pi-extension/package.json +++ b/apps/pi-extension/package.json @@ -75,6 +75,9 @@ "peerDependencies": { "@earendil-works/pi-coding-agent": ">=0.79.1" }, + "engines": { + "node": ">=22.23.4 <23 || >=24.22.1 <25 || >=26.12.1" + }, "devDependencies": { "glimpseui": "^0.8.0", "@earendil-works/pi-coding-agent": ">=0.79.1", diff --git a/apps/pi-extension/server/external-annotations.ts b/apps/pi-extension/server/external-annotations.ts index a7b9ca7f7d..2bb71d0ecc 100644 --- a/apps/pi-extension/server/external-annotations.ts +++ b/apps/pi-extension/server/external-annotations.ts @@ -56,6 +56,23 @@ export function createExternalAnnotationHandler(mode: "plan" | "review") { /** Send an event that is not a store mutation (session-closed) to every open stream. */ broadcast, + /** + * End every open stream. A server's stop() calls this before + * closeAllConnections(): that destroys the sockets, and on Node (and Bun + * >= 1.4) a destroy drops what was just written, so the tab never got the + * session-closed sent right before stop(). Ending first sends it. + */ + endStreams() { + for (const res of subscribers) { + try { + res.end(); + } catch { + // Already closed. + } + } + subscribers.clear(); + }, + /** Push annotations directly into the store (bypasses HTTP, reuses same validation). */ addAnnotations(body: unknown): { ids: string[] } | { error: string } { const parsed = transform(body); diff --git a/apps/pi-extension/server/network.test.ts b/apps/pi-extension/server/network.test.ts index be574b7eed..c5f7351121 100644 --- a/apps/pi-extension/server/network.test.ts +++ b/apps/pi-extension/server/network.test.ts @@ -5,6 +5,7 @@ import { mkdtempSync, readFileSync, rmSync, + statSync, writeFileSync, } from "node:fs"; import { createServer } from "node:http"; @@ -169,13 +170,16 @@ describe("pi port selection", () => { await closeServer(servers[1]); process.env.PLANNOTATOR_PORT = `${start}-${start + 1}`; const server = createServer(); + // Bun >= 1.4, like Node, gives every node:http Server its own "listening" + // listener (connection tracking): count only what listenOnPort leaves. + const initialListening = server.listenerCount("listening"); try { expect(await listenOnPort(server)).toEqual({ port: start + 1, portSource: "env", }); expect(server.listenerCount("error")).toBe(0); - expect(server.listenerCount("listening")).toBe(0); + expect(server.listenerCount("listening")).toBe(initialListening); } finally { await closeServer(server); await closeServer(servers[0]); @@ -217,11 +221,12 @@ describe("pi port selection", () => { const { start, servers } = await occupyConsecutivePorts(12); process.env.PLANNOTATOR_PORT = `${start}-${start + servers.length - 1}`; const server = createServer(); + const initialListening = server.listenerCount("listening"); try { await expect(listenOnPort(server)).rejects.toThrow("exhausted"); expect(server.listenerCount("error")).toBe(0); - expect(server.listenerCount("listening")).toBe(0); + expect(server.listenerCount("listening")).toBe(initialListening); } finally { await Promise.all(servers.map(closeServer)); } @@ -234,13 +239,14 @@ describe("pi non-range port compatibility", () => { const { start, servers } = await occupyConsecutivePorts(1); process.env.PLANNOTATOR_PORT = String(start); const server = createServer(); + const initialListening = server.listenerCount("listening"); try { await expect(listenOnPort(server)).rejects.toThrow( new RegExp(`^Port ${start} in use after 5 retries$`), ); expect(server.listenerCount("error")).toBe(0); - expect(server.listenerCount("listening")).toBe(0); + expect(server.listenerCount("listening")).toBe(initialListening); } finally { await closeServer(servers[0]); } @@ -402,9 +408,11 @@ function writeExecutable(dir: string, name: string, body: string): string { return file; } +// The fake browser's `> file` creates the file before printf writes to it: +// wait for content, not just the file, or the read can see it empty. async function waitForFile(file: string, timeoutMs = 2000): Promise { const deadline = Date.now() + timeoutMs; - while (!existsSync(file)) { + while (!existsSync(file) || statSync(file).size === 0) { if (Date.now() > deadline) throw new Error(`timed out waiting for ${file}`); await new Promise((resolve) => setTimeout(resolve, 10)); } diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index 023c6b3b25..a029430fe3 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -1568,6 +1568,8 @@ export async function startAnnotateServer(options: { } } } finally { + // Before closeAllConnections, or a session-closed just sent is lost. + externalAnnotations.endStreams(); server.close(); // close() only stops the listener; drain browser keep-alive sockets so a // stopped session's connections die immediately instead of at the diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 11e2b2f456..636e486188 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -773,6 +773,8 @@ export async function startPlanReviewServer(options: { closeAllFileBrowserWatchers(); aiRuntime?.dispose(); } finally { + // Before closeAllConnections, or a session-closed just sent is lost. + externalAnnotations?.endStreams(); server.close(); // close() only stops the listener; drain browser keep-alive sockets so a // stopped session's connections die immediately instead of at the diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index 65c83ab827..74422c0861 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -4060,6 +4060,8 @@ export async function startReviewServer(options: { callFlowService.cancelAll(); aiRuntime?.dispose(); } finally { + // Before closeAllConnections, or a session-closed just sent is lost. + externalAnnotations.endStreams(); server.close(); // close() only stops the listener; drain browser keep-alive sockets so a // stopped session's connections die immediately instead of at the diff --git a/apps/relay/bunfig.toml b/apps/relay/bunfig.toml index 0269e871e6..46a5f9fd01 100644 --- a/apps/relay/bunfig.toml +++ b/apps/relay/bunfig.toml @@ -6,10 +6,11 @@ [test] preload = ["../../tests/setup/gh-tripwire.ts", "../../tests/setup/feedback-archive-off.ts", "../../packages/ui/test-setup/happy-dom.ts"] -# Load image and video assets as files, never as JavaScript. Bun 1.3.14 has no runtime -# loader of its own for some image types: a `.webp` import worked until the test -# file importing it grew past 50 KiB and was served from Bun's runtime -# transpiler cache, then parsed as JS ("Expected ;" in tater-sidebar.webp, #2154). +# Load image and video assets as files, never as JavaScript. Bun 1.3 loses the +# file treatment of an extension it has no loader for once the file importing it +# is past 50 KiB and served from the runtime transpiler cache, then parses the +# asset as JS ("Expected ;" in tater-sidebar.webp, #2154). Bun 1.4.4 does not; +# the explicit loader still states the intent and covers an older Bun. # tests/package-bunfig.test.ts keeps this list in step with the assets the # source imports, and every package bunfig in step with this one. [loader] diff --git a/apps/skills/bunfig.toml b/apps/skills/bunfig.toml index 05bf3fe701..e30033459e 100644 --- a/apps/skills/bunfig.toml +++ b/apps/skills/bunfig.toml @@ -5,10 +5,11 @@ [test] preload = ["../../tests/setup/gh-tripwire.ts", "../../tests/setup/feedback-archive-off.ts", "../../packages/ui/test-setup/happy-dom.ts"] -# Load image and video assets as files, never as JavaScript. Bun 1.3.14 has no runtime -# loader of its own for some image types: a `.webp` import worked until the test -# file importing it grew past 50 KiB and was served from Bun's runtime -# transpiler cache, then parsed as JS ("Expected ;" in tater-sidebar.webp, #2154). +# Load image and video assets as files, never as JavaScript. Bun 1.3 loses the +# file treatment of an extension it has no loader for once the file importing it +# is past 50 KiB and served from the runtime transpiler cache, then parses the +# asset as JS ("Expected ;" in tater-sidebar.webp, #2154). Bun 1.4.4 does not; +# the explicit loader still states the intent and covers an older Bun. # tests/package-bunfig.test.ts keeps this list in step with the assets the # source imports, and every package bunfig in step with this one. [loader] diff --git a/apps/telemetry/bunfig.toml b/apps/telemetry/bunfig.toml index 0d3f717308..32028a26af 100644 --- a/apps/telemetry/bunfig.toml +++ b/apps/telemetry/bunfig.toml @@ -6,10 +6,11 @@ [test] preload = ["../../tests/setup/gh-tripwire.ts", "../../tests/setup/feedback-archive-off.ts", "../../packages/ui/test-setup/happy-dom.ts"] -# Load image and video assets as files, never as JavaScript. Bun 1.3.14 has no runtime -# loader of its own for some image types: a `.webp` import worked until the test -# file importing it grew past 50 KiB and was served from Bun's runtime -# transpiler cache, then parsed as JS ("Expected ;" in tater-sidebar.webp, #2154). +# Load image and video assets as files, never as JavaScript. Bun 1.3 loses the +# file treatment of an extension it has no loader for once the file importing it +# is past 50 KiB and served from the runtime transpiler cache, then parses the +# asset as JS ("Expected ;" in tater-sidebar.webp, #2154). Bun 1.4.4 does not; +# the explicit loader still states the intent and covers an older Bun. # tests/package-bunfig.test.ts keeps this list in step with the assets the # source imports, and every package bunfig in step with this one. [loader] diff --git a/apps/vscode-extension/bunfig.toml b/apps/vscode-extension/bunfig.toml index 05bf3fe701..e30033459e 100644 --- a/apps/vscode-extension/bunfig.toml +++ b/apps/vscode-extension/bunfig.toml @@ -5,10 +5,11 @@ [test] preload = ["../../tests/setup/gh-tripwire.ts", "../../tests/setup/feedback-archive-off.ts", "../../packages/ui/test-setup/happy-dom.ts"] -# Load image and video assets as files, never as JavaScript. Bun 1.3.14 has no runtime -# loader of its own for some image types: a `.webp` import worked until the test -# file importing it grew past 50 KiB and was served from Bun's runtime -# transpiler cache, then parsed as JS ("Expected ;" in tater-sidebar.webp, #2154). +# Load image and video assets as files, never as JavaScript. Bun 1.3 loses the +# file treatment of an extension it has no loader for once the file importing it +# is past 50 KiB and served from the runtime transpiler cache, then parses the +# asset as JS ("Expected ;" in tater-sidebar.webp, #2154). Bun 1.4.4 does not; +# the explicit loader still states the intent and covers an older Bun. # tests/package-bunfig.test.ts keeps this list in step with the assets the # source imports, and every package bunfig in step with this one. [loader] diff --git a/bun.lock b/bun.lock index b1b33ee43d..90b92ae261 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "plannotator", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.303", - "@opencode-ai/sdk": "^1.19.3", + "@opencode-ai/sdk": "1.19.3", "@pierre/diffs": "1.5.2", "diff": "^8.0.4", "dockview-react": "^5.2.0", @@ -357,9 +357,6 @@ "@modelcontextprotocol/client": "2.3.1", "glimpseui": "^0.8.0", }, - "peerDependencies": { - "bun": ">=1.0.0", - }, }, "packages/shared": { "name": "@plannotator/shared", @@ -451,6 +448,9 @@ }, }, }, + "patchedDependencies": { + "@opencode-ai/sdk@1.19.3": "patches/@opencode-ai%2Fsdk@1.19.3.patch", + }, "overrides": { "dompurify": "^3.4.17", "lodash-es": "4.18.1", @@ -1067,30 +1067,6 @@ "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], - "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GCpf8QuFLsyioVawP5HrMxA1ZRBlu6Hq9RNnSc3UTUWAzIxBso9trjoZczw1HdgpqSssFkszfIV2zmOzFTjhkw=="], - - "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cIrhwOr0SPEraewznhC+c/k6TG8bwFn5uZ4EJuXwjiKJLcAF36q7/bGjWkeXSe48JwMcPRUR054JXF7+cRwSSA=="], - - "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-09x7wnjMR6M5KGBDBhVl2CpfoCIQOkVDbPX2KfIhpXv4N6grbWE7dfLPw/Ydi9gaUMGhU7UKhoz444Nu6RCycA=="], - - "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dRwzti/qJqV1HWplU27iUWUqp+f2DtFSf2yqQKSb+HH2dDOC//Uqd9u/A5h1DMsLszfP5OGP9UwQIKxVwFODaA=="], - - "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y5yAtCbHK6JjprXEtkdklDQFPADgs+CkfcliyY5g4JJ8baGHyQSrfpSkX3XVJ2C+aBLsdwNDdW+oczMsAwx6uA=="], - - "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.4.0", "", { "os": "android", "cpu": "arm64" }, "sha512-HpPIxJfDNPBPhiBNMyZoo/dOLijARfsx5j72vNuLtaTvl0Hh7HUculxjsOQ2WSyGoCgqXMEr1Qqjab1im9u1RA=="], - - "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RUjAAkJ/CdNV++zVxyANWshPc73CECYsfhk0fWAkoJjtywxJ2BwXzI6nopBBDMfs0HS+fhRGn6zGwU8ccxLeJg=="], - - "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Du44zebtPXJujvMLmtIxEQ6ykOhYt7L/Q+YIGVm+Yy+Pj/fpOnq60ggwIpKp/pGAFbYHNiTrA3JTjuZ9MTbZIg=="], - - "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.4.0", "", { "os": "android", "cpu": "x64" }, "sha512-u++KyLlfMn36yWz+AgJs+fZtS46UFDNpSSZhrcitkytONtNwq0X6Q9BDVEFXxYl/+Eec0xme1rb6MgW+U35WeA=="], - - "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-C1Dv+ISL8YKEKM9jAHzNifOcRUoziy6UMxh+yVXjUCP6QnbRhENDHLaIWWkQZJyBLTn0I3xozflorAlHiGzGqA=="], - - "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FBAYaQpJBP0asgqzL6NFUfjdQqsV+kvTpJ/eWxPKj+RcDgIfPSuE8kvQuPYu5pa8u8JTujYMjmuyvHxVuQsInA=="], - - "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-jRKv1NPLznMSZY5BEWciMF7zv0Tiyo2pQSxAJ3w+YWJ6y3VWNJQQQdLlV5Jx8lbOFDrJdrc9dD3GV17k3BP41A=="], - "@oxc-project/types": ["@oxc-project/types@0.154.0", "", {}, "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA=="], "@pierre/diffs": ["@pierre/diffs@1.5.2", "", { "dependencies": { "@pierre/theme": "2.0.0", "@pierre/theming": "1.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-QVxIWQEnNoko22k63maERzozd29+FtaUNpRrZiw603WMe5R1mFSED+jMtPkcVP3SDYS1DWg5K7sa7sJbr3xkNQ=="], @@ -1641,8 +1617,6 @@ "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], - "bun": ["bun@1.4.0", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.0", "@oven/bun-darwin-x64": "1.4.0", "@oven/bun-freebsd-aarch64": "1.4.0", "@oven/bun-freebsd-x64": "1.4.0", "@oven/bun-linux-aarch64": "1.4.0", "@oven/bun-linux-aarch64-android": "1.4.0", "@oven/bun-linux-aarch64-musl": "1.4.0", "@oven/bun-linux-x64": "1.4.0", "@oven/bun-linux-x64-android": "1.4.0", "@oven/bun-linux-x64-musl": "1.4.0", "@oven/bun-windows-aarch64": "1.4.0", "@oven/bun-windows-x64": "1.4.0" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-iRiFkc2W7UVpCyZXO9tod45TP9QCyN19fWqbpeN/jaM/K7uzeHYx/OSPsahMJazGKBgPsnxRt+4Jc43d8BcHZw=="], - "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], diff --git a/bunfig.toml b/bunfig.toml index c97bce223c..8fdfa0a275 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -14,10 +14,11 @@ preload = ["./tests/setup/gh-tripwire.ts", "./tests/setup/feedback-archive-off.t # proof (apps/telemetry/test) runs `wrangler dev` too, from its own folder. pathIgnorePatterns = ["apps/hook/tests/**", "tests/e2e/**", "apps/relay/**", "apps/telemetry/**"] -# Load image and video assets as files, never as JavaScript. Bun 1.3.14 has no runtime -# loader of its own for some image types: a `.webp` import worked until the test -# file importing it grew past 50 KiB and was served from Bun's runtime -# transpiler cache, then parsed as JS ("Expected ;" in tater-sidebar.webp, #2154). +# Load image and video assets as files, never as JavaScript. Bun 1.3 loses the +# file treatment of an extension it has no loader for once the file importing it +# is past 50 KiB and served from the runtime transpiler cache, then parses the +# asset as JS ("Expected ;" in tater-sidebar.webp, #2154). Bun 1.4.4 does not; +# the explicit loader still states the intent and covers an older Bun. # tests/package-bunfig.test.ts keeps this list in step with the assets the # source imports, and every package bunfig in step with this one. [loader] diff --git a/docs/agent-guides/code-review.md b/docs/agent-guides/code-review.md index a4b2af05d6..022c02a5a2 100644 --- a/docs/agent-guides/code-review.md +++ b/docs/agent-guides/code-review.md @@ -424,7 +424,7 @@ Approved design: owner-approved variant C (design record, not in this repo). A M Owner approval "review-diagrams-on-demand" (2026-10-23; issue #2096). Mermaid and Graphviz fences in a Markdown file's Read and Changes views, and a guide overview's Mermaid diagram, draw with ui's diagram engine (`DiagramBlock`: themed, zoom, the popout), read-only. The single-file review page still carries no diagram runtime (the earlier owner rule: diagrams return to code review only through true lazy loading, never by bundling; `tests/entry-assets.test.ts`): the engine is three separate files the review server serves and the page imports by URL only when a preview or guide overview actually holds a diagram fence. - **The files** (`apps/review/diagram-engine/build.ts`, run by `apps/review`'s `build` after the page build into `apps/review/dist/diagram-engine/`): `engine.js` (ui's `MermaidBlock`/`GraphvizBlock` → `DiagramBlock` path: canvas, overlay, popout, the renderer slot and sanitizer; about 190 KB, entry `engine.tsx`), `mermaid.js` (the Mermaid runtime with ELK, about 4.8 MB) and `graphviz.js` (`@viz-js/viz`, wasm inlined, about 1.3 MB), each a minified ES module. The engine brings no React and no copy of ui's stateful modules: imports of React, `react/jsx-runtime`, `react-dom`, `ThemeProvider`, `config` (the store), `utils/identity` and `utils/math` become modules that read the PAGE's instances from a host object (`DIAGRAM_ENGINE_SHARED_MODULES`, `packages/review-editor/utils/diagramEngineContract.ts`; Rollup `syntheticNamedExports`), so hooks, the theme context and the config store are the page's own. The build fails when the engine would bundle a stateful ui module (`packages/ui/config/`, storage, the theme registry, the identity and math slots, ThemeProvider), any repository module that defines a React context (its own `anchorClaims` excepted), React or a runtime. The renderer slots' `import('mermaid')` / `import('@viz-js/viz')` (exactly one site each in `utils/mermaid.ts` / `utils/graphviz.ts`, or the build fails) are rewritten to imports of the URLs the host carries, so the ui source and the guides.show viewer bundle are unchanged. Mermaid's own `import("katex")` goes to `utils/mermaid-math-slot`, which typesets `$$` labels with the page's math renderer, so the runtime carries no second KaTeX. The Source pane (CodeMirror) is stubbed out: code review never edits a diagram's source. `build:hook` copies the folder to `apps/hook/dist/diagram-engine/`; build:opencode and Pi's build copy it into their packages (`diagram-engine/`, gitignored, in each package's `files`). -- **Serving** (`packages/shared/diagram-engine-assets.ts`, vendored to Pi): the review server takes `diagramEngine` (the three files' text, or a function that reads them; the CLI's `apps/hook/server/diagram-engine.ts` reaches them through literal dynamic `import(..., { with: { type: "file" } })` and reads the embedded path with `Bun.file`, which `bun build --compile` embeds and a source checkout without the files survives (a `type: "text"` dynamic import of the 4.8 MB Mermaid file cost ~2.4 s of startup in a compiled binary on bun 1.3.14); Pi and OpenCode read their package folder), hashes them off the request path at start (`warm()`), advertises `diagramEngine: { engine, mermaid, graphviz }` on `/api/diff` (paths `/assets/diagram-engine/-.js`; absent when the host has no complete set) and answers `GET`/`HEAD /assets/diagram-engine/-.js` with `text/javascript; charset=utf-8`, `Cache-Control: public, max-age=31536000, immutable`, `nosniff` and `Cross-Origin-Resource-Policy: same-origin`; any other name under the prefix is `404` (never the app page), other methods `405`. Remote and `--tailscale` sessions get them compressed exactly as the app page (`prepareAppHtml`'s new `contentType` argument; its cache now holds 8 bodies). Both runtimes: Bun `packages/server/review.ts`, Pi `apps/pi-extension/server/serverReview.ts`; the OpenCode 1 embedded `/plannotator-review` passes its package folder; OpenCode 2 runs the CLI. The host allowlist and the cross-origin write guard apply as to every route. +- **Serving** (`packages/shared/diagram-engine-assets.ts`, vendored to Pi): the review server takes `diagramEngine` (the three files' text, or a function that reads them; the CLI's `apps/hook/server/diagram-engine.ts` reaches them through literal dynamic `import(..., { with: { type: "file" } })` and reads the embedded path with `Bun.file`, which `bun build --compile` embeds and a source checkout without the files survives (a `type: "text"` dynamic import of the 4.8 MB Mermaid file cost ~2.4 s of startup in a compiled binary on bun 1.3.14; on 1.4.4 both take ~2 ms, but Bun 1.4 stores a `type: "text"` import that is not pure ASCII as UTF-16, twice its size, so `type: "file"` keeps the binary ~5 MB smaller; the app pages are embedded the same way, see project-and-development.md); Pi and OpenCode read their package folder), hashes them off the request path at start (`warm()`), advertises `diagramEngine: { engine, mermaid, graphviz }` on `/api/diff` (paths `/assets/diagram-engine/-.js`; absent when the host has no complete set) and answers `GET`/`HEAD /assets/diagram-engine/-.js` with `text/javascript; charset=utf-8`, `Cache-Control: public, max-age=31536000, immutable`, `nosniff` and `Cross-Origin-Resource-Policy: same-origin`; any other name under the prefix is `404` (never the app page), other methods `405`. Remote and `--tailscale` sessions get them compressed exactly as the app page (`prepareAppHtml`'s new `contentType` argument; its cache now holds 8 bodies). Both runtimes: Bun `packages/server/review.ts`, Pi `apps/pi-extension/server/serverReview.ts`; the OpenCode 1 embedded `/plannotator-review` passes its package folder; OpenCode 2 runs the CLI. The host allowlist and the cross-origin write guard apply as to every route. - **The page** (`packages/review-editor/utils/diagramEngine.ts`, `components/ReviewDiagramFence.tsx`): App reads the advert once from `/api/diff` (`parseDiagramEngineUrls`: three same-origin paths, else none) into review state (`diagramEngine`). `MarkdownFilePreview`'s `renderBlock` hands a Mermaid or Graphviz fence (`diagramLanguages`, now exported by `@plannotator/ui/components/diagramLanguages`) to `ReviewDiagramFence`, and `ReviewGuideHost` passes `renderOverviewDiagram` (`components/guide/overviewDiagram.tsx`, Mermaid only; an HTML picture keeps the chain's note). The first such fence installs the host (`globalThis.__plannotatorDiagramEngineHost`: the shared modules and the runtime URLs) and imports the engine by URL (`import(/* @vite-ignore */ url)`, which the single-file build leaves alone), once per page; the engine then imports each runtime on its first diagram of that kind. Until it lands the fence shows as code; a failed import shows the code with a quiet note and Retry, and is forgotten so a later fence tries again. No advert (an older server, a host without the files, the demo): code, as before. The Source view, a review without diagram fences and a guide without an overview diagram request nothing. - **Measured** (2026-10-23, Apple M-series, compiled binaries of main and this change, 5 headless Chromium loads each): the review page grew 4.8 KB (17,199,799 to 17,204,622 bytes; 3 KB gzipped); the engine files are 192 KB, 4,835 KB and 1,318 KB; the median time from navigation to the file tree (about 485 ms), `/api/diff` (about 97 ms) and process start to ready (about 670 ms) were unchanged. Tests: `packages/review-editor/utils/diagramEngine.test.ts` (the loader), `components/MarkdownFilePreview.test.tsx` and `components/guide/overviewDiagram.test.tsx` (DOM: drawn by the engine, code without it, the note when it fails), `packages/server/diagram-engine-assets.test.ts` (both runtimes: the advert, the route's headers, 404/405, no engine, remote compression), `tests/entry-assets.test.ts` (the page imports no engine, only the loader imports by URL, the built files hold their runtimes apart with no bare imports), and `tests/e2e/review-diagrams.spec.ts` (compiled binary: Read and Changes draw both diagrams with each file requested once; a review without diagrams and the Source view request nothing). diff --git a/docs/agent-guides/inbox-files-and-guides.md b/docs/agent-guides/inbox-files-and-guides.md index 1568faf792..87ca6058dc 100644 --- a/docs/agent-guides/inbox-files-and-guides.md +++ b/docs/agent-guides/inbox-files-and-guides.md @@ -14,7 +14,7 @@ - `GET /api/inbox/attachments/:id/image[?version=sent]`: kind `image` only (`415 attachment_not_image` otherwise), `Content-Type` re-sniffed from the bytes served (never the record's or the name's), CSP `sandbox; default-src 'none'; style-src 'unsafe-inline'` (the message-images policy), `inline`, `ETag` = sha256 (`304`), `Cache-Control: private, no-cache`, `X-Image-Width/Height`. The window draws images only by ``, so an SVG never runs. - `GET /api/inbox/attachments/:id/video[?version=sent]` (design record "inbox-video", approved 2026-10-15; not in this repo): kind `video` only (`415 attachment_not_video` otherwise), GET only (`405`), `Content-Type` re-sniffed from the bytes served (`video/mp4`, `video/quicktime`, `video/webm`), `inline`, the message-images CSP, `Cache-Control: private, no-cache`, and HTTP Range (below). The window plays it in `