Merge remote-tracking branch 'origin/main' into kennylopez-agent-catalog-sharing

This commit is contained in:
kenny lopez
2026-07-24 09:49:47 -07:00
15 changed files with 1383 additions and 169 deletions
+227
View File
@@ -0,0 +1,227 @@
name: Linux Canary
# Produces unsigned Linux .deb and .AppImage packages from main without
# creating a tag, GitHub Release, or auto-updater artifact. Artifacts are
# available as a short-lived GitHub Actions artifact for explicit testing.
#
# Design notes vs. signed-macos-canary.yml:
# - fix-appimage.sh is run without signing env vars; the script detects
# their absence and skips re-signing, repacking only (documented inline).
# - mold linker added (rui314/setup-mold) to reduce link time, matching
# the Linux Rust CI jobs in ci.yml.
# - pnpm store restore/save pattern mirrors ci.yml:149-196.
on:
workflow_dispatch:
permissions:
contents: read
jobs:
build:
name: Build Linux canary
if: github.repository == 'block/buzz'
runs-on: ubuntu-latest
container: ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982
timeout-minutes: 60
permissions:
contents: read
env:
# AppImage tools are themselves AppImages; containers lack FUSE so we
# must use the extract-and-run fallback.
APPIMAGE_EXTRACT_AND_RUN: "1"
defaults:
run:
shell: bash
steps:
- name: Require main
env:
SOURCE_REF: ${{ github.ref }}
run: |
if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then
echo "::error::Canary builds must run from main; got $SOURCE_REF"
exit 1
fi
- name: Install system dependencies
env:
DEBIAN_FRONTEND: noninteractive
run: |
apt-get update \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30
apt-get install -y --no-install-recommends \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
-o DPkg::Lock::Timeout=120 \
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Mark workspace safe for git (containerized job)
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
# Rust cache covering both the workspace sidecar build and the Tauri
# crate build. shared-key scoped to linux-canary-release so canary runs
# warm each other without colliding with CI's debug-profile keys.
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: |
.
desktop/src-tauri
shared-key: linux-canary-release
- name: Install appimagetool
run: |
case "$(uname -m)" in
x86_64) ARCH_SUFFIX="x86_64" ;;
aarch64) ARCH_SUFFIX="aarch64" ;;
*)
echo "::error::Unsupported architecture: $(uname -m)"
exit 1
;;
esac
wget -q --tries=3 --timeout=30 -O /tmp/appimagetool \
"https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${ARCH_SUFFIX}.AppImage"
if [[ "$ARCH_SUFFIX" == "x86_64" ]]; then
echo "ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 /tmp/appimagetool" | sha256sum -c
else
echo "::error::No pinned SHA256 for appimagetool-${ARCH_SUFFIX} — add it before enabling this architecture"
exit 1
fi
install -m 755 /tmp/appimagetool /usr/local/bin/appimagetool
wget -q --tries=3 --timeout=30 -O /tmp/appimage-runtime \
"https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-${ARCH_SUFFIX}"
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: Get pnpm store directory
id: pnpm-cache
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install desktop dependencies
run: just desktop-install-ci
- name: Derive canary version
id: version
run: |
set -euo pipefail
BASE_VERSION=$(node -p "require('./desktop/package.json').version")
if ! [[ "$BASE_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Desktop version '$BASE_VERSION' is not semver"
exit 1
fi
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-test.${GITHUB_RUN_NUMBER}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Building canary version $VERSION from $GITHUB_SHA"
- name: Patch canary version
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
- name: Generate non-updating bundle config
run: |
cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON'
{
"bundle": {
"createUpdaterArtifacts": false
}
}
JSON
- name: Build sidecars
run: |
cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh
- name: Build Linux Tauri app
run: cd desktop && pnpm tauri build --ci --bundles deb,appimage --config src-tauri/tauri.canary.conf.json
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Fix AppImage (remove infra libs, symlink system GStreamer)
# fix-appimage.sh checks for TAURI_SIGNING_PRIVATE_KEY and skips
# re-signing when absent, so no signing env vars are needed here.
# Repacking still runs, removing the Mesa/GLib/GStreamer conflict libs.
run: |
mapfile -t APPIMAGES < <(find desktop/src-tauri/target/release/bundle/appimage -name '*.AppImage' -type f)
if [[ ${#APPIMAGES[@]} -eq 0 ]]; then
echo "::error::No AppImage found to post-process"
exit 1
fi
if [[ ${#APPIMAGES[@]} -gt 1 ]]; then
echo "::error::Expected exactly one AppImage, found ${#APPIMAGES[@]}: ${APPIMAGES[*]}"
exit 1
fi
bash desktop/scripts/fix-appimage.sh "${APPIMAGES[0]}"
- name: Save pnpm store cache
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Locate Linux build artifacts
id: artifacts
run: |
set -euo pipefail
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
DEB=$(find "$BUNDLE_DIR/deb" -name '*.deb' -type f | head -1)
if [[ -z "$DEB" ]]; then
echo "::error::No DEB found in $BUNDLE_DIR/deb"
exit 1
fi
echo "deb=$DEB" >> "$GITHUB_OUTPUT"
APPIMAGE=$(find "$BUNDLE_DIR/appimage" -name '*.AppImage' -type f | head -1)
if [[ -z "$APPIMAGE" ]]; then
echo "::error::No AppImage found in $BUNDLE_DIR/appimage"
exit 1
fi
echo "appimage=$APPIMAGE" >> "$GITHUB_OUTPUT"
- name: Upload Linux canary packages
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: buzz-linux-canary-${{ github.sha }}
path: |
${{ steps.artifacts.outputs.deb }}
${{ steps.artifacts.outputs.appimage }}
if-no-files-found: error
retention-days: 7
+28 -1
View File
@@ -28,12 +28,33 @@ jobs:
exit 1
fi
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
# Rust cache covering both the workspace sidecar build and the Tauri
# crate build. shared-key scoped to macos-canary-release so canary runs
# warm each other without colliding with CI's debug-profile keys.
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: |
.
desktop/src-tauri
shared-key: macos-canary-release
- name: Get pnpm store directory
id: pnpm-cache
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install desktop dependencies
run: just desktop-install-ci
@@ -188,3 +209,9 @@ jobs:
path: ${{ steps.artifact.outputs.path }}
if-no-files-found: error
retention-days: 7
- name: Save pnpm store cache
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
+159
View File
@@ -0,0 +1,159 @@
name: Windows Canary
# Produces an unsigned Windows NSIS installer from main without creating
# a tag, GitHub Release, or auto-updater artifact. The installer is available
# only as a short-lived GitHub Actions artifact for explicit testing.
#
# Design notes vs. signed-macos-canary.yml:
# - No mesh-llm: release-windows doesn't build it.
# - pnpm store restore/save pattern mirrors ci.yml:149-196.
on:
workflow_dispatch:
permissions:
contents: read
jobs:
build:
name: Build Windows canary
if: github.repository == 'block/buzz'
runs-on: windows-latest
timeout-minutes: 60
permissions:
contents: read
env:
TARGET: x86_64-pc-windows-msvc
steps:
- name: Require main
shell: bash
env:
SOURCE_REF: ${{ github.ref }}
run: |
if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then
echo "::error::Canary builds must run from main; got $SOURCE_REF"
exit 1
fi
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
# The Windows runner ships with rustup preinstalled; rust-toolchain.toml
# in the repo root pins the channel (1.95.0) automatically. We only need
# to ensure the cross-compile target is registered; on windows-latest the
# host IS x86_64-pc-windows-msvc so this is typically a no-op.
- name: Add Rust target
shell: bash
run: rustup target add "$TARGET"
# Rust cache covering both the workspace sidecar build and the Tauri
# crate build. shared-key scoped to windows-canary-release so canary
# runs warm each other without colliding with CI's debug-profile key
# (CI windows job does clippy/check, not --release).
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: |
.
desktop/src-tauri
shared-key: windows-canary-release
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24.14.1
# Disable setup-node's built-in cache: we manage the pnpm store cache
# explicitly below (restore before install, save after) to mirror the
# pattern used by ci.yml and to keep caching logic consistent across
# all three canary workflows.
package-manager-cache: false
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
with:
version: 11.4.0
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install desktop dependencies
shell: bash
run: pnpm install --frozen-lockfile
- name: Derive canary version
id: version
shell: bash
run: |
set -euo pipefail
BASE_VERSION=$(node -p "require('./desktop/package.json').version")
if ! [[ "$BASE_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Desktop version '$BASE_VERSION' is not semver"
exit 1
fi
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-test.${GITHUB_RUN_NUMBER}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Building canary version $VERSION from $GITHUB_SHA"
- name: Patch canary version
shell: bash
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
- name: Generate non-updating bundle config
shell: bash
run: |
cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON'
{
"bundle": {
"createUpdaterArtifacts": false
}
}
JSON
- name: Build sidecars
shell: bash
run: |
cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh "$TARGET"
- name: Build Windows NSIS installer (unsigned)
shell: bash
run: cd desktop && pnpm tauri build --target "$TARGET" --bundles nsis --config src-tauri/tauri.canary.conf.json
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Locate NSIS installer
id: artifact
shell: bash
run: |
set -euo pipefail
BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle"
EXE=$(find "$BUNDLE_DIR/nsis" -name '*.exe' -type f | head -1)
if [[ -z "$EXE" ]]; then
echo "::error::No NSIS installer found in $BUNDLE_DIR/nsis"
exit 1
fi
echo "exe=$EXE" >> "$GITHUB_OUTPUT"
- name: Upload Windows canary installer
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: buzz-windows-canary-${{ github.sha }}
path: ${{ steps.artifact.outputs.exe }}
if-no-files-found: error
retention-days: 7
- name: Save pnpm store cache
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -43,11 +43,27 @@ const MANAGED_NODE_ARTIFACT: Option<ManagedNodeArtifact> = Some(ManagedNodeArtif
sha256: "4786d00c4d259d3ff0b2328307f764ef3ced65f2d6e9502d433e68d66238509d",
});
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
const MANAGED_NODE_ARTIFACT: Option<ManagedNodeArtifact> = Some(ManagedNodeArtifact {
platform: "win-x64",
filename: "node-v24.11.0-win-x64.zip",
sha256: "1054540bce22b54ec7e50ebc078ec5d090700a77657607a58f6a64df21f49fdd",
});
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
const MANAGED_NODE_ARTIFACT: Option<ManagedNodeArtifact> = Some(ManagedNodeArtifact {
platform: "win-arm64",
filename: "node-v24.11.0-win-arm64.zip",
sha256: "12d3b1aa9696b7411e115a4fa2aef57f95560b5ee16bb62cd69843e535ec72be",
});
#[cfg(not(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "aarch64")
all(target_os = "linux", target_arch = "aarch64"),
all(target_os = "windows", target_arch = "x86_64"),
all(target_os = "windows", target_arch = "aarch64")
)))]
const MANAGED_NODE_ARTIFACT: Option<ManagedNodeArtifact> = None;
@@ -180,7 +196,12 @@ fn install_managed_node_runtime(
extract_managed_node_archive(&archive_path, &temp_dir, artifact.filename)?;
let _ = std::fs::remove_file(&archive_path);
let extracted_dir = temp_dir.join(artifact.filename.trim_end_matches(".tar.gz"));
let extracted_dir = temp_dir.join(
artifact
.filename
.trim_end_matches(".tar.gz")
.trim_end_matches(".zip"),
);
let source_dir = if extracted_dir.is_dir() {
extracted_dir
} else {
@@ -277,22 +298,106 @@ fn extract_managed_node_archive(
dest_dir: &std::path::Path,
filename: &str,
) -> Result<(), String> {
if !filename.ends_with(".tar.gz") {
return Err(format!("unsupported managed Node.js archive: {filename}"));
}
let file =
std::fs::File::open(archive_path).map_err(|e| format!("open Node.js archive: {e}"))?;
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
validate_managed_node_archive_entries(&mut archive)?;
if filename.ends_with(".tar.gz") {
let file =
std::fs::File::open(archive_path).map_err(|e| format!("open Node.js archive: {e}"))?;
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
validate_managed_node_archive_entries(&mut archive)?;
let file = std::fs::File::open(archive_path)
.map_err(|e| format!("open Node.js archive for extraction: {e}"))?;
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive
.unpack(dest_dir)
.map_err(|e| format!("extract Node.js archive: {e}"))
let file = std::fs::File::open(archive_path)
.map_err(|e| format!("open Node.js archive for extraction: {e}"))?;
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive
.unpack(dest_dir)
.map_err(|e| format!("extract Node.js archive: {e}"))
} else if filename.ends_with(".zip") {
let file =
std::fs::File::open(archive_path).map_err(|e| format!("open Node.js archive: {e}"))?;
let mut archive =
zip::ZipArchive::new(file).map_err(|e| format!("read Node.js zip archive: {e}"))?;
validate_managed_node_zip_entries(&archive)?;
extract_managed_node_zip(&mut archive, dest_dir)
} else {
Err(format!("unsupported managed Node.js archive: {filename}"))
}
}
/// Validate ZIP entry names using platform-neutral string logic.
///
/// `std::path::Path` is intentionally avoided: its `is_absolute()` and
/// `Component` parsing use BUILD-HOST grammar, so `/etc/passwd` is not
/// `is_absolute()` on Windows (no drive prefix), causing the check to lie on
/// the platform this guard exists to protect. Instead we apply pure string
/// rules that produce identical results on every host:
///
/// - Unix-rooted: starts with `/`
/// - Windows-rooted: starts with `\`, has a drive prefix (`X:`), or is UNC
/// (`\\` / `//`)
/// - Traversal: any component that is `..` when split on EITHER `/` or `\`
fn validate_managed_node_zip_entries(
archive: &zip::ZipArchive<std::fs::File>,
) -> Result<(), String> {
for i in 0..archive.len() {
let name = archive
.name_for_index(i)
.ok_or_else(|| format!("Node.js zip entry {i}: missing name"))?;
// Absolute-path checks (platform-neutral).
if name.starts_with('/') || name.starts_with('\\') {
return Err(format!("Node.js zip contains absolute path: {name}"));
}
// Drive prefix: one ASCII letter followed by ':'
if name.len() >= 2 && name.as_bytes()[1] == b':' && name.as_bytes()[0].is_ascii_alphabetic()
{
return Err(format!("Node.js zip contains absolute path: {name}"));
}
// UNC prefix: // or \\ (covered by starts_with checks above for \\,
// and // is caught by starts_with('/') then a second '/' — belt + suspenders).
// (Already caught by the starts_with checks above; explicit for clarity.)
// Traversal: split on both separators and check each component.
let has_traversal = name.split(['/', '\\']).any(|component| component == "..");
if has_traversal {
return Err(format!("Node.js zip contains path traversal: {name}"));
}
}
Ok(())
}
fn extract_managed_node_zip(
archive: &mut zip::ZipArchive<std::fs::File>,
dest_dir: &std::path::Path,
) -> Result<(), String> {
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| format!("Node.js zip entry {i}: {e}"))?;
let outpath = match entry.enclosed_name() {
Some(p) => dest_dir.join(p),
None => {
return Err(format!(
"Node.js zip contains unsafe path: {}",
entry.name()
))
}
};
if entry.is_dir() {
std::fs::create_dir_all(&outpath)
.map_err(|e| format!("create dir in Node.js zip: {e}"))?;
} else {
if let Some(parent) = outpath.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create parent dir in Node.js zip: {e}"))?;
}
let mut out = std::fs::File::create(&outpath)
.map_err(|e| format!("create file in Node.js zip: {e}"))?;
std::io::copy(&mut entry, &mut out)
.map_err(|e| format!("extract file in Node.js zip: {e}"))?;
}
}
Ok(())
}
fn validate_managed_node_archive_entries<R: std::io::Read>(
@@ -325,15 +430,36 @@ fn validate_managed_node_archive_entries<R: std::io::Read>(
}
fn verify_node_tree(dir: &std::path::Path) -> Result<(), String> {
let node = dir.join("bin").join("node");
let npm = dir.join("bin").join("npm");
if !node.is_file() {
return Err("Node.js archive missing bin/node".to_string());
#[cfg(windows)]
{
// Windows zip layout: node.exe + npm.cmd + npm (POSIX sh shim) at archive root
let node = dir.join("node.exe");
let npm_cmd = dir.join("npm.cmd");
let npm = dir.join("npm");
if !node.is_file() {
return Err("Node.js archive missing node.exe".to_string());
}
if !npm_cmd.is_file() {
return Err("Node.js archive missing npm.cmd".to_string());
}
if !npm.is_file() {
return Err("Node.js archive missing npm".to_string());
}
Ok(())
}
if !npm.is_file() {
return Err("Node.js archive missing bin/npm".to_string());
#[cfg(not(windows))]
{
// Unix tarball layout: bin/node + bin/npm
let node = dir.join("bin").join("node");
let npm = dir.join("bin").join("npm");
if !node.is_file() {
return Err("Node.js archive missing bin/node".to_string());
}
if !npm.is_file() {
return Err("Node.js archive missing bin/npm".to_string());
}
Ok(())
}
Ok(())
}
// ── managed npm adapter installs ──────────────────────────────────────────────
@@ -466,4 +592,157 @@ mod tests {
"'/tmp/Buzz'\\''s Node'"
);
}
// ── zip validation tests ──────────────────────────────────────────────────
/// Build an in-memory zip archive with the supplied entry names and return
/// a temporary file containing it (zip::ZipArchive requires Seek).
fn make_zip_with_entries(entry_names: &[&str]) -> tempfile::NamedTempFile {
let mut buf: Vec<u8> = Vec::new();
{
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts = zip::write::SimpleFileOptions::default();
for name in entry_names {
writer.start_file(*name, opts).unwrap();
}
writer.finish().unwrap();
}
let mut tmp = tempfile::NamedTempFile::new().unwrap();
std::io::Write::write_all(&mut tmp, &buf).unwrap();
tmp
}
#[test]
fn test_validate_zip_accepts_normal_entries() {
let tmp = make_zip_with_entries(&[
"node-v24.11.0-win-x64/node.exe",
"node-v24.11.0-win-x64/npm.cmd",
"node-v24.11.0-win-x64/npm",
]);
let file = std::fs::File::open(tmp.path()).unwrap();
let archive = zip::ZipArchive::new(file).unwrap();
assert!(validate_managed_node_zip_entries(&archive).is_ok());
}
#[test]
fn test_validate_zip_rejects_absolute_path() {
let tmp = make_zip_with_entries(&["/etc/passwd"]);
let file = std::fs::File::open(tmp.path()).unwrap();
let archive = zip::ZipArchive::new(file).unwrap();
let err = validate_managed_node_zip_entries(&archive).unwrap_err();
assert!(
err.contains("absolute path"),
"expected 'absolute path' in: {err}"
);
}
#[test]
fn test_validate_zip_rejects_path_traversal() {
let tmp = make_zip_with_entries(&["../../../etc/passwd"]);
let file = std::fs::File::open(tmp.path()).unwrap();
let archive = zip::ZipArchive::new(file).unwrap();
let err = validate_managed_node_zip_entries(&archive).unwrap_err();
assert!(
err.contains("path traversal"),
"expected 'path traversal' in: {err}"
);
}
#[test]
fn test_validate_zip_rejects_backslash_rooted() {
// Windows-style absolute path using backslash — must reject on every host.
let tmp = make_zip_with_entries(&["\\Windows\\system32\\evil.dll"]);
let file = std::fs::File::open(tmp.path()).unwrap();
let archive = zip::ZipArchive::new(file).unwrap();
let err = validate_managed_node_zip_entries(&archive).unwrap_err();
assert!(
err.contains("absolute path"),
"expected 'absolute path' in: {err}"
);
}
#[test]
fn test_validate_zip_rejects_drive_prefix() {
// Windows drive-letter absolute path — must reject on every host.
let tmp = make_zip_with_entries(&["C:\\evil\\payload.exe"]);
let file = std::fs::File::open(tmp.path()).unwrap();
let archive = zip::ZipArchive::new(file).unwrap();
let err = validate_managed_node_zip_entries(&archive).unwrap_err();
assert!(
err.contains("absolute path"),
"expected 'absolute path' in: {err}"
);
}
#[test]
fn test_validate_zip_rejects_backslash_traversal() {
// Path traversal using Windows separator — must reject on every host.
let tmp = make_zip_with_entries(&["node-v24.11.0-win-x64\\..\\..\\evil"]);
let file = std::fs::File::open(tmp.path()).unwrap();
let archive = zip::ZipArchive::new(file).unwrap();
let err = validate_managed_node_zip_entries(&archive).unwrap_err();
assert!(
err.contains("path traversal"),
"expected 'path traversal' in: {err}"
);
}
// ── verify_node_tree layout tests ─────────────────────────────────────────
#[test]
fn test_verify_node_tree_unix_layout_passes() {
let tmp = tempfile::TempDir::new().unwrap();
let bin = tmp.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(bin.join("node"), b"").unwrap();
std::fs::write(bin.join("npm"), b"").unwrap();
// On non-Windows the unix branch is active — this must pass.
#[cfg(not(windows))]
assert!(verify_node_tree(tmp.path()).is_ok());
// On Windows the windows branch is active — unix layout must fail.
#[cfg(windows)]
assert!(verify_node_tree(tmp.path()).is_err());
}
#[test]
fn test_verify_node_tree_unix_layout_missing_npm_fails() {
let tmp = tempfile::TempDir::new().unwrap();
let bin = tmp.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(bin.join("node"), b"").unwrap();
// npm intentionally absent
#[cfg(not(windows))]
{
let err = verify_node_tree(tmp.path()).unwrap_err();
assert!(err.contains("bin/npm"), "err: {err}");
}
}
#[test]
fn test_verify_node_tree_windows_layout_passes() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("node.exe"), b"").unwrap();
std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap();
std::fs::write(tmp.path().join("npm"), b"").unwrap();
// On Windows the windows branch is active — this must pass.
#[cfg(windows)]
assert!(verify_node_tree(tmp.path()).is_ok());
// On non-Windows the unix branch is active — windows-layout root files
// don't satisfy bin/node + bin/npm, so this must fail.
#[cfg(not(windows))]
assert!(verify_node_tree(tmp.path()).is_err());
}
#[test]
fn test_verify_node_tree_windows_layout_missing_npm_shim_fails() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("node.exe"), b"").unwrap();
std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap();
// npm POSIX shim intentionally absent
#[cfg(windows)]
{
let err = verify_node_tree(tmp.path()).unwrap_err();
assert!(err.contains("npm"), "err: {err}");
}
}
}
@@ -11,22 +11,37 @@ pub(crate) fn buzz_managed_node_root() -> Option<PathBuf> {
}
pub(crate) fn buzz_managed_node_bin_dir() -> Option<PathBuf> {
let platform = match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => "darwin-arm64",
("macos", "x86_64") => "darwin-x64",
("linux", "x86_64") => "linux-x64",
("linux", "aarch64") => "linux-arm64",
_ => return None,
};
let (platform, bin_subdir): (&str, Option<&str>) =
match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => ("darwin-arm64", Some("bin")),
("macos", "x86_64") => ("darwin-x64", Some("bin")),
("linux", "x86_64") => ("linux-x64", Some("bin")),
("linux", "aarch64") => ("linux-arm64", Some("bin")),
// Windows zips have node.exe + npm.cmd at the archive root — no bin/ subdir
("windows", "x86_64") => ("win-x64", None),
("windows", "aarch64") => ("win-arm64", None),
_ => return None,
};
buzz_managed_node_root().map(|root| {
root.join(BUZZ_MANAGED_NODE_VERSION)
.join(platform)
.join("bin")
let dir = root.join(BUZZ_MANAGED_NODE_VERSION).join(platform);
match bin_subdir {
Some(sub) => dir.join(sub),
None => dir,
}
})
}
pub(crate) fn buzz_managed_node_bin_path() -> Option<PathBuf> {
buzz_managed_node_bin_dir().map(|bin| bin.join("node"))
buzz_managed_node_bin_dir().map(|bin| {
#[cfg(windows)]
{
bin.join("node.exe")
}
#[cfg(not(windows))]
{
bin.join("node")
}
})
}
pub(crate) fn buzz_managed_npm_bin_dir() -> Option<PathBuf> {
@@ -681,3 +681,114 @@ test("parseSystemPromptSections splits on the LAST occurrence of the canonical d
},
]);
});
// ── Modern [Team Instructions] bracket-header extraction ─────────────────────
test("parseSystemPromptSections (modern) extracts bracket Team Instructions after Base+System", () => {
// with_team() emits "\n\n[Team Instructions]\n{instructions}" as a top-level
// bracket section — the same framing used by with_core() and with_canvas().
const framed = [
"[Base]",
"You are a helpful assistant.",
"",
"[System]",
"You are Agent X.",
"",
"[Team Instructions]",
"Always respond in markdown.",
].join("\n");
const sections = parseSystemPromptSections(framed);
assert.deepEqual(sections, [
{ title: "Base", body: "You are a helpful assistant." },
{ title: "System", body: "You are Agent X." },
{ title: "Team Instructions", body: "Always respond in markdown." },
]);
});
test("parseSystemPromptSections (modern) extracts bracket Team Instructions after System-only", () => {
// [Base] absent; [Team Instructions] is a direct top-level bracket section.
const framed = [
"[System]",
"You are Agent Y.",
"",
"[Team Instructions]",
"Keep responses concise.",
].join("\n");
const sections = parseSystemPromptSections(framed);
assert.deepEqual(sections, [
{ title: "System", body: "You are Agent Y." },
{ title: "Team Instructions", body: "Keep responses concise." },
]);
});
test("parseSystemPromptSections (modern) handles bracket Team Instructions as start-of-string", () => {
// with_team() also handles prompt=None → "[Team Instructions]\n{instructions}".
const framed = ["[Team Instructions]", "Instructions only."].join("\n");
const sections = parseSystemPromptSections(framed);
assert.deepEqual(sections, [
{ title: "Team Instructions", body: "Instructions only." },
]);
});
test("parseSystemPromptSections (modern) pins full 5-section shape: Base+System+Team+Core+Canvas", () => {
// Production shape from with_team() + with_core() + with_canvas(): all five sections present.
const framed = [
"[Base]",
"You are a helpful AI assistant running in Buzz.",
"",
"[System]",
"You are Observer Agent. You coordinate multi-agent workflows.",
"",
"[Team Instructions]",
"Always tag on handoff.",
"Never expand scope without approval.",
"",
"[Agent Memory — core]",
"I am Observer Agent.",
"## Lessons Learned",
"Always tag on handoff.",
"",
"[Channel Canvas]",
"Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"Last modified: 2026-07-11T10:00:00Z",
"Fetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301",
].join("\n");
const sections = parseSystemPromptSections(framed);
assert.deepEqual(sections, [
{ title: "Base", body: "You are a helpful AI assistant running in Buzz." },
{
title: "System",
body: "You are Observer Agent. You coordinate multi-agent workflows.",
},
{
title: "Team Instructions",
body: "Always tag on handoff.\nNever expand scope without approval.",
},
{
title: "Core Memory",
body: "I am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.",
},
{
title: "Channel Canvas",
body: "Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301",
},
]);
});
test("parseSystemPromptSections (modern) does NOT split on bracket [Team Instructions] preceded by only a single newline", () => {
// The inline marker is "\n\n[Team Instructions]\n" — a single preceding newline
// must be kept literal inside System, same guard as canvas/core.
const framed = [
"[System]",
"Persona preamble.",
"[Team Instructions]",
"This is persona text, not a real team block.",
].join("\n");
const sections = parseSystemPromptSections(framed);
assert.deepEqual(sections, [
{
title: "System",
body: "Persona preamble.\n[Team Instructions]\nThis is persona text, not a real team block.",
},
]);
});
@@ -61,15 +61,12 @@ export function parsePromptText(text: string): {
* deterministically.
*
* The harness composes the value in order:
* `[Base]\n{base}\n\n[System]\n{persona}\n\n[Agent Memory — core]\n{core}\n\n[Channel Canvas]\n{canvas}`
* with any section omitted when absent. For team-pack agents the persona body
* already contains the pack-level instructions appended by `compose_prompt()`
* in `buzz-persona/src/resolve.rs`:
* `{persona_body}\n\n---\n# Team Instructions\n{pack_instructions}`
* Extraction runs in reverse producer order so that each `lastIndexOf` search
* operates on the full input and each extraction boundary is unambiguous.
* `[Base]\n{base}\n\n[System]\n{persona}\n\n[Team Instructions]\n{team}\n\n[Agent Memory — core]\n{core}\n\n[Channel Canvas]\n{canvas}`
* with any section omitted when absent. Extraction runs in reverse producer
* order so that each `lastIndexOf` search operates on the full input and each
* extraction boundary is unambiguous.
*
* Four extraction passes before Base/System parsing:
* Five extraction passes:
*
* 1. **Canvas** (`[Channel Canvas]`): appended last by `with_canvas()`.
* - Start-of-string: canvas-only input.
@@ -80,18 +77,23 @@ export function parsePromptText(text: string): {
* 2. **Core** (`[Agent Memory — core]`): appended before canvas by `with_core()`.
* Same two cases, same last-occurrence guard.
*
* 3. **Base/System**: remainder after canvas and core extraction.
* 3. **Team Instructions** (`[Team Instructions]`): appended before core by
* `with_team()` in `buzz-acp/src/pool.rs`. Same two cases (start-of-string
* or `\n\n[Team Instructions]\n` inline), same last-occurrence guard. Output
* position: after System, before Core Memory.
*
* 4. **Base/System**: remainder after the three top-level section extractions.
* Split on the first `\n[System]\n` boundary; no embedded `[...]` line
* inside a body can start a new section.
*
* 4. **Team Instructions**: if the `System` body contains the exact canonical
* delimiter `\n\n---\n# Team Instructions\n` (produced by `compose_prompt()`),
* the body is split at the **last** occurrence of that boundary (same
* last-occurrence guard as canvas and core). The text before becomes the
* `System` body; the text after becomes a `Team Instructions` section
* inserted immediately after `System`. Non-canonical lookalikes (bare `---`
* without the heading, a `# Team Instructions` on a different line, or only
* a single preceding newline) are kept literal inside `System`.
* 5. **Legacy Team Instructions** (backward compat): if the `System` body
* contains the exact canonical delimiter `\n\n---\n# Team Instructions\n`
* (produced by the now-removed `compose_prompt()` in buzz-persona), the body
* is split at the **last** occurrence of that boundary. The text before
* becomes the `System` body; the text after becomes a `Team Instructions`
* section inserted immediately after `System`. Non-canonical lookalikes
* (bare `---` without the heading, a `# Team Instructions` on a different
* line, or only a single preceding newline) are kept literal inside `System`.
*/
export function parseSystemPromptSections(
systemPrompt: string,
@@ -133,7 +135,29 @@ export function parseSystemPromptSections(
}
}
// ── 3. Parse Base/System from the remaining prefix ────────────────────────
// ── 3. Extract [Team Instructions] (modern runtime framing) ─────────────
// with_team() in buzz-acp/src/pool.rs appends "\n\n[Team Instructions]\n{instructions}"
// after [System] and before core/canvas. Same two cases as canvas/core:
// start-of-string (team-only input) or the inline double-newline marker
// (last occurrence guards against embedded lookalikes preceded by a single \n).
const TEAM_HEADER = "[Team Instructions]";
const TEAM_MARKER_INLINE = `\n\n${TEAM_HEADER}\n`;
let modernTeamBody: string | null = null;
if (remainder.startsWith(`${TEAM_HEADER}\n`)) {
modernTeamBody = remainder.slice(`${TEAM_HEADER}\n`.length).trim();
remainder = "";
} else {
const lastTeam = remainder.lastIndexOf(TEAM_MARKER_INLINE);
if (lastTeam !== -1) {
modernTeamBody = remainder
.slice(lastTeam + TEAM_MARKER_INLINE.length)
.trim();
remainder = remainder.slice(0, lastTeam);
}
}
// ── 4. Parse Base/System from the remaining prefix ────────────────────────
// The canonical team-instructions delimiter produced by compose_prompt() in
// buzz-persona/src/resolve.rs:
// format!("{persona_prompt}\n\n---\n# Team Instructions\n{instructions}")
@@ -181,7 +205,9 @@ export function parseSystemPromptSections(
}
}
// ── 4. Append core and canvas sections in producer order ──────────────────
// ── 5. Append team (modern), core, and canvas sections in producer order ──
if (modernTeamBody)
sections.push({ title: "Team Instructions", body: modernTeamBody });
if (coreBody) sections.push({ title: "Core Memory", body: coreBody });
if (canvasBody) sections.push({ title: "Channel Canvas", body: canvasBody });
@@ -42,7 +42,9 @@ export function RuntimeErrorTooltip({
side="bottom"
sideOffset={12}
>
<span className="leading-4">{detail}</span>
<span className="block max-h-48 overflow-y-auto overflow-x-hidden break-words whitespace-pre-line leading-4">
{detail}
</span>
</TooltipContent>
</Tooltip>
);
@@ -463,19 +463,56 @@ function RuntimeAuthError({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
}
function RuntimeCard({
installError,
isInstalling,
onInstall,
installResults,
onInstallResultsChange,
runtime,
}: {
installError: string | null;
isInstalling: boolean;
onInstall: () => void;
installResults: InstallResultsState;
onInstallResultsChange: React.Dispatch<
React.SetStateAction<InstallResultsState>
>;
runtime: AcpRuntimeCatalogEntry;
}) {
// Each card owns its own mutation instance so concurrent installs on
// different cards each track their own isPending state and callbacks
// independently (react-query v5 per-mutate callbacks only fire for the
// latest mutate() call on a shared instance, silently dropping earlier ones).
const installMutation = useInstallAcpRuntimeMutation();
const installError = installResults[runtime.id]?.error ?? null;
const isInstalling = installMutation.isPending;
const isAvailable = runtime.availability === "available";
const isReady = runtimeIsReadyForOnboarding(runtime);
function handleInstall() {
onInstallResultsChange((current) => ({
...current,
[runtime.id]: { error: null, success: false },
}));
installMutation.mutate(runtime.id, {
onSuccess: (result) => {
onInstallResultsChange((current) => ({
...current,
[runtime.id]: result.success
? { error: null, success: true }
: {
error: getInstallErrorMessage(result.steps),
success: false,
},
}));
},
onError: (error) => {
onInstallResultsChange((current) => ({
...current,
[runtime.id]: {
error: error instanceof Error ? error.message : "Install failed.",
success: false,
},
}));
},
});
}
return (
<Card
className={cn(
@@ -499,7 +536,7 @@ function RuntimeCard({
<RuntimeStatus
installError={installError}
isInstalling={isInstalling}
onInstall={onInstall}
onInstall={handleInstall}
runtime={runtime}
/>
{!isAvailable && runtimeDetailText(runtime) ? (
@@ -517,7 +554,7 @@ function RuntimeCard({
{installError ? (
<RuntimeErrorTooltip
className="absolute inset-x-3 bottom-2 flex min-w-0 items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap text-xs leading-4 text-destructive"
detail="Installation couldn’t be completed. Try again."
detail={installError}
label="Installation failed"
showIcon
testId={`onboarding-runtime-error-${runtime.id}`}
@@ -560,34 +597,6 @@ function RuntimeProvidersSection({
}) {
const { errorMessage, isChecking, items } = runtimeProviders;
const orderedItems = getVisibleOnboardingRuntimes(items);
const installMutation = useInstallAcpRuntimeMutation();
function handleInstall(runtimeId: string) {
onInstallResultsChange((current) => ({
...current,
[runtimeId]: { error: null, success: false },
}));
installMutation.mutate(runtimeId, {
onSuccess: (result) => {
onInstallResultsChange((current) => ({
...current,
[runtimeId]: result.success
? { error: null, success: true }
: { error: getInstallErrorMessage(result.steps), success: false },
}));
},
onError: (error) => {
onInstallResultsChange((current) => ({
...current,
[runtimeId]: {
error: error instanceof Error ? error.message : "Install failed.",
success: false,
},
}));
},
});
}
return (
<section className="flex min-h-full w-full flex-col items-center">
@@ -606,13 +615,9 @@ function RuntimeProvidersSection({
<div className="grid min-w-0 w-full max-w-[592px] grid-cols-1 gap-4 md:grid-cols-2">
{orderedItems.map((runtime) => (
<RuntimeCard
installError={installResults[runtime.id]?.error ?? null}
isInstalling={
installMutation.isPending &&
installMutation.variables === runtime.id
}
installResults={installResults}
key={runtime.id}
onInstall={() => handleInstall(runtime.id)}
onInstallResultsChange={onInstallResultsChange}
runtime={runtime}
/>
))}
@@ -279,22 +279,56 @@ function RuntimeHeader({
}
function RuntimeRow({
installError,
installSuccess,
isInstalling,
onInstall,
resetEpoch,
runtime,
}: {
installError: string | null;
installSuccess: boolean;
isInstalling: boolean;
onInstall: () => void;
resetEpoch: number;
runtime: AcpRuntimeCatalogEntry;
}) {
const [terminalLaunchMethodId, setTerminalLaunchMethodId] = React.useState<
string | null
>(null);
const [isUpdateWarningOpen, setIsUpdateWarningOpen] = React.useState(false);
// Each row owns its mutation instance so concurrent installs each track
// their own isPending / result state independently.
const installMutation = useInstallAcpRuntimeMutation();
const [installResult, setInstallResult] = React.useState<{
success: boolean;
error: string | null;
} | null>(null);
// Clear stale install results when the parent triggers a catalog refresh
// (Check again) — the runtime may now be healthy and stale failure state
// would linger because keyed rows don't remount on refetch.
// biome-ignore lint/correctness/useExhaustiveDependencies: resetEpoch is an intentional trigger only; its value is not consumed in the effect body
React.useEffect(() => {
setInstallResult(null);
}, [resetEpoch]);
const isInstalling = installMutation.isPending;
const installError = installResult?.error ?? null;
const installSuccess = installResult?.success ?? false;
function handleInstall() {
setInstallResult(null);
installMutation.mutate(runtime.id, {
onSuccess: (result) => {
if (result.success) {
setInstallResult({ success: true, error: null });
} else {
setInstallResult({
success: false,
error: getInstallErrorMessage(result.steps),
});
}
},
onError: (error) => {
setInstallResult({
success: false,
error: error instanceof Error ? error.message : "Install failed.",
});
},
});
}
const canConnectAccount =
runtime.availability === "available" &&
runtime.authStatus.status === "logged_out";
@@ -352,7 +386,7 @@ function RuntimeRow({
setIsUpdateWarningOpen(true);
return;
}
onInstall();
handleInstall();
}}
runtime={runtime}
/>
@@ -372,7 +406,10 @@ function RuntimeRow({
</p>
) : null}
{installError ? (
<p className="mt-2 whitespace-pre-line rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-sm text-destructive">
<p
className="mt-2 whitespace-pre-line rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-sm text-destructive"
data-testid={`doctor-runtime-install-error-${runtime.id}`}
>
{installError}
</p>
) : null}
@@ -410,7 +447,7 @@ function RuntimeRow({
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onInstall}
onClick={handleInstall}
data-testid={`doctor-runtime-confirm-update-${runtime.id}`}
>
Update
@@ -491,59 +528,9 @@ export function DoctorSettingsPanel() {
[runtimesQuery.data],
);
const isRefreshing = runtimesQuery.isFetching;
const installMutation = useInstallAcpRuntimeMutation();
const [installResults, setInstallResults] = React.useState<
Record<string, { success: boolean; error: string | null }>
>({});
// Per-runtime installing state: tracks which runtime IDs have an in-flight
// install so concurrent installs each show their own spinner correctly.
const [installingIds, setInstallingIds] = React.useState<Set<string>>(
new Set(),
);
function handleInstall(runtimeId: string) {
// Clear any previous result for this runtime before retrying.
setInstallResults((prev) => ({
...prev,
[runtimeId]: { success: false, error: null },
}));
setInstallingIds((prev) => new Set(prev).add(runtimeId));
installMutation.mutate(runtimeId, {
onSuccess: (result) => {
if (result.success) {
setInstallResults((prev) => ({
...prev,
[runtimeId]: { success: true, error: null },
}));
} else {
setInstallResults((prev) => ({
...prev,
[runtimeId]: {
success: false,
error: getInstallErrorMessage(result.steps),
},
}));
}
},
onError: (error) => {
setInstallResults((prev) => ({
...prev,
[runtimeId]: {
success: false,
error: error instanceof Error ? error.message : "Install failed.",
},
}));
},
onSettled: () => {
setInstallingIds((prev) => {
const next = new Set(prev);
next.delete(runtimeId);
return next;
});
},
});
}
// Incremented each time the user clicks "Check again" so RuntimeRow
// useEffect clears stale install results from before the refresh.
const [resetEpoch, setResetEpoch] = React.useState(0);
return (
<section
@@ -558,7 +545,7 @@ export function DoctorSettingsPanel() {
<Button
disabled={isRefreshing}
onClick={() => {
setInstallResults({});
setResetEpoch((e) => e + 1);
void runtimesQuery.refetch();
void gitBashQuery.refetch();
}}
@@ -598,11 +585,8 @@ export function DoctorSettingsPanel() {
<div className="space-y-3" data-testid="doctor-runtime-list">
{runtimes.map((runtime) => (
<RuntimeRow
installError={installResults[runtime.id]?.error ?? null}
installSuccess={installResults[runtime.id]?.success ?? false}
isInstalling={installingIds.has(runtime.id)}
key={runtime.id}
onInstall={() => handleInstall(runtime.id)}
resetEpoch={resetEpoch}
runtime={runtime}
/>
))}
@@ -67,3 +67,47 @@ test("getInstallErrorMessage: failed step with empty stderr falls back to stdout
]);
assert.match(message, /some stdout output/);
});
test("getInstallErrorMessage: hint and step detail are separated by double newline for whitespace-pre-line rendering", () => {
const hint = "Git Bash is required. Install it from git-scm.com.";
const message = getInstallErrorMessage([
{
step: "shell",
command: "bash -l -c 'npm install'",
success: false,
stdout: "",
stderr: "bash: command not found",
exitCode: 127,
hint,
},
]);
assert.ok(
message.includes("\n\n"),
"hint and step detail should be separated by a blank line",
);
assert.ok(message.startsWith(hint));
});
test("getInstallErrorMessage: only reports the last (failing) step when multiple steps present", () => {
const message = getInstallErrorMessage([
{
step: "node",
command: "node --version",
success: true,
stdout: "v20.0.0",
stderr: "",
exitCode: 0,
},
{
step: "adapter",
command: "npm install -g @agentclientprotocol/claude-code-acp",
success: false,
stdout: "",
stderr: "npm ERR! code E404",
exitCode: 1,
},
]);
assert.match(message, /Step "adapter" failed:/);
assert.match(message, /npm ERR! code E404/);
assert.doesNotMatch(message, /Step "node"/);
});
+39
View File
@@ -187,6 +187,18 @@ type E2eConfig = {
* Call N returns results[N]; when exhausted the last entry repeats.
* Takes precedence over `installAcpRuntimeResult`. */
installAcpRuntimeResults?: RawInstallRuntimeResult[];
/** Per-runtime install configuration keyed by runtimeId.
* When a runtimeId matches, its entry overrides the global
* installAcpRuntime* fields for that specific runtime. */
installAcpRuntimeByRuntime?: Record<
string,
{
delayMs?: number;
result?: RawInstallRuntimeResult;
/** Call-order sequence — same semantics as installAcpRuntimeResults. */
results?: RawInstallRuntimeResult[];
}
>;
managedAgentPrereqs?: {
acp?: MockCommandAvailability;
mcp?: MockCommandAvailability;
@@ -7075,6 +7087,8 @@ async function handleConnectAcpRuntime(
// Per-page install call counter. Reset each test run because this module is
// re-evaluated via addInitScript, so the counter starts at 0 for every test.
let installCallCount = 0;
/** Per-runtime call counters for `installAcpRuntimeByRuntime` sequences. */
const installCallCountByRuntime: Record<string, number> = {};
let addChannelMembersCallCount = 0;
let mockGlobalAgentConfig: {
env_vars: Record<string, string>;
@@ -7095,6 +7109,31 @@ async function handleInstallAcpRuntime(
},
config: E2eConfig | undefined,
): Promise<RawInstallRuntimeResult> {
const runtimeId = args.runtimeId ?? "";
const perRuntime = config?.mock?.installAcpRuntimeByRuntime?.[runtimeId];
if (perRuntime) {
const delayMs = perRuntime.delayMs ?? 0;
if (delayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
const seq = perRuntime.results;
if (seq && seq.length > 0) {
const idx = Math.min(
installCallCountByRuntime[runtimeId] ?? 0,
seq.length - 1,
);
installCallCountByRuntime[runtimeId] = idx + 1;
const result = seq[idx];
if (result.success) mockInstallCompleted = true;
return result;
}
if (perRuntime.result) {
if (perRuntime.result.success) mockInstallCompleted = true;
return perRuntime.result;
}
}
const delayMs = config?.mock?.installAcpRuntimeDelayMs ?? 0;
if (delayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
+129
View File
@@ -735,4 +735,133 @@ test.describe("Doctor panel state screenshots", () => {
await expect(loading).toBeVisible();
await expect(loading).toContainText("Codex installing");
});
/**
* 08 — concurrent installs each keep their own spinner/result state;
* stale install failure is cleared when Check again fires (F1 fix).
*
* Flow:
* - Claude (400ms delay) → failure
* - Codex (100ms delay) → success
* Both started before either settles.
* After both settle: claude shows failure, codex shows success banner.
* Click Check again → both rows lose stale state (claude error gone).
*/
test("08-concurrent-installs-and-stale-clear", async ({ page }) => {
await installMockBridge(page, {
acpRuntimesCatalog: [
{
...CLAUDE_AVAILABLE_LOGGED_IN,
availability: "adapter_missing",
command: null,
binary_path: null,
can_auto_install: true,
auth_status: { status: "unknown" },
},
{
...CODEX_NOT_INSTALLED,
can_auto_install: true,
node_required: false,
},
GOOSE_AVAILABLE,
BUZZ_AGENT_AVAILABLE,
],
installAcpRuntimeByRuntime: {
claude: {
delayMs: 400,
result: {
success: false,
steps: [
{
step: "adapter",
command: "npm install -g @agentclientprotocol/claude-agent-acp",
success: false,
stdout: "",
stderr:
"npm ERR! code EACCES\nnpm ERR! syscall mkdir\nnpm ERR! path /usr/local\n\nHint: Check prefix permissions.",
exit_code: 1,
},
],
},
},
codex: {
delayMs: 100,
result: {
success: true,
steps: [
{
step: "adapter",
command: "npm install -g @zed-industries/codex-acp",
success: true,
stdout: "added 1 package",
stderr: "",
exit_code: 0,
},
],
},
},
},
// After the catalog refresh (triggered by a successful install or Check
// again), all runtimes report healthy so stale errors must clear.
acpRuntimesCatalogAfterInstall: [
{
...CLAUDE_AVAILABLE_LOGGED_IN,
availability: "available",
},
{
...CODEX_NOT_INSTALLED,
availability: "available",
command: "codex-acp",
binary_path: "/usr/local/bin/codex-acp",
auth_status: { status: "logged_in" },
},
GOOSE_AVAILABLE,
BUZZ_AGENT_AVAILABLE,
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page, "agents");
const claudeRow = page.getByTestId("doctor-runtime-claude");
const codexRow = page.getByTestId("doctor-runtime-codex");
await expect(claudeRow).toBeVisible({ timeout: 10_000 });
await expect(codexRow).toBeVisible();
const claudeToggle = page.getByTestId("doctor-runtime-toggle-claude");
const codexToggle = page.getByTestId("doctor-runtime-toggle-codex");
// Start both installs before either settles.
await claudeToggle.click();
await codexToggle.click();
// Codex settles first (shorter delay): toggle flips on, no error on codex.
// The catalog refresh triggered by codex's success immediately returns
// availability === "available", so the transient "installed. Checking..."
// banner is replaced by the stable isOn state — assert the toggle instead.
await expect(codexToggle).toBeChecked({ timeout: 3_000 });
await expect(
page.getByTestId("doctor-runtime-install-error-codex"),
).toHaveCount(0);
// Claude settles (after its longer delay): failure error visible with
// multiline stderr. Codex toggle must still be on — unaffected by claude.
const claudeError = page.getByTestId("doctor-runtime-install-error-claude");
await expect(claudeError).toBeVisible({ timeout: 3_000 });
await expect(claudeError).toContainText("npm ERR!");
await expect(codexToggle).toBeChecked();
// Click Check again — epoch increments, RuntimeRow useEffect clears
// local installResult state, so the stale claude error disappears.
await page.getByRole("button", { name: "Check again" }).click();
await expect(claudeError).toHaveCount(0, { timeout: 5_000 });
// Codex toggle stays on (catalog still reports available after refresh).
await expect(codexToggle).toBeChecked({ timeout: 5_000 });
await claudeRow.scrollIntoViewIfNeeded();
await waitForAnimations(page);
await claudeRow.screenshot({
path: `${SHOTS}/08-concurrent-installs-and-stale-clear.png`,
});
});
});
@@ -418,7 +418,7 @@ test.describe("observer feed screenshots", () => {
method: "session/new",
params: {
systemPrompt:
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.\n\n---\n# Team Instructions\nAlways tag on handoff.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\nCanvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301",
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.\n\n[Team Instructions]\nAlways tag on handoff.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\nCanvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301",
},
},
},
@@ -698,7 +698,7 @@ test.describe("observer feed screenshots", () => {
method: "session/new",
params: {
systemPrompt:
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.\n\n---\n# Team Instructions\nAlways tag on handoff.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\nCanvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301",
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.\n\n[Team Instructions]\nAlways tag on handoff.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\nCanvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301",
},
},
},
@@ -635,3 +635,170 @@ test("defaults requires a choice when multiple visible harnesses are ready", asy
await expect(page.getByTestId("onboarding-finish")).toBeEnabled();
await expect.poll(() => readSavedRuntime(page)).toBe("codex");
});
/**
* Two installs started concurrently — claude fails with a multiline error
* (rich hint+stderr in the tooltip) while codex succeeds. Each card must
* keep its own independent spinner and its own terminal result; neither card
* may show the other's outcome.
*
* This is the behavioral regression test for the per-card mutation fix
* (Bug B) and the multiline tooltip fix (Bug A / F3 from Thufir pass 1).
*/
test("concurrent installs each keep their own state — one fails, one succeeds", async ({
page,
}) => {
// Realistic 512-head + 1024-tail shape: many short lines followed by one
// long unbroken Windows path. This exercises both overflow axes:
// • vertical: enough lines to exceed max-h-48 (192px at ~16px/line)
// • horizontal: the long path has no spaces, so only break-words prevents
// scrollWidth > clientWidth.
const longWindowsPath =
"C:\\Users\\willp\\AppData\\Roaming\\npm\\node_modules\\@agentclientprotocol\\claude-agent-acp\\dist\\bin\\claude-agent-acp.exe";
const multilineError = [
"npm ERR! code EACCES",
"npm ERR! syscall mkdir",
"npm ERR! path C:\\Users\\willp\\AppData\\Roaming\\npm",
"npm ERR! errno -4048",
"npm ERR! Error: EACCES: permission denied, mkdir 'C:\\Users\\willp\\AppData\\Roaming\\npm'",
"npm ERR! { [Error: EACCES: permission denied, mkdir 'C:\\Users\\willp\\AppData\\Roaming\\npm']",
"npm ERR! errno: -4048,",
"npm ERR! code: 'EACCES',",
"npm ERR! syscall: 'mkdir',",
"npm ERR! path: 'C:\\\\Users\\\\willp\\\\AppData\\\\Roaming\\\\npm' }",
"npm ERR!",
"npm ERR! The operation was rejected by your operating system.",
"npm ERR! It is likely you do not have the permissions to access this file as the current user",
"npm ERR!",
`npm ERR! If you believe this might be a permissions issue, please double-check the`,
`npm ERR! permissions of the file and its containing directories, or try running`,
`npm ERR! the command again as root/Administrator.`,
"",
`Hint: Run as Administrator or change npm prefix: npm config set prefix ${longWindowsPath}`,
].join("\n");
const claudeNotInstalled = runtime("claude", "adapter_missing", {
status: "unknown",
});
const codexNotInstalled = runtime("codex", "adapter_missing", {
status: "unknown",
});
await installMockBridge(
page,
{
acpRuntimesCatalog: [claudeNotInstalled, codexNotInstalled],
// Claude: long delay then failure with multiline stderr + hint.
// Codex: short delay then success.
// Per-runtime config lets both be in flight simultaneously.
installAcpRuntimeByRuntime: {
claude: {
delayMs: 600,
result: {
success: false,
steps: [
{
step: "adapter",
command: "npm install -g @agentclientprotocol/claude-agent-acp",
success: false,
stdout: "",
stderr: multilineError,
exit_code: 1,
},
],
},
},
codex: {
delayMs: 200,
result: {
success: true,
steps: [
{
step: "adapter",
command: "npm install -g @zed-industries/codex-acp",
success: true,
stdout: "added 1 package",
stderr: "",
exit_code: 0,
},
],
},
},
},
acpRuntimesCatalogAfterInstall: [
runtime("claude", "adapter_missing", { status: "unknown" }),
runtime("codex", "available", { status: "logged_in" }),
],
},
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
await navigateToSetupPage(page);
const claudeInstall = page.getByTestId("onboarding-runtime-install-claude");
const codexInstall = page.getByTestId("onboarding-runtime-install-codex");
// Start both installs before either settles.
await claudeInstall.click();
await codexInstall.click();
// While in flight: both install buttons must be absent (no duplicate clicks).
await expect(claudeInstall).toHaveCount(0);
await expect(codexInstall).toHaveCount(0);
// Codex settles first (shorter delay): success indicator, no error.
await expect(page.getByTestId("onboarding-runtime-ready-codex")).toBeVisible({
timeout: 3_000,
});
await expect(page.getByTestId("onboarding-runtime-error-codex")).toHaveCount(
0,
);
// Claude still in flight: its install button must still be absent.
await expect(claudeInstall).toHaveCount(0);
// Claude settles: failure error visible; codex still shows ready (not reset).
const claudeError = page.getByTestId("onboarding-runtime-error-claude");
await expect(claudeError).toBeVisible({ timeout: 3_000 });
await expect(
page.getByTestId("onboarding-runtime-ready-codex"),
).toBeVisible();
await expect(page.getByTestId("onboarding-runtime-error-codex")).toHaveCount(
0,
);
// The error trigger has the full aria-label (label + detail).
await expect(claudeError).toHaveAttribute("aria-label", /npm ERR!/);
// Open the tooltip and verify the detail span handles overflow correctly:
// • vertical overflow exists and is scrollable (max-h-48 + overflow-y-auto)
// • no horizontal overflow (break-words forces the long unbroken path to wrap)
await claudeError.focus();
const tooltip = page.getByRole("tooltip");
await expect(tooltip).toBeVisible({ timeout: 2_000 });
await expect(tooltip).toContainText("npm ERR! code EACCES");
await expect(tooltip).toContainText("Hint: Run as Administrator");
// Locate the scroll container using page-level locator since Radix portals
// can place content outside the tooltip role element's subtree in the DOM.
// Use .first() because Radix keeps a hidden duplicate in the light DOM.
const detailSpan = page.locator("span.overflow-y-auto").first();
await expect(detailSpan).toBeVisible();
// Vertical: scrollHeight must exceed clientHeight (content taller than max-h-48).
// Scroll position must advance when set, proving scrollability.
const isVerticallyScrollable = await detailSpan.evaluate((el) => {
return el.scrollHeight > el.clientHeight;
});
expect(isVerticallyScrollable).toBe(true);
// Confirm scroll position can actually advance.
await detailSpan.evaluate((el) => {
el.scrollTop = 9999;
});
const scrolledDown = await detailSpan.evaluate((el) => el.scrollTop > 0);
expect(scrolledDown).toBe(true);
// Horizontal: break-words must prevent horizontal overflow.
const hasHorizontalOverflow = await detailSpan.evaluate((el) => {
return el.scrollWidth > el.clientWidth;
});
expect(hasHorizontalOverflow).toBe(false);
});