mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Merge remote-tracking branch 'origin/main' into kennylopez-agent-catalog-sharing
This commit is contained in:
@@ -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,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') }}
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user