From 3fbf5e4bceefe9396b3a4956adae76abe9bcacc9 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Wed, 27 May 2026 21:46:48 -0700 Subject: [PATCH 1/6] Pin Node.js via package.json devEngines, single source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the bundled Node.js version pin out of standalone/.node-version and into the root package.json's devEngines.runtime block. pnpm 11 honors this field natively (onFail: "download") so scripts run with the pinned Node locally, eliminating the "wrong node on PATH" failure mode for contributors. build.rs reads the same field to verify the bundled binary, generate-deps.js reads it for the supply-chain disclosure, and release.yml extracts it via jq to drive actions/setup-node — all from one file. Also drops the brittle cargo:rerun-if-changed on the resolved node source path in build.rs: when a node version manager's symlink went dangling (e.g. vfox's cache/current pointing at a deleted version), cargo would keep replaying the stale error instead of re-running. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 10 +++++++- SECURITY.md | 8 +++---- package.json | 7 ++++++ standalone/.node-version | 1 - standalone/src-tauri/Cargo.toml | 1 + standalone/src-tauri/build.rs | 41 ++++++++++++++++++++++---------- website/scripts/generate-deps.js | 13 +++++----- 7 files changed, 57 insertions(+), 24 deletions(-) delete mode 100644 standalone/.node-version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c356be33..dc0f068a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,9 +31,17 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # The bundled Node.js version is pinned in package.json's + # devEngines.runtime.version (see standalone/src-tauri/build.rs, which + # fails the build unless the bundled binary matches this pin). + - name: Read pinned Node.js version + id: node-pin + shell: bash + run: echo "version=$(jq -r '.devEngines.runtime.version' package.json)" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version-file: standalone/.node-version + node-version: ${{ steps.node-pin.outputs.version }} - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 with: diff --git a/SECURITY.md b/SECURITY.md index 41cdbc77..e1af39c8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,12 +16,12 @@ Every dependency shipped in the end-user application is listed at Result<(), Box> { let host = env::var("HOST")?; let node_source = resolve_node_binary(&host, &target)?; - println!("cargo:rerun-if-changed={}", node_source.display()); validate_node_binary(&node_source, &target)?; // The supply-chain page (website/src/data/dependencies-runtime.json) discloses // an exact Node.js version. Fail the build if the binary we're about to bundle - // doesn't match standalone/.node-version, so the disclosed version provably - // equals what ships. CI installs the pin via setup-node's node-version-file. + // doesn't match the pin in the root package.json's devEngines.runtime.version, + // so the disclosed version provably equals what ships. Locally pnpm honors + // devEngines (onFail: "download") so scripts run with the pinned Node; CI + // reads the same field to drive actions/setup-node. let pinned_version = read_pinned_node_version(&manifest_dir)?; verify_node_version(&node_source, &host, &target, &pinned_version)?; @@ -92,15 +93,31 @@ fn reject_macos_dynamic_node(node_source: &Path) -> Result<(), Box> { } fn read_pinned_node_version(manifest_dir: &Path) -> Result> { - let pin_path = manifest_dir + let repo_root = manifest_dir .parent() - .ok_or("manifest dir has no parent")? - .join(".node-version"); + .and_then(Path::parent) + .ok_or("manifest dir has no grandparent (expected /standalone/src-tauri)")?; + let pin_path = repo_root.join("package.json"); println!("cargo:rerun-if-changed={}", pin_path.display()); let raw = fs::read_to_string(&pin_path) .map_err(|err| format!("failed to read {}: {err}", pin_path.display()))?; - let version = raw.trim().trim_start_matches('v').to_owned(); + let pkg: serde_json::Value = serde_json::from_str(&raw) + .map_err(|err| format!("failed to parse {}: {err}", pin_path.display()))?; + let version = pkg + .get("devEngines") + .and_then(|v| v.get("runtime")) + .and_then(|v| v.get("version")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + format!( + "{} is missing devEngines.runtime.version (string)", + pin_path.display() + ) + })? + .trim() + .trim_start_matches('v') + .to_owned(); let is_exact = version.split('.').count() == 3 && version @@ -108,7 +125,7 @@ fn read_pinned_node_version(manifest_dir: &Path) -> Result Date: Thu, 28 May 2026 00:07:18 -0700 Subject: [PATCH 2/6] Lock in pnpm-provisioned Node 22.22.3 across all platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First `pnpm install` after adding devEngines.runtime materialized the runtime in pnpm-lock.yaml as `node@runtime:22.22.3` with sha256-pinned tarballs for every platform variant (aix/darwin/linux/win × cpu archs + musl). This makes the bundled Node provably reproducible from the lockfile alone, on top of build.rs's runtime check. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 3 +- pnpm-lock.yaml | 150 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a236021c..67b74b44 100644 --- a/package.json +++ b/package.json @@ -23,5 +23,6 @@ "dogfood:standalone": "bash standalone/scripts/dogfood.sh", "storybook": "pnpm --filter dormouse-lib storybook", "bundle-themes": "node lib/scripts/bundle-themes.mjs" - } + }, + "devDependencies": {} } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6626384e..fbb60184 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,11 @@ settings: importers: - .: {} + .: + devDependencies: + node: + specifier: runtime:22.22.3 + version: runtime:22.22.3 lib: dependencies: @@ -3233,6 +3237,148 @@ packages: resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==} engines: {node: '>=20'} + node@runtime:22.22.3: + resolution: + type: variations + variants: + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-QAJqz3ioCOQ9IioLDg5xG5X/J5NGlreSW1Mpi8fIxgo= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-aix-ppc64.tar.gz + targets: + - cpu: ppc64 + os: aix + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-Daf/dO+GETKMghLxeUM2hxOirZU/t9iajIoOrofCMgc= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-darwin-arm64.tar.gz + targets: + - cpu: arm64 + os: darwin + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-RYMLp1L6DYksbc1kCUZmmAEpPKyCCjNZHe1ArAdRmOw= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-darwin-x64.tar.gz + targets: + - cpu: x64 + os: darwin + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-zIvIKy3QtZXDuVpMPJyMNQkHz/ARr73uPRN56BLh4+M= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-linux-arm64.tar.gz + targets: + - cpu: arm64 + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-GPvhv91CBFrxXwIlauPllJDRuf+T38J6IjrMLJJ0AlA= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-linux-armv7l.tar.gz + targets: + - cpu: armv7l + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-qQ9SPPFk4cdhv0xZtcaqDZ1VKJJwGraUmeaD7h/Aa7w= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-linux-ppc64le.tar.gz + targets: + - cpu: ppc64le + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-S4TkPCnZDk+QGBXDmRBqSa9O5zDRKTOvgsgtTcRQMIw= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-linux-s390x.tar.gz + targets: + - cpu: s390x + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-x6ENaBbajqqnU03XPHHG4rLDkdu/hF42SQLRVmFd0bg= + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-linux-x64.tar.gz + targets: + - cpu: x64 + os: linux + - resolution: + archive: zip + bin: + node: node.exe + integrity: sha256-AL4SmgnohyzVLTu4u6EkEsVzPSIkEjpIKi3KSm+/JYY= + prefix: node-v22.22.3-win-arm64 + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-win-arm64.zip + targets: + - cpu: arm64 + os: win32 + - resolution: + archive: zip + bin: + node: node.exe + integrity: sha256-bI1U9jX+/033bCyoD0UzLrL/V9JSJu3ONlkuUaF37jM= + prefix: node-v22.22.3-win-x64 + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-win-x64.zip + targets: + - cpu: x64 + os: win32 + - resolution: + archive: zip + bin: + node: node.exe + integrity: sha256-e6QmD2nha6libQd8sRJPP7AfYEIa8qbHOWrrTi0Nja4= + prefix: node-v22.22.3-win-x86 + type: binary + url: https://nodejs.org/download/release/v22.22.3/node-v22.22.3-win-x86.zip + targets: + - cpu: x86 + os: win32 + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-Cch6y6+uZeGPzA6xiDdsElTKt03HaRGPS2BHnF+jJs8= + type: binary + url: https://unofficial-builds.nodejs.org/download/release/v22.22.3/node-v22.22.3-linux-arm64-musl.tar.gz + targets: + - cpu: arm64 + os: linux + libc: musl + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-0idpJYPWrhfEQCj9jxfLGzZyFnprqgNYGCQMu5226ko= + type: binary + url: https://unofficial-builds.nodejs.org/download/release/v22.22.3/node-v22.22.3-linux-x64-musl.tar.gz + targets: + - cpu: x64 + os: linux + libc: musl + version: 22.22.3 + hasBin: true + normalize-package-data@6.0.2: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} @@ -6775,6 +6921,8 @@ snapshots: '@types/sarif': 2.1.7 fs-extra: 11.3.5 + node@runtime:22.22.3: {} + normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 From 27b5477fac68c890821402aaa583aa23705b5ac0 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 28 May 2026 00:14:38 -0700 Subject: [PATCH 3/6] Patch bundled node.exe to PE subsystem 2 (Windows GUI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DETACHED_PROCESS on the sidecar spawn was not actually preventing the visible window — checking the live process tree shows Windows Terminal (class CASCADIA_HOSTING_WINDOW_CLASS) hosting the sidecar with the title set to the node.exe path. On Win11 with WT as the default terminal, the DefTerm COM handoff fires for any console-subsystem (IMAGE_SUBSYSTEM_ WINDOWS_CUI = 3) child regardless of CREATE_NO_WINDOW / DETACHED_PROCESS: those flags suppress the conhost window but not the WT activation. Flip the bundled node.exe's PE Optional Header Subsystem field to 2 (IMAGE_SUBSYSTEM_WINDOWS_GUI) during the build script's post-copy step. A GUI-subsystem binary is never auto-given a console, so the DefTerm path has nothing to hand off and WT is never activated. Node.js itself reads its stdio handles from STARTUPINFO and works identically under either subsystem; smoke-tested with `node -e 'console.log(...)'` after patching. Co-Authored-By: Claude Opus 4.7 (1M context) --- standalone/src-tauri/build.rs | 43 +++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/standalone/src-tauri/build.rs b/standalone/src-tauri/build.rs index f9319c64..cf7a71d0 100644 --- a/standalone/src-tauri/build.rs +++ b/standalone/src-tauri/build.rs @@ -43,6 +43,10 @@ fn bundle_node_runtime() -> Result<(), Box> { let node_dest = binaries_dir.join(node_binary_name(&target)); fs::copy(&node_source, &node_dest)?; + if target.contains("windows") { + force_windows_gui_subsystem(&node_dest)?; + } + #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -55,6 +59,45 @@ fn bundle_node_runtime() -> Result<(), Box> { Ok(()) } +// Rewrite the PE subsystem byte of the bundled node.exe from 3 (Windows +// console) to 2 (Windows GUI). Node.js does not care which subsystem its +// host binary advertises — it reads stdio handles from STARTUPINFO either +// way — but a console-subsystem process triggers Windows' default-terminal +// COM handoff, which on Win11 with Windows Terminal as DefTerm activates WT +// to host the sidecar (visible as a stray WT window titled with the node.exe +// path behind Dormouse). Neither CREATE_NO_WINDOW nor DETACHED_PROCESS opts +// out of that handoff; only a non-console subsystem does. +fn force_windows_gui_subsystem(path: &Path) -> Result<(), Box> { + const IMAGE_SUBSYSTEM_WINDOWS_GUI: u16 = 2; + const IMAGE_SUBSYSTEM_WINDOWS_CUI: u16 = 3; + + let mut bytes = fs::read(path)?; + if bytes.len() < 0x40 || &bytes[0..2] != b"MZ" { + return Err(format!("{} is not a PE/COFF binary", path.display()).into()); + } + let pe_offset = u32::from_le_bytes(bytes[0x3C..0x40].try_into()?) as usize; + // PE signature (4) + COFF header (20) + Optional header up to Subsystem (0x44). + let subsystem_offset = pe_offset + 0x5C; + if bytes.len() < subsystem_offset + 2 || &bytes[pe_offset..pe_offset + 4] != b"PE\0\0" { + return Err(format!("{} has no PE signature at expected offset", path.display()).into()); + } + let current = u16::from_le_bytes(bytes[subsystem_offset..subsystem_offset + 2].try_into()?); + if current == IMAGE_SUBSYSTEM_WINDOWS_GUI { + return Ok(()); + } + if current != IMAGE_SUBSYSTEM_WINDOWS_CUI { + return Err(format!( + "{} has unexpected PE subsystem {current}; refusing to patch", + path.display() + ) + .into()); + } + bytes[subsystem_offset..subsystem_offset + 2] + .copy_from_slice(&IMAGE_SUBSYSTEM_WINDOWS_GUI.to_le_bytes()); + fs::write(path, &bytes)?; + Ok(()) +} + #[cfg(target_os = "macos")] fn validate_node_binary(node_source: &Path, target: &str) -> Result<(), Box> { if target.contains("apple-darwin") { From 294c596bf9982ded69bf05c12beaedd950c4da4c Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 28 May 2026 10:29:32 -0700 Subject: [PATCH 4/6] Clear node.exe's read-only attribute before patching the PE subsystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fs::copy on Windows preserves FILE_ATTRIBUTE_READONLY from the source. When the runtime comes from pnpm's content-addressable store (devEngines onFail: "download"), the source node.exe is read-only, so the destination inherits that and fs::write fails with "access denied" — breaking `pnpm tauri build` on a local Windows workstation. Clear the attribute defensively before writing the patched buffer back. Co-Authored-By: Claude Opus 4.7 (1M context) --- standalone/src-tauri/build.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/standalone/src-tauri/build.rs b/standalone/src-tauri/build.rs index cf7a71d0..87c37af3 100644 --- a/standalone/src-tauri/build.rs +++ b/standalone/src-tauri/build.rs @@ -94,6 +94,17 @@ fn force_windows_gui_subsystem(path: &Path) -> Result<(), Box> { } bytes[subsystem_offset..subsystem_offset + 2] .copy_from_slice(&IMAGE_SUBSYSTEM_WINDOWS_GUI.to_le_bytes()); + + // fs::copy preserves the source's read-only attribute. When the runtime + // comes from pnpm's content-addressable store (devEngines `onFail: + // "download"`), the source node.exe is typically read-only, so the + // destination would be too — and fs::write would fail with "access + // denied". Clear it defensively before writing the patched bytes back. + let mut perms = fs::metadata(path)?.permissions(); + if perms.readonly() { + perms.set_readonly(false); + fs::set_permissions(path, perms)?; + } fs::write(path, &bytes)?; Ok(()) } From f5a1dc92968350d5dde93cfedf6db5d84680c585 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 28 May 2026 10:29:41 -0700 Subject: [PATCH 5/6] Validate the pinned Node.js version after extracting it from package.json `jq -r` on a missing field exits 0 with the literal output "null", which silently propagated to actions/setup-node as `node-version: null` and produced a confusing "Unable to find Node version" error a step later. Validate the MAJOR.MINOR.PATCH shape at the extraction step so a missing or malformed pin fails CI at the right place with a clear message. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc0f068a..4cd1468c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,14 @@ jobs: - name: Read pinned Node.js version id: node-pin shell: bash - run: echo "version=$(jq -r '.devEngines.runtime.version' package.json)" >> "$GITHUB_OUTPUT" + run: | + set -euo pipefail + version=$(jq -r '.devEngines.runtime.version' package.json) + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "ERROR: package.json devEngines.runtime.version is not MAJOR.MINOR.PATCH, got: '$version'" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: From 0797875281bcae3bade31deba47bb80d44d72880 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 28 May 2026 10:29:49 -0700 Subject: [PATCH 6/6] Disclose the Windows PE subsystem byte flip in SECURITY.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build patches one byte of the bundled node.exe on Windows (IMAGE_SUBSYSTEM_WINDOWS_CUI → IMAGE_SUBSYSTEM_WINDOWS_GUI) to suppress Windows Terminal's default-terminal handoff. The version-equality claim still holds — `node --version` runs before the patch — but the bundled binary is no longer byte-identical to the upstream archive, so spell that out for a supply-chain reader. Co-Authored-By: Claude Opus 4.7 (1M context) --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index e1af39c8..2bbbd8b1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,7 +16,7 @@ Every dependency shipped in the end-user application is listed at