diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfd9fd7e9..2bf27c5b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,12 @@ -name: macOS prerelease +name: Desktop previews on: workflow_dispatch: + inputs: + candidates: + description: Build Windows/Linux candidates only (no publishing or macOS signing) + type: boolean + default: false schedule: - cron: "17 */6 * * *" @@ -9,12 +14,12 @@ permissions: contents: read concurrency: - group: macos-prerelease + group: desktop-preview-${{ inputs.candidates && github.ref || 'macos-prerelease' }} cancel-in-progress: false jobs: build: - if: github.repository == 'block/buzz-app' + if: github.repository == 'block/buzz-app' && !inputs.candidates name: Build, sign, and notarize runs-on: macos-latest timeout-minutes: 120 @@ -151,3 +156,140 @@ jobs: --target "$SOURCE_SHA" --prerelease --latest=false \ --title "Buzz $VERSION (macOS preview)" \ --notes "Apple Silicon macOS preview from commit $SOURCE_SHA. Built, signed, and notarized by Actions run $GITHUB_RUN_ID." + + windows: + if: github.repository == 'block/buzz-app' && inputs.candidates + name: Windows x64 candidate (unsigned) + runs-on: windows-2025 + timeout-minutes: 120 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Reuse the native CI pin selection; Hermit does not run on Windows. + - name: Read repository tool pins + id: pins + shell: pwsh + run: | + foreach ($tool in @('rust', 'node', 'pnpm')) { + $pins = @(Get-ChildItem "bin/.$tool-*.pkg") + if ($pins.Count -ne 1) { throw "Expected one $tool pin" } + $version = $pins[0].Name -replace "^\.$tool-(.*)\.pkg$", '$1' + "$tool=$version" >> $env:GITHUB_OUTPUT + } + - name: Select pinned Rust + shell: pwsh + env: + RUST_VERSION: ${{ steps.pins.outputs.rust }} + run: | + rustup toolchain install $env:RUST_VERSION --profile minimal + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + "RUSTUP_TOOLCHAIN=$env:RUST_VERSION" >> $env:GITHUB_ENV + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ steps.pins.outputs.node }} + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: ${{ steps.pins.outputs.pnpm }} + - run: pnpm install --frozen-lockfile + - name: Set candidate version + run: node scripts/candidate-version.mjs + - name: Build NSIS installer + run: pnpm tauri build --ci --no-sign --bundles nsis --config "$RUNNER_TEMP/candidate.json" -- --locked + - name: Install into disposable runner and verify resource payload + shell: pwsh + run: | + $installers = @(Get-ChildItem target/release/bundle/nsis/*.exe) + if ($installers.Count -ne 1) { throw 'Expected exactly one NSIS installer' } + $destination = "$env:RUNNER_TEMP\candidate-install" + # NSIS /D must be last and unquoted, including paths containing spaces. + $process = Start-Process $installers[0].FullName -ArgumentList "/S /D=$destination" -Wait -PassThru + if ($process.ExitCode -ne 0) { throw "Installer failed: $($process.ExitCode)" } + if (!(Test-Path "$destination\buzz-foundation.exe")) { throw 'App executable missing' } + node scripts/verify-runtime-bundle.mjs "$destination\agent-runtime" x86_64-pc-windows-msvc + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Stage candidate and checksum + run: | + mkdir release-assets + installers=(target/release/bundle/nsis/*.exe) + test "${#installers[@]}" -eq 1 + cp "${installers[0]}" "release-assets/Buzz_${VERSION}_x64_unsigned.exe" + printf '%s\n' "$GITHUB_SHA" > release-assets/SOURCE_COMMIT + cd release-assets + sha256sum ./*.exe > SHA256SUMS + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-x64-candidate + path: release-assets/ + if-no-files-found: error + retention-days: 7 + + linux: + if: github.repository == 'block/buzz-app' && inputs.candidates + name: Linux x64 candidates + runs-on: ubuntu-latest + # Shipped old-Buzz recipe, without its mesh/Kubernetes/updater dependencies. + container: ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 + timeout-minutes: 120 + defaults: + run: + shell: bash + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + steps: + - name: Install build and packaging dependencies + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + build-essential ca-certificates curl desktop-file-utils file git \ + libasound2-dev libayatana-appindicator3-dev libgtk-3-dev librsvg2-dev \ + libssl-dev libwebkit2gtk-4.1-dev libxdo-dev patchelf pkg-config \ + squashfs-tools wget xdg-utils + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + # No shared writable release caches. Use existing pins and frozen inputs. + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - run: pnpm install --frozen-lockfile + - name: Install pinned AppImage repacking tools + run: | + wget -q -O /tmp/appimagetool https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage + echo 'ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 /tmp/appimagetool' | sha256sum -c + install -m 755 /tmp/appimagetool /usr/local/bin/appimagetool + wget -q -O /tmp/appimage-runtime https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-x86_64 + echo '2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d /tmp/appimage-runtime' | sha256sum -c + install -D -m 644 /tmp/appimage-runtime /usr/local/lib/appimage-runtime + echo 'APPIMAGETOOL_RUNTIME_FILE=/usr/local/lib/appimage-runtime' >> "$GITHUB_ENV" + - name: Set candidate version + run: node scripts/candidate-version.mjs + - name: Build deb and AppImage + run: pnpm tauri build --ci --no-sign --bundles deb,appimage --config "$RUNNER_TEMP/candidate.json" -- --locked + - name: Repair AppImage and verify both packaged resource payloads + run: | + shopt -s nullglob + debs=(target/release/bundle/deb/*.deb) + images=(target/release/bundle/appimage/*.AppImage) + test "${#debs[@]}" -eq 1 && test "${#images[@]}" -eq 1 + bash scripts/fix-appimage.sh "${images[0]}" + dpkg-deb -x "${debs[0]}" "$RUNNER_TEMP/deb" + node scripts/verify-runtime-bundle.mjs "$RUNNER_TEMP/deb/usr/lib/Buzz Foundation/agent-runtime" x86_64-unknown-linux-gnu + image=$(realpath "${images[0]}") + mkdir "$RUNNER_TEMP/appimage" + (cd "$RUNNER_TEMP/appimage" && "$image" --appimage-extract >/dev/null) + node scripts/verify-runtime-bundle.mjs "$RUNNER_TEMP/appimage/squashfs-root/usr/lib/Buzz Foundation/agent-runtime" x86_64-unknown-linux-gnu + mkdir release-assets + cp "${debs[0]}" "release-assets/Buzz_${VERSION}_amd64.deb" + cp "${images[0]}" "release-assets/Buzz_${VERSION}_x86_64.AppImage" + printf '%s\n' "$GITHUB_SHA" > release-assets/SOURCE_COMMIT + cd release-assets + sha256sum ./*.deb ./*.AppImage > SHA256SUMS + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-x64-candidates + path: release-assets/ + if-no-files-found: error + retention-days: 7 diff --git a/docs/releases.md b/docs/releases.md index 607e0d658..2cabba777 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,6 +1,6 @@ -# macOS test releases +# Desktop test releases -The **macOS prerelease** workflow builds and signs Apple Silicon test builds from +The **Desktop previews** workflow builds and signs Apple Silicon test builds from `main`. It publishes a DMG and `SHA256SUMS` as a GitHub prerelease, tagged `v-preview..` at the built commit. @@ -27,3 +27,59 @@ ID signature. The app retains the signed files' hashes in memory and checks them before each launch. Development builds and other platforms still require the manifest hashes to match. The release workflow verifies the signed seal, notarization, and manifest identity before publishing. + +## Windows and Linux installer candidates + +The same **Desktop previews** workflow has a manual `candidates` switch. It builds +unsigned Windows x64 NSIS `.exe` and Ubuntu 24.04 x64 `.deb`/AppImage artifacts, +without running macOS signing or the release publisher: + +```sh +gh workflow run release.yml --repo block/buzz-app \ + --ref -f candidates=true +``` + +Scheduled runs and dispatches without this switch retain the existing macOS +publication path. Candidates use the same preview version, source commit, pinned +runtime revision and five-tool manifest; they never use legacy Buzz's sidecars, +updater feed, application identifier, or signing secrets. The workflow records +`SOURCE_COMMIT` and checksums over final installer bytes. Download the +`windows-x64-candidate` and `linux-x64-candidates` artifacts from that Actions run +within seven days. These are **ready to try only after their build and payload +checks pass**, not accepted releases. + +Windows uses the repository's Rust, Node and pnpm pins on the hosted MSVC runner +because Hermit does not run there. NSIS retains Tauri's current-user install and +WebView2 download-bootstrapper defaults (network needed if WebView2 is absent). +The job silently installs into a disposable runner directory and verifies the +installed runtime manifest and each tool's hash, without launching the app. + +Linux reuses old Buzz's Ubuntu 24.04 recipe and guarded Wayland/GStreamer AppImage +repair from `block/buzz` tag `desktop-v0.5.25`. Repacking tools and the type2 runtime +are checksum-pinned. Resource binaries must retain their manifest hashes; the +repair restores the verified original tools after linuxdeploy rewrites ELF RPATHs, +then the workflow verifies both extracted package payloads after repacking. +Compatibility guards fail rather than silently omitting a fix. +AppImage still relies on host desktop/media libraries; this is not a promise of +universal distro compatibility. Neither candidate job writes shared build caches. + +### Acceptance still required + +Use disposable Windows 11 and Ubuntu 24.04 GNOME accounts with throwaway keys. +Do not replace an everyday machine's `buzz://` handler without agreement. + +1. Install and launch without a developer toolchain; respect Windows security + policy for unsigned apps. Linux needs a working Secret Service desktop session. +2. Create/import identity, quit and relaunch with the same key. Check unavailable + storage fails safely. Join the intended community, send/receive and reconnect. +3. Check cold/warm `buzz://` links, install a newer preview over the previous one, + verify identity/settings survive, and record uninstall/retained-data behavior. +4. Have a human repeat install → messaging → restart before accepting the build. + +Two Windows native failures were last observed at `a68b39d6` (legacy-import path +separator assertion and model-auth recovery). Re-run the existing manual Windows +CI lane on the candidate and diagnose any surviving failures separately from +installer success. The packaging jobs do not waive them or enable local-agent +hosting. Builderlab/NIP-FI admission remains a separate product limitation. +There is no Windows/Linux publication or auto-update in this first candidate +slice; accepted artifact publication follows native acceptance. diff --git a/scripts/build-agent-runtime.mjs b/scripts/build-agent-runtime.mjs index 3a920b340..8b5731bf9 100644 --- a/scripts/build-agent-runtime.mjs +++ b/scripts/build-agent-runtime.mjs @@ -18,22 +18,12 @@ import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { runtimeBuildPlatform } from "./runtime-build-platform.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const spec = JSON.parse( await readFile(join(root, "runtime/agent-runtime.json"), "utf8"), ); -// Drop injected credentials and per-shell compiler overrides so cache entries -// for a key come from the same pin, toolchain and build arguments. User-level -// Cargo config and native compiler inputs (CC, CFLAGS) still apply unkeyed. -const env = Object.fromEntries( - Object.entries(process.env).filter( - ([key]) => - !/^(BUZZ_|BUZZODZ_|NOSTR_|DATABRICKS_|CARGO_(BUILD|ENCODED|PROFILE|TARGET)_|RUSTC$|RUSTC_|RUSTFLAGS$|RUSTDOCFLAGS$)/.test( - key, - ), - ), -); -env.PATH = `${join(root, "bin")}:${env.PATH ?? ""}`; +const { env, cargo, rustc } = runtimeBuildPlatform(root); async function run(command, args, capture = false, cwd = root) { return new Promise((accept, reject) => { const child = spawn(command, args, { @@ -53,8 +43,8 @@ async function run(command, args, capture = false, cwd = root) { ); }); } -const toolchain = await run(join(root, "bin/rustc"), ["-vV"], true); -const target = toolchain.match(/^host: (.+)$/m)?.[1]; +const toolchain = await run(rustc, ["-vV"], true); +const target = toolchain.match(/^host: (.+)$/m)?.[1]?.trim(); if (!target) throw new Error("Could not resolve pinned Rust target"); const destination = join(root, "src-tauri/resources/agent-runtime"); const filenames = spec.tools.map((name) => @@ -205,8 +195,7 @@ try { false, source, ); - env.CARGO_TARGET_DIR = join(root, "target/agent-runtime-build"); - await run(join(root, "bin/cargo"), buildArgs, false, source); + await run(cargo, buildArgs, false, source); await publish(join(env.CARGO_TARGET_DIR, target, "release"), destination); console.log( `Verified inputs staged at ${destination} (${spec.revision}, ${target})`, diff --git a/scripts/candidate-version.mjs b/scripts/candidate-version.mjs new file mode 100644 index 000000000..e37f38031 --- /dev/null +++ b/scripts/candidate-version.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { readFileSync, writeFileSync, appendFileSync } from "node:fs"; +import { join } from "node:path"; + +const config = JSON.parse(readFileSync("src-tauri/tauri.conf.json", "utf8")); +const base = config.version.split("-")[0]; +assert.match(base, /^\d+\.\d+\.\d+$/); +const { + GITHUB_RUN_NUMBER: run, + GITHUB_RUN_ATTEMPT: attempt, + RUNNER_TEMP: temp, +} = process.env; +assert.match(run ?? "", /^[1-9]\d*$/); +assert.match(attempt ?? "", /^[1-9]\d*$/); +const version = `${base}-preview.${run}.${attempt}`; +writeFileSync( + join(temp, "candidate.json"), + JSON.stringify({ version, bundle: { createUpdaterArtifacts: false } }), +); +appendFileSync(process.env.GITHUB_ENV, `VERSION=${version}\n`); diff --git a/scripts/fix-appimage.sh b/scripts/fix-appimage.sh new file mode 100644 index 000000000..5f0520b0e --- /dev/null +++ b/scripts/fix-appimage.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# fix-appimage.sh — Remove infra libs from a Tauri-produced AppImage that crash +# on Mesa 25+ / GLib 2.88 distros (Ubuntu 26.04, Fedora 42+, etc.). +# +# Usage: fix-appimage.sh +# +# Set APPIMAGETOOL_RUNTIME_FILE to a pre-downloaded AppImage type2 runtime to +# avoid appimagetool fetching one from its mutable `continuous` tag (CI pins +# this; required for candidate builds). +# +# Root cause — three interlocking failures (upstream: https://github.com/tauri-apps/tauri/issues/15665): +# +# 1. EGL crash: linuxdeploy bundles libwayland-client.so.0 (1.22) alongside +# the app. Mesa 25's libEGL calls the bundled version at runtime; the version +# skew causes eglGetDisplay to return EGL_BAD_PARAMETER under Wayland, which +# WebKitWebProcess treats as fatal and aborts before the window ever appears. +# +# 2. GStreamer crash: linuxdeploy's compiled AppRun.wrapped force-sets +# GST_PLUGIN_SYSTEM_PATH_1_0 to $APPDIR/usr/lib/gstreamer-1.0 -- a dir the +# bundler never populates (bundleMediaFramework is off, and we strip the +# bundled libgst* core below to use the host's). Crucially, once that variable +# is set it *replaces* GStreamer's compiled-in default search path rather than +# adding to it, so the app finds ZERO plugins on every distro: +# "GStreamer element appsink not found" kills the WebKitWebProcess and the +# window never paints. An earlier revision of this script hid the failure on +# Debian only by symlinking usr/lib/gstreamer-1.0 to the Debian multiarch dir +# (/usr/lib/x86_64-linux-gnu/gstreamer-1.0); that symlink dangles on Arch and +# Fedora, and the "safe fallback to default discovery" it assumed does not +# exist -- a set GST_PLUGIN_SYSTEM_PATH_1_0 disables the default. A broken run +# also poisons ~/.cache/gstreamer-1.0/registry.x86_64.bin. +# +# 3. WebKit helper mismatch (latent): the bundled WebKit helpers +# (WebKitNetworkProcess/WebKitWebProcess) have RUNPATH=$ORIGIN only, and +# linuxdeploy string-patches /usr -> ././ inside libwebkit2gtk so the helper +# dir is resolved relative to the process cwd. AppRun's chdir($APPDIR/usr) +# makes this work; any launch that bypasses AppRun (extracted-AppDir usage, +# repack workflows, dbus/systemd activation with cwd=/) resolves the helpers +# wrong -- spawning nothing, dying on unresolved bundled libs, or spawning +# the system helpers -- and the window never appears. +# +# Fix: (a) remove the offending libs so the app uses the system copies (newer and +# ABI-compatible on any distro shipping glib >= 2.72 / Ubuntu 22.04+), and +# (b) install a launcher shim in front of the app binary that strips the +# bundle-pointing GST_PLUGIN_* overrides AppRun.wrapped injects, letting the host +# GStreamer resolve plugins via its own default path (correct on Debian, Arch, and +# Fedora alike). The shim has to run *after* AppRun.wrapped: the wrapper rewrites +# the variable last -- after every apprun-hook -- so any value set before it is +# discarded (verified empirically; a runtime GST_PLUGIN_SYSTEM_PATH_1_0 passed +# into the AppImage does not survive). No tauri.conf.json knob can do this -- +# bundle.linux.appimage only exposes bundleMediaFramework, files (copy-only, no +# remove/symlink), and bundleXdgOpen. + +# Adapted from block/buzz desktop-v0.5.25, desktop/scripts/fix-appimage.sh. +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: fix-appimage.sh " >&2 + exit 1 +fi + +if [[ ! -f "$1" ]]; then + echo "Error: file not found: $1" >&2 + exit 1 +fi + +APPIMAGE_ABS="$(realpath "$1")" +APPIMAGE_NAME="$(basename "$APPIMAGE_ABS")" + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +echo "==> Extracting $APPIMAGE_NAME" +(cd "$WORKDIR" && APPIMAGE_EXTRACT_AND_RUN=1 "$APPIMAGE_ABS" --appimage-extract) + +LIBDIR="$WORKDIR/squashfs-root/usr/lib" + +# Guard against a bundler layout change: if the primary offending lib is not +# where we expect it, the rm globs below would silently no-op and we'd ship +# an unfixed artifact. Fail loudly instead so a tauri/linuxdeploy upgrade +# that changes the bundled lib set gets noticed here, not by users. +if ! compgen -G "$LIBDIR/libwayland-client.so*" > /dev/null; then + echo "Error: libwayland-client not found in $LIBDIR — bundler layout changed; update fix-appimage.sh" >&2 + exit 1 +fi + +echo "==> Removing infra libs that conflict with system Mesa / GLib / GStreamer / systemd" +rm -f \ + "$LIBDIR"/libwayland-client.so* \ + "$LIBDIR"/libwayland-cursor.so* \ + "$LIBDIR"/libwayland-egl.so* \ + "$LIBDIR"/libwayland-server.so* \ + "$LIBDIR"/libglib-2.0.so* \ + "$LIBDIR"/libgio-2.0.so* \ + "$LIBDIR"/libgobject-2.0.so* \ + "$LIBDIR"/libgmodule-2.0.so* \ + "$LIBDIR"/libmount.so* \ + "$LIBDIR"/libblkid.so* \ + "$LIBDIR"/libselinux.so* \ + "$LIBDIR"/libsystemd.so* \ + "$LIBDIR"/libpcre2-8.so* \ + "$LIBDIR"/libgst*.so* \ + "$LIBDIR"/libzstd.so* \ + "$LIBDIR"/libelf.so* \ + "$LIBDIR"/libffi.so* + +echo "==> Installing GStreamer launcher shim on the app binary" +# AppRun.wrapped force-sets GST_PLUGIN_SYSTEM_PATH_1_0 (and the 0.10-era +# GST_PLUGIN_SYSTEM_PATH) to $APPDIR/usr/lib/gstreamer-1.0 — a dir we bundle no +# plugins into. Because a set path *replaces* GStreamer's default instead of +# extending it, the app finds zero plugins on any distro and WebKit aborts. The +# wrapper rewrites the variable after every apprun-hook, so the only place to undo +# it is a shim between AppRun.wrapped and the real binary. First confirm the +# wrapper still injects the override; if a tauri/linuxdeploy bump drops it, the +# shim becomes a harmless no-op, but we want a human to re-verify rather than +# silently ship — so fail loudly (mirrors the libwayland guard above). +APPRUN_WRAPPED="$WORKDIR/squashfs-root/AppRun.wrapped" +if ! grep -aq "GST_PLUGIN_SYSTEM_PATH_1_0" "$APPRUN_WRAPPED"; then + echo "Error: AppRun.wrapped is missing or no longer references GST_PLUGIN_SYSTEM_PATH_1_0 — GStreamer path injection changed; re-verify fix-appimage.sh" >&2 + exit 1 +fi + +APP_BIN="$WORKDIR/squashfs-root/usr/bin/buzz-foundation" +if [[ ! -f "$APP_BIN" ]]; then + echo "Error: app binary usr/bin/buzz-foundation not found — bundler layout changed; update fix-appimage.sh" >&2 + exit 1 +fi +if [[ -e "$APP_BIN.bin" ]]; then + echo "Error: usr/bin/buzz-foundation.bin already exists — shim already installed?" >&2 + exit 1 +fi + +# The real binary moves aside; buzz-foundation becomes a shim AppRun.wrapped execs. +mv "$APP_BIN" "$APP_BIN.bin" +cat > "$APP_BIN" <<'SHIM' +#!/usr/bin/env bash +# GStreamer shim installed by scripts/fix-appimage.sh. +# +# linuxdeploy's AppRun.wrapped force-sets GST_PLUGIN_SYSTEM_PATH_1_0 to an empty +# in-bundle dir ($APPDIR/usr/lib/gstreamer-1.0). A set path *replaces* the host's +# default GStreamer search path, so the app finds zero plugins and WebKit aborts +# (blank window). Drop the bundle-pointing GST_PLUGIN_* overrides so the system +# GStreamer — which we use, having removed the bundled core libs — resolves +# plugins via its own default path on any distro. Values that don't point into +# this AppImage are the user's own and are preserved. +here="$(dirname "$(readlink -f "$0")")" +appdir="$(readlink -f "$here/../..")" +for var in GST_PLUGIN_SYSTEM_PATH_1_0 GST_PLUGIN_SYSTEM_PATH \ + GST_PLUGIN_PATH_1_0 GST_PLUGIN_PATH \ + GST_PLUGIN_SCANNER GST_PLUGIN_SCANNER_1_0; do + val="${!var-}" + if [[ -n "$val" && "$val" == *"$appdir/"* ]]; then + unset "$var" + fi +done +exec -a "buzz-foundation" "$here/buzz-foundation.bin" "$@" +SHIM +chmod +x "$APP_BIN" + +# linuxdeploy rewrites RPATH on every dynamic ELF under usr/lib, including our +# tools. Restore the original, hash-verified payload after its ELF processing; +# never regenerate the manifest to bless transformed bytes. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RUNTIME_SOURCE="$SCRIPT_DIR/../src-tauri/resources/agent-runtime" +RUNTIME_DEST="$LIBDIR/Buzz Foundation/agent-runtime" +if [[ ! -f "$RUNTIME_DEST/manifest.json" || -L "$RUNTIME_DEST" ]]; then + echo "Error: expected runtime resource directory missing or symlinked" >&2 + exit 1 +fi +node "$SCRIPT_DIR/verify-runtime-bundle.mjs" "$RUNTIME_SOURCE" x86_64-unknown-linux-gnu +cmp "$RUNTIME_SOURCE/manifest.json" "$RUNTIME_DEST/manifest.json" +rm -rf "$RUNTIME_DEST" +cp -a "$RUNTIME_SOURCE" "$RUNTIME_DEST" + +echo "==> Repacking AppImage" +: "${APPIMAGETOOL_RUNTIME_FILE:?Set a checksum-verified AppImage type2 runtime}" +APPIMAGE_EXTRACT_AND_RUN=1 ARCH="$(uname -m)" appimagetool \ + --runtime-file "$APPIMAGETOOL_RUNTIME_FILE" \ + "$WORKDIR/squashfs-root" "$APPIMAGE_ABS" + +echo "==> Done: $APPIMAGE_ABS" diff --git a/scripts/runtime-build-platform.mjs b/scripts/runtime-build-platform.mjs new file mode 100644 index 000000000..788644827 --- /dev/null +++ b/scripts/runtime-build-platform.mjs @@ -0,0 +1,30 @@ +import { posix, win32 } from "node:path"; + +// Hermit owns POSIX tools; Windows CI provisions the same pins through rustup. +export function runtimeBuildPlatform( + root, + platform = process.platform, + inherited = process.env, +) { + const windows = platform === "win32"; + const path = windows ? win32 : posix; + // Keep shared-cache builds independent of injected credentials and shell + // compiler overrides. User Cargo config and CC/CFLAGS still apply unkeyed. + const env = Object.fromEntries( + Object.entries(inherited).filter( + ([key]) => + !/^(BUZZ_|BUZZODZ_|NOSTR_|DATABRICKS_|CARGO_(BUILD|ENCODED|PROFILE|TARGET)_|RUSTC$|RUSTC_|RUSTFLAGS$|RUSTDOCFLAGS$)/i.test( + key, + ), + ), + ); + if (!windows) env.PATH = `${path.join(root, "bin")}:${env.PATH ?? ""}`; + // On Windows preserve the runner's Path spelling. Duplicate PATH/Path keys + // cause Node to select only one, silently dropping provisioned tools. + env.CARGO_TARGET_DIR = path.join(root, "target/agent-runtime-build"); + return { + env, + cargo: windows ? "cargo.exe" : path.join(root, "bin/cargo"), + rustc: windows ? "rustc.exe" : path.join(root, "bin/rustc"), + }; +} diff --git a/scripts/verify-runtime-bundle.mjs b/scripts/verify-runtime-bundle.mjs new file mode 100644 index 000000000..af984b57c --- /dev/null +++ b/scripts/verify-runtime-bundle.mjs @@ -0,0 +1,44 @@ +// Verify extracted Windows/Linux payloads, never launch their executables. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync, lstatSync } from "node:fs"; +import { join } from "node:path"; + +const [directory, target] = process.argv.slice(2); +assert.ok( + directory && target, + "Usage: verify-runtime-bundle.mjs ", +); +const source = JSON.parse( + readFileSync( + new URL("../runtime/agent-runtime.json", import.meta.url), + "utf8", + ), +); +const manifest = JSON.parse( + readFileSync(join(directory, "manifest.json"), "utf8"), +); +assert.deepEqual(Object.keys(manifest).sort(), [ + "files", + "revision", + "target", + "version", +]); +assert.equal(manifest.version, 1); +assert.equal(manifest.revision, source.revision); +assert.equal(manifest.target, target); +const windows = target.endsWith("-windows-msvc"); +const names = source.tools.map((name) => (windows ? `${name}.exe` : name)); +assert.deepEqual(Object.keys(manifest.files).sort(), names.sort()); +for (const name of names) { + const file = join(directory, name); + const stat = lstatSync(file); + assert.ok(stat.isFile(), `${name} must be a regular file`); + if (!windows) assert.ok(stat.mode & 0o111, `${name} must be executable`); + assert.equal( + createHash("sha256").update(readFileSync(file)).digest("hex"), + manifest.files[name], + `${name} hash changed during packaging`, + ); +} +console.log(`Verified packaged runtime (${manifest.revision}, ${target})`); diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index bd058fd82..023deda9b 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -907,6 +907,13 @@ fn startup_trace(value: serde_json::Value) { pub(crate) const NOT_WAITING_FOR_GOOSE: &str = "Agent no longer waiting for Goose"; pub(crate) const NOT_WAITING_FOR_PI: &str = "Agent no longer waiting for Pi"; #[derive(Clone, Copy)] +#[cfg_attr( + not(any(target_os = "macos", target_os = "linux")), + expect( + dead_code, + reason = "Harness installation is unavailable on this platform" + ) +)] pub(crate) enum InstallRestart { Goose, Pi, diff --git a/src-tauri/src/harness_setup.rs b/src-tauri/src/harness_setup.rs index f902ed0b6..4b6a03360 100644 --- a/src-tauri/src/harness_setup.rs +++ b/src-tauri/src/harness_setup.rs @@ -378,7 +378,7 @@ pub(crate) async fn pi_install( #[cfg(not(any(target_os = "macos", target_os = "linux")))] { let _ = (app, state, agents); - return Err("Pi installation is supported only on macOS and Linux".into()); + Err("Pi installation is supported only on macOS and Linux".into()) } #[cfg(any(target_os = "macos", target_os = "linux"))] { diff --git a/tests/integration/agent-runtime-fixture.mjs b/tests/integration/agent-runtime-fixture.mjs index f0daf656b..8973568fa 100644 --- a/tests/integration/agent-runtime-fixture.mjs +++ b/tests/integration/agent-runtime-fixture.mjs @@ -7,6 +7,7 @@ export function runtimeFixture(directory) { mkdirSync(path.join(directory, name), { recursive: true }); for (const name of [ "scripts/build-agent-runtime.mjs", + "scripts/runtime-build-platform.mjs", "runtime/agent-runtime.json", ]) copyFileSync( diff --git a/tests/integration/appimage-repair.test.mjs b/tests/integration/appimage-repair.test.mjs new file mode 100644 index 000000000..43cddcc77 --- /dev/null +++ b/tests/integration/appimage-repair.test.mjs @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; + +// Execute the production repair script. Only AppImage extraction/repacking is +// synthetic: this proves byte restoration and failure gates, not ELF/GUI behavior. +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "buzz-appimage-repair-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + for (const name of ["scripts", "runtime", "tools"]) + mkdirSync(join(root, name)); + for (const name of [ + "scripts/fix-appimage.sh", + "scripts/verify-runtime-bundle.mjs", + "runtime/agent-runtime.json", + ]) + copyFileSync(new URL(`../../${name}`, import.meta.url), join(root, name)); + const source = join(root, "src-tauri/resources/agent-runtime"); + mkdirSync(source, { recursive: true }); + const spec = JSON.parse( + readFileSync(join(root, "runtime/agent-runtime.json")), + ); + const files = {}; + for (const name of spec.tools) { + writeFileSync(join(source, name), `original ${name}`, { mode: 0o755 }); + files[name] = createHash("sha256").update(`original ${name}`).digest("hex"); + } + writeFileSync( + join(source, "manifest.json"), + JSON.stringify({ + version: 1, + revision: spec.revision, + target: "x86_64-unknown-linux-gnu", + files, + }), + ); + const extracted = join(root, "extracted"); + const resource = "usr/lib/Buzz Foundation/agent-runtime"; + const destination = join(extracted, resource); + mkdirSync(dirname(destination), { recursive: true }); + cpSync(source, destination, { recursive: true }); + writeFileSync(join(destination, "buzz-agent"), "linuxdeploy changed RPATH"); + writeFileSync(join(destination, "unexpected-tool"), "stale"); + writeFileSync(join(extracted, "usr/lib/libwayland-client.so.0"), "fixture"); + writeFileSync( + join(extracted, "AppRun.wrapped"), + "GST_PLUGIN_SYSTEM_PATH_1_0", + ); + mkdirSync(join(extracted, "usr/bin")); + writeFileSync(join(extracted, "usr/bin/buzz-foundation"), "fixture app"); + const image = join(root, "fixture.AppImage"); + writeFileSync( + image, + '#!/bin/sh\nset -eu\ntest "$1" = --appimage-extract\ncp -a "$FIXTURE_EXTRACTED" squashfs-root\n', + { mode: 0o755 }, + ); + // Exercise a spaced executable path even when the host Node path has none. + const node = join(root, "tools/node with spaces"); + symlinkSync(process.execPath, node); + writeFileSync( + join(root, "tools/appimagetool"), + `#!/bin/sh +exec "$FIXTURE_NODE" - "$@" <<'NODE' +const fs = require('node:fs'); +const assert = require('node:assert/strict'); +assert.equal(process.argv[2], '--runtime-file'); +assert.equal(process.argv[3], process.env.APPIMAGETOOL_RUNTIME_FILE); +fs.cpSync(process.argv[4], process.env.FIXTURE_REPACKED, { recursive: true }); +NODE +`, + { mode: 0o755 }, + ); + const repacked = join(root, "repacked"); + const run = () => + spawnSync("bash", [join(root, "scripts/fix-appimage.sh"), image], { + encoding: "utf8", + timeout: 10_000, + env: { + ...process.env, + PATH: `${join(root, "tools")}:${dirname(process.execPath)}:${process.env.PATH}`, + FIXTURE_NODE: node, + FIXTURE_EXTRACTED: extracted, + FIXTURE_REPACKED: repacked, + APPIMAGETOOL_RUNTIME_FILE: join(root, "pinned-runtime"), + }, + }); + return { source, destination, repacked, resource, run }; +} + +test("AppImage repair restores original runtime bytes and removes transformed leftovers before repack", (t) => { + const f = fixture(t); + const result = f.run(); + assert.equal(result.status, 0, result.stderr); + for (const name of ["manifest.json", "buzz-agent"]) + assert.deepEqual( + readFileSync(join(f.repacked, f.resource, name)), + readFileSync(join(f.source, name)), + ); + assert.equal( + existsSync(join(f.repacked, f.resource, "unexpected-tool")), + false, + ); + assert.equal( + existsSync(join(f.repacked, "usr/lib/libwayland-client.so.0")), + false, + ); + assert.equal( + readFileSync(join(f.repacked, "usr/bin/buzz-foundation.bin"), "utf8"), + "fixture app", + ); +}); + +for (const condition of [ + "missing packaged manifest", + "mismatched manifest", + "corrupt source", +]) { + test(`AppImage repair refuses ${condition} before repack`, (t) => { + const f = fixture(t); + if (condition === "missing packaged manifest") + rmSync(join(f.destination, "manifest.json")); + if (condition === "mismatched manifest") + writeFileSync(join(f.destination, "manifest.json"), "{}"); + if (condition === "corrupt source") + writeFileSync(join(f.source, "buzz-agent"), "corrupt"); + assert.notEqual(f.run().status, 0); + assert.equal(existsSync(f.repacked), false); + }); +} diff --git a/tests/integration/packaging.test.mjs b/tests/integration/packaging.test.mjs new file mode 100644 index 000000000..4071118cb --- /dev/null +++ b/tests/integration/packaging.test.mjs @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { parse } from "yaml"; +import { runtimeBuildPlatform } from "../../scripts/runtime-build-platform.mjs"; + +const root = new URL("../../", import.meta.url); +const read = (file) => readFileSync(new URL(file, root), "utf8"); +function temporary(t) { + const directory = mkdtempSync(join(tmpdir(), "buzz-packaging-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + return directory; +} + +test("Windows uses provisioned executables without corrupting Path or retaining credentials", () => { + const inherited = { + Path: "C:\\tools;C:\\Windows", + SystemRoot: "C:\\Windows", + RUSTUP_TOOLCHAIN: "1.97.1", + buzz_private_key: "secret", + BuzzODZ_PROFILE: "private", + Nostr_KEY: "secret", + Databricks_TOKEN: "secret", + Cargo_Target_Dir: "old", + Cargo_Build_Target: "other-target", + Cargo_Encoded_Rustflags: "-Copt-level=0", + Cargo_Profile_Release_Opt_Level: "0", + Rustc: "other-rustc", + Rustc_Wrapper: "sccache", + Rustflags: "-C target-cpu=native", + Rustdocflags: "--cfg other", + }; + const { env, cargo, rustc } = runtimeBuildPlatform( + "C:\\repo", + "win32", + inherited, + ); + assert.equal(cargo, "cargo.exe"); + assert.equal(rustc, "rustc.exe"); + assert.deepEqual(env, { + Path: inherited.Path, + SystemRoot: inherited.SystemRoot, + RUSTUP_TOOLCHAIN: inherited.RUSTUP_TOOLCHAIN, + CARGO_TARGET_DIR: "C:\\repo\\target\\agent-runtime-build", + }); + assert.equal( + inherited.buzz_private_key, + "secret", + "must not mutate parent environment", + ); + const posix = runtimeBuildPlatform("/repo", "linux", { + PATH: "/usr/bin", + CARGO_TARGET_DIR: "old", + BUZZ_PRIVATE_KEY: "secret", + }); + assert.equal(posix.cargo, "/repo/bin/cargo"); + assert.equal(posix.rustc, "/repo/bin/rustc"); + assert.deepEqual(posix.env, { + PATH: "/repo/bin:/usr/bin", + CARGO_TARGET_DIR: "/repo/target/agent-runtime-build", + }); +}); + +test("candidate version writes an updater-disabled overlay, never changes product identity", (t) => { + const directory = temporary(t); + const output = join(directory, "env"); + const result = spawnSync( + process.execPath, + ["scripts/candidate-version.mjs"], + { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + RUNNER_TEMP: directory, + GITHUB_ENV: output, + GITHUB_RUN_NUMBER: "42", + GITHUB_RUN_ATTEMPT: "2", + }, + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual( + JSON.parse(readFileSync(join(directory, "candidate.json"))), + { + version: "0.0.0-preview.42.2", + bundle: { createUpdaterArtifacts: false }, + }, + ); + assert.equal(readFileSync(output, "utf8"), "VERSION=0.0.0-preview.42.2\n"); +}); + +for (const target of ["x86_64-pc-windows-msvc", "x86_64-unknown-linux-gnu"]) { + test(`packaged runtime verification rejects transformed or missing payloads: ${target}`, (t) => { + const directory = temporary(t); + const spec = JSON.parse(read("runtime/agent-runtime.json")); + const files = {}; + for (const tool of spec.tools) { + const name = target.endsWith("-msvc") ? `${tool}.exe` : tool; + writeFileSync(join(directory, name), tool, { mode: 0o755 }); + files[name] = createHash("sha256").update(tool).digest("hex"); + } + const manifest = { version: 1, revision: spec.revision, target, files }; + const save = () => + writeFileSync(join(directory, "manifest.json"), JSON.stringify(manifest)); + const run = () => + spawnSync( + process.execPath, + ["scripts/verify-runtime-bundle.mjs", directory, target], + { cwd: root, encoding: "utf8" }, + ); + save(); + assert.equal(run().status, 0); + const name = Object.keys(files)[0]; + writeFileSync(join(directory, name), "bundler transformed bytes"); + assert.notEqual(run().status, 0); + writeFileSync(join(directory, name), spec.tools[0], { mode: 0o755 }); + assert.equal(run().status, 0); + manifest.revision = "0".repeat(40); + save(); + assert.notEqual(run().status, 0); + manifest.revision = spec.revision; + manifest.target = "wrong"; + save(); + assert.notEqual(run().status, 0); + manifest.target = target; + save(); + rmSync(join(directory, name)); + assert.notEqual(run().status, 0); + }); +} + +test("candidate builds cannot reach publisher or macOS signing and use read-only tokens", () => { + const workflow = parse(read(".github/workflows/release.yml")); + assert.equal(workflow.on.workflow_dispatch.inputs.candidates.default, false); + assert.equal(workflow.permissions.contents, "read"); + assert.match(workflow.jobs.build.if, /!inputs\.candidates/); + assert.equal(workflow.jobs.publish.needs, "build"); + assert.equal( + workflow.jobs.publish.if, + undefined, + "default success dependency must skip publisher when macOS is skipped", + ); + for (const platform of ["windows", "linux"]) { + const job = workflow.jobs[platform]; + assert.equal( + job.if, + "github.repository == 'block/buzz-app' && inputs.candidates", + ); + assert.equal(job.permissions, undefined); + assert.ok( + job.steps.some((step) => step.run?.includes("verify-runtime-bundle.mjs")), + ); + assert.ok( + job.steps.some((step) => + step.uses?.startsWith("actions/upload-artifact@"), + ), + ); + assert.ok(!JSON.stringify(job).includes("secrets.")); + } +});