From 7585e4febca4d49ac09559f505ed632459aa25c0 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 13 Jul 2026 13:08:27 +1000 Subject: [PATCH 01/18] feat(desktop): pin and stage bundled ACP bridge tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buzz desktop spawned the claude-agent-acp and codex-acp bridges from whatever the user had installed globally — an unpinned `npm install -g` surface with no integrity checking, which produced stale-bridge drift (the deprecated @zed-industries/codex-acp 0.16.x gate) and missing-tool failures. Bundle both bridges as app resources instead, pinned per target: - desktop/acp-tools.lock.json pins @agentclientprotocol/claude-agent-acp 0.58.1 and @agentclientprotocol/codex-acp 1.1.2 (npm `latest` at time of commit) for the four supported targets, with integrity hashes and Block Artifactory tarballs for the package, its claude-agent-sdk / @openai/codex dependency, and the per-target native package. - desktop/scripts/update-acp-tools-lock.mjs regenerates the lock from the registry's `latest` dist-tags, failing loudly on any unresolvable package — never silently pinning an older version. Ranged dependencies (codex-acp's ^0.144.0) resolve to the highest matching version when `npm view` returns an array. - desktop/scripts/ensure-acp-tools.sh installs the locked tools into a shared dev cache (~/Library/Caches/buzz-dev/acp-tools), validates versions + integrity against the lock, and stamps staged binaries next to the shared bin dir so any lock change — including a revert — forces a re-stage; binaries no longer in the lock are pruned. - desktop/scripts/prepare-acp-tools-resource.sh stages the vendored npm trees + node wrapper shims into desktop/src-tauri/resources/acp, writes the node-runtime.json manifest for the app's Node.js doctor check, and ad-hoc signs every nested Mach-O (file(1) scan — the codex package vendors rg, zsh, and codex-code-mode-host beyond the main CLIs) so Gatekeeper doesn't kill them in local builds. - desktop/scripts/lib/acp-node-wrapper.sh is the single wrapper-shim generator shared by both scripts, so the dev-cache and bundled wrappers (and the Node major they enforce) cannot drift. - Wiring: `just dev` / `just staging` stage the resources and export BUZZ_ACP_TOOLS_DIR; `just desktop-release-build` stages per target before `tauri build`; `just bump-acp-tools` reruns the lock updater; tauri.conf.json bundles resources/acp; staged artifacts are gitignored with a .gitkeep placeholder. Runtime resolution of the staged dir follows in the next commit. The sprout-releases internal pipeline will need the same staging step before its `tauri build`; that lives in a separate repo. Ports the build-time half of the Staged implementation in block/builderbot#876 (branch commits 16b115a7 bundle feature, f5c6bba9 re-stage after lock revert, 288fa7eb shared node wrapper lib, adea4017 node-runtime manifest, 5124302a sign all nested Mach-Os, de6a9782 ranged-dependency updater fix), itself ported from squareup/berd f07df1d2 + 1db993fb + 24d7518b + 07087303 + 737e33a5. Verified: update-acp-tools-lock.mjs regenerated the lock against the Block registry (byte-identical pins to the donor lock; both packages confirmed at npm `latest`); fresh stage installs both tools and the staged wrappers report 0.58.1 / 1.1.2; no-op re-run performs zero npm installs; a stamp/lock mismatch forces a re-stage and stray binaries are pruned; all five staged Mach-Os pass `codesign --verify`; cargo check on desktop/src-tauri passes with the new resources entry. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .gitignore | 6 + Justfile | 15 + desktop/acp-tools.lock.json | 196 ++++++++++ desktop/scripts/ensure-acp-tools.sh | 363 ++++++++++++++++++ desktop/scripts/lib/acp-node-wrapper.sh | 61 +++ desktop/scripts/prepare-acp-tools-resource.sh | 128 ++++++ desktop/scripts/update-acp-tools-lock.mjs | 363 ++++++++++++++++++ desktop/src-tauri/resources/acp/bin/.gitkeep | 0 desktop/src-tauri/tauri.conf.json | 1 + 9 files changed, 1133 insertions(+) create mode 100644 desktop/acp-tools.lock.json create mode 100755 desktop/scripts/ensure-acp-tools.sh create mode 100644 desktop/scripts/lib/acp-node-wrapper.sh create mode 100755 desktop/scripts/prepare-acp-tools-resource.sh create mode 100755 desktop/scripts/update-acp-tools-lock.mjs create mode 100644 desktop/src-tauri/resources/acp/bin/.gitkeep diff --git a/.gitignore b/.gitignore index ed2d1a9413..bbf5358839 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,12 @@ node_modules/ # Root npm lockfiles are accidental; desktop uses pnpm in /desktop. /package-lock.json +# Bundled ACP bridge tools (regenerated by desktop/scripts/prepare-acp-tools-resource.sh) +desktop/src-tauri/resources/acp/bin/* +!desktop/src-tauri/resources/acp/bin/.gitkeep +desktop/src-tauri/resources/acp/node/ +desktop/src-tauri/resources/acp/node-runtime.json + # sqlx offline query data (generated, not portable) .sqlx/ diff --git a/Justfile b/Justfile index 1059b6d96a..fe3b0ea45f 100644 --- a/Justfile +++ b/Justfile @@ -121,6 +121,10 @@ desktop-test: desktop-typecheck: cd {{desktop_dir}} && pnpm typecheck +# Query the latest npm releases of the bundled ACP bridge tools and update desktop/acp-tools.lock.json +bump-acp-tools *ARGS: + node desktop/scripts/update-acp-tools-lock.mjs {{ARGS}} + # Build desktop frontend assets desktop-build: cd {{desktop_dir}} && pnpm build @@ -206,6 +210,7 @@ desktop-release-build target="aarch64-apple-darwin": touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" + ./desktop/scripts/prepare-acp-tools-resource.sh "$TARGET" pnpm install cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}} @@ -327,6 +332,11 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations done fi cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay + # Stage the pinned ACP bridge tools and point the app at the staged dir so + # dev builds resolve the same bundled bridges as packaged builds. + ./desktop/scripts/prepare-acp-tools-resource.sh + export BUZZ_ACP_TOOLS_DIR="{{justfile_directory()}}/desktop/src-tauri/resources/acp/bin" + echo "Using ACP tools dir: ${BUZZ_ACP_TOOLS_DIR}" ./target/debug/buzz-relay & RELAY_PID=$! sleep 1 @@ -355,6 +365,11 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs export PATH="{{justfile_directory()}}/bin:$PATH" pnpm install # unconditional: staging must always start with a clean dep tree cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + # Stage the pinned ACP bridge tools and point the app at the staged dir so + # staging builds resolve the same bundled bridges as packaged builds. + ./desktop/scripts/prepare-acp-tools-resource.sh + export BUZZ_ACP_TOOLS_DIR="{{justfile_directory()}}/desktop/src-tauri/resources/acp/bin" + echo "Using ACP tools dir: ${BUZZ_ACP_TOOLS_DIR}" FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) diff --git a/desktop/acp-tools.lock.json b/desktop/acp-tools.lock.json new file mode 100644 index 0000000000..d2361a3b6b --- /dev/null +++ b/desktop/acp-tools.lock.json @@ -0,0 +1,196 @@ +{ + "tools": [ + { + "id": "claude-acp", + "binary": "claude-agent-acp", + "source": "npm", + "package": "@agentclientprotocol/claude-agent-acp", + "version": "0.58.1", + "integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz", + "target": "aarch64-apple-darwin", + "npmOs": "darwin", + "npmCpu": "arm64", + "nodeEngine": ">=22", + "dependencyPackage": "@anthropic-ai/claude-agent-sdk", + "dependencyVersion": "0.3.205", + "dependencyIntegrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz", + "claudeCodeVersion": "2.1.205", + "nativePackage": "@anthropic-ai/claude-agent-sdk-darwin-arm64", + "nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-arm64", + "nativeVersion": "0.3.205", + "nativeIntegrity": "sha512-lrfJ4eVtzfPkCpbSkBOGSMQCBbvmW6nbPzgHE4IwMN3scZlpuFMUFqh2aaJa/X2SAcWD9H2S0t2WWvSRgM7BjA==", + "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.205.tgz", + "nativeExecutable": "claude" + }, + { + "id": "claude-acp", + "binary": "claude-agent-acp", + "source": "npm", + "package": "@agentclientprotocol/claude-agent-acp", + "version": "0.58.1", + "integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz", + "target": "aarch64-unknown-linux-gnu", + "npmOs": "linux", + "npmCpu": "arm64", + "npmLibc": "glibc", + "nodeEngine": ">=22", + "dependencyPackage": "@anthropic-ai/claude-agent-sdk", + "dependencyVersion": "0.3.205", + "dependencyIntegrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz", + "claudeCodeVersion": "2.1.205", + "nativePackage": "@anthropic-ai/claude-agent-sdk-linux-arm64", + "nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-arm64", + "nativeVersion": "0.3.205", + "nativeIntegrity": "sha512-CXzySK3PV3EizCRPXnxPqeaAtgrBFDnMFOVpMe36oC3U16yDb1b1tAJGqZi/7uFrVvAiaXvnSFxhUWnDDSaO+A==", + "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.205.tgz", + "nativeExecutable": "claude" + }, + { + "id": "claude-acp", + "binary": "claude-agent-acp", + "source": "npm", + "package": "@agentclientprotocol/claude-agent-acp", + "version": "0.58.1", + "integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz", + "target": "x86_64-apple-darwin", + "npmOs": "darwin", + "npmCpu": "x64", + "nodeEngine": ">=22", + "dependencyPackage": "@anthropic-ai/claude-agent-sdk", + "dependencyVersion": "0.3.205", + "dependencyIntegrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz", + "claudeCodeVersion": "2.1.205", + "nativePackage": "@anthropic-ai/claude-agent-sdk-darwin-x64", + "nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-x64", + "nativeVersion": "0.3.205", + "nativeIntegrity": "sha512-G6ETPmL5mNzJ2DFsWxG3jmsmrXgZX1N2ZCJvxaGUUpjTsKZJ4Tup1cWYvcd/m7o5fYZmx9REmgzTwsAIc1fdPQ==", + "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.205.tgz", + "nativeExecutable": "claude" + }, + { + "id": "claude-acp", + "binary": "claude-agent-acp", + "source": "npm", + "package": "@agentclientprotocol/claude-agent-acp", + "version": "0.58.1", + "integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz", + "target": "x86_64-unknown-linux-gnu", + "npmOs": "linux", + "npmCpu": "x64", + "npmLibc": "glibc", + "nodeEngine": ">=22", + "dependencyPackage": "@anthropic-ai/claude-agent-sdk", + "dependencyVersion": "0.3.205", + "dependencyIntegrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz", + "claudeCodeVersion": "2.1.205", + "nativePackage": "@anthropic-ai/claude-agent-sdk-linux-x64", + "nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-x64", + "nativeVersion": "0.3.205", + "nativeIntegrity": "sha512-siS+1iNqBSlGFZZvJY6+mhzZ/6/ec/TbX9GMuwmTF0E6fxGhIIp797jJxR1q8r6FAq7d39mEoRNhC0Ffo60uNQ==", + "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.205.tgz", + "nativeExecutable": "claude" + }, + { + "id": "codex-acp", + "binary": "codex-acp", + "source": "npm", + "package": "@agentclientprotocol/codex-acp", + "version": "1.1.2", + "integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz", + "target": "aarch64-apple-darwin", + "npmOs": "darwin", + "npmCpu": "arm64", + "nodeEngine": ">=22", + "dependencyPackage": "@openai/codex", + "dependencyVersion": "0.144.1", + "dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1.tgz", + "nativePackage": "@openai/codex-darwin-arm64", + "nativePackageName": "@openai/codex", + "nativeVersion": "0.144.1-darwin-arm64", + "nativeIntegrity": "sha512-dABeDK+ATqMG54MGBd3VjpKfh5EOoqx9PKVQB2QYDaEXx3F6CdUCXue5QIMfr4OxziUj8pUcLAQyd+KFqiTUFw==", + "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1-darwin-arm64.tgz", + "nativeExecutable": "vendor/aarch64-apple-darwin/bin/codex" + }, + { + "id": "codex-acp", + "binary": "codex-acp", + "source": "npm", + "package": "@agentclientprotocol/codex-acp", + "version": "1.1.2", + "integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz", + "target": "aarch64-unknown-linux-gnu", + "npmOs": "linux", + "npmCpu": "arm64", + "npmLibc": "glibc", + "nodeEngine": ">=22", + "dependencyPackage": "@openai/codex", + "dependencyVersion": "0.144.1", + "dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1.tgz", + "nativePackage": "@openai/codex-linux-arm64", + "nativePackageName": "@openai/codex", + "nativeVersion": "0.144.1-linux-arm64", + "nativeIntegrity": "sha512-451o15+XtaXCCb35t/KCyyPqXHnTPxPxtdqEYOnE3e4sH5AfnI/uVJwfdjOksMG6vRLy6R+fLvSDOMguRFLmQw==", + "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1-linux-arm64.tgz", + "nativeExecutable": "vendor/aarch64-unknown-linux-musl/bin/codex" + }, + { + "id": "codex-acp", + "binary": "codex-acp", + "source": "npm", + "package": "@agentclientprotocol/codex-acp", + "version": "1.1.2", + "integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz", + "target": "x86_64-apple-darwin", + "npmOs": "darwin", + "npmCpu": "x64", + "nodeEngine": ">=22", + "dependencyPackage": "@openai/codex", + "dependencyVersion": "0.144.1", + "dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1.tgz", + "nativePackage": "@openai/codex-darwin-x64", + "nativePackageName": "@openai/codex", + "nativeVersion": "0.144.1-darwin-x64", + "nativeIntegrity": "sha512-K2g3Q3tNxzFhV0SuzO6HcsYK7EQrp/o4HyeReyhkwVrwwUPoYwyIbB0IRjHIiDzRhbKriDccid2iyF5aPqdTcg==", + "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1-darwin-x64.tgz", + "nativeExecutable": "vendor/x86_64-apple-darwin/bin/codex" + }, + { + "id": "codex-acp", + "binary": "codex-acp", + "source": "npm", + "package": "@agentclientprotocol/codex-acp", + "version": "1.1.2", + "integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz", + "target": "x86_64-unknown-linux-gnu", + "npmOs": "linux", + "npmCpu": "x64", + "npmLibc": "glibc", + "nodeEngine": ">=22", + "dependencyPackage": "@openai/codex", + "dependencyVersion": "0.144.1", + "dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1.tgz", + "nativePackage": "@openai/codex-linux-x64", + "nativePackageName": "@openai/codex", + "nativeVersion": "0.144.1-linux-x64", + "nativeIntegrity": "sha512-HNGVI+BulrOaC/0IzBvd6EL62j7LrlbFKibrhw6hZjjCjAeUYzRB2jB4qDzXN1NfqDi6Xrvniof3kwbwab24lg==", + "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1-linux-x64.tgz", + "nativeExecutable": "vendor/x86_64-unknown-linux-musl/bin/codex" + } + ] +} diff --git a/desktop/scripts/ensure-acp-tools.sh b/desktop/scripts/ensure-acp-tools.sh new file mode 100755 index 0000000000..ddee056ca4 --- /dev/null +++ b/desktop/scripts/ensure-acp-tools.sh @@ -0,0 +1,363 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +app_root="$(cd "$script_dir/.." && pwd)" +lock_file="${ACP_TOOLS_LOCK_FILE:-$app_root/acp-tools.lock.json}" + +# shellcheck source=lib/acp-node-wrapper.sh +source "$script_dir/lib/acp-node-wrapper.sh" + +usage() { + cat <<'USAGE' +Usage: desktop/scripts/ensure-acp-tools.sh [--target ] [--print-bin-dir] + +Installs the ACP bridge tools pinned in acp-tools.lock.json into the shared +Buzz dev cache. The lockfile is target-specific; only entries matching the +requested target are prepared. Each tool is installed as a vendored npm +package tree with a small executable wrapper, validated against the locked +versions and integrity hashes. + +Environment variables: + ACP_TOOLS_LOCK_FILE lockfile path (default: desktop/acp-tools.lock.json) + ACP_TOOLS_CACHE_DIR cache dir override +USAGE +} + +default_cache_root() { + if [[ -n "${XDG_CACHE_HOME:-}" ]]; then + printf '%s/buzz-dev/acp-tools\n' "$XDG_CACHE_HOME" + return + fi + case "$(uname -s)" in + Darwin) printf '%s/Library/Caches/buzz-dev/acp-tools\n' "$HOME" ;; + *) printf '%s/.cache/buzz-dev/acp-tools\n' "$HOME" ;; + esac +} + +target="" +print_bin_dir=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --target) + target="${2:-}" + [[ -n "$target" ]] || { echo "--target requires a value" >&2; exit 1; } + shift 2 + ;; + --print-bin-dir) + print_bin_dir=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$target" ]]; then + target="$(rustc -vV | sed -n 's|host: ||p')" +fi +if [[ -z "$target" ]]; then + echo "Could not determine rust host target. Pass --target explicitly." >&2 + exit 1 +fi + +cache_root="${ACP_TOOLS_CACHE_DIR:-$(default_cache_root)}" +bin_dir="$cache_root/bin/$target" + +if [[ ! -f "$lock_file" ]]; then + echo "ACP tools lockfile not found: $lock_file" >&2 + exit 1 +fi + +require_tool() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Required tool missing: $1" >&2 + exit 1 + fi +} + +require_tool node +require_tool npm + +lock_entries="$(node - "$lock_file" "$target" <<'NODE' +const fs = require("node:fs"); +const [lockFile, target] = process.argv.slice(2); +const data = JSON.parse(fs.readFileSync(lockFile, "utf8")); +const entries = (data.tools ?? []).filter((tool) => tool.target === target); +function requireString(entry, field) { + if (typeof entry[field] !== "string" || entry[field].trim() === "") { + throw new Error(`Invalid ACP tool lock entry for ${entry.id ?? "(unknown)"}: missing ${field}`); + } +} +for (const entry of entries) { + if (entry.source !== "npm") { + throw new Error(`Invalid ACP tool lock entry for ${entry.id}: unsupported source ${entry.source}`); + } + for (const field of [ + "id", + "binary", + "target", + "package", + "version", + "integrity", + "tarball", + "npmOs", + "npmCpu", + "dependencyPackage", + "dependencyVersion", + "dependencyIntegrity", + "dependencyTarball", + "nativePackage", + "nativePackageName", + "nativeVersion", + "nativeIntegrity", + "nativeTarball", + "nativeExecutable", + ]) { + requireString(entry, field); + } +} +process.stdout.write(JSON.stringify(entries)); +NODE +)" + +entry_count="$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]).length))' "$lock_entries")" +mkdir -p "$bin_dir" +if [[ "$entry_count" == "0" ]]; then + find "$bin_dir" -type f -delete + if [[ "$print_bin_dir" == "1" ]]; then + printf '%s\n' "$bin_dir" + else + echo "No ACP tools locked for target $target." + fi + exit 0 +fi + +validate_npm_install() { + local install_dir="$1" + local package="$2" + local version="$3" + local integrity="$4" + local dependency_package="$5" + local dependency_version="$6" + local dependency_integrity="$7" + local native_package="$8" + local native_package_name="$9" + local native_version="${10}" + local native_integrity="${11}" + local native_executable="${12}" + local claude_code_version="${13}" + + node - "$install_dir" "$package" "$version" "$integrity" "$dependency_package" "$dependency_version" "$dependency_integrity" "$native_package" "$native_package_name" "$native_version" "$native_integrity" "$native_executable" "$claude_code_version" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const [ + installDir, + packageName, + expectedVersion, + expectedIntegrity, + dependencyPackageName, + expectedDependencyVersion, + expectedDependencyIntegrity, + nativePackageName, + expectedNativePackageName, + expectedNativeVersion, + expectedNativeIntegrity, + nativeExecutable, + expectedClaudeCodeVersion, +] = process.argv.slice(2); + +function packagePath(name, ...segments) { + return path.join(installDir, "node_modules", ...name.split("/"), ...segments); +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label}: expected ${expected}, got ${actual}`); + } +} + +function packageLockEntry(lock, packageName) { + const suffix = `node_modules/${packageName}`; + const match = Object.entries(lock.packages ?? {}).find(([key]) => key === suffix || key.endsWith(`/${suffix}`)); + if (!match) { + throw new Error(`package-lock entry not found for ${packageName}`); + } + return match[1]; +} + +const packageJson = readJson(packagePath(packageName, "package.json")); +assertEqual(packageJson.name, packageName, `${packageName} name`); +assertEqual(packageJson.version, expectedVersion, `${packageName} version`); + +const lock = readJson(path.join(installDir, "package-lock.json")); +assertEqual(packageLockEntry(lock, packageName).integrity, expectedIntegrity, `${packageName} integrity`); + +const dependencyPackageJson = readJson(packagePath(dependencyPackageName, "package.json")); +assertEqual( + dependencyPackageJson.name, + dependencyPackageName, + `${dependencyPackageName} name`, +); +assertEqual( + dependencyPackageJson.version, + expectedDependencyVersion, + `${dependencyPackageName} version`, +); +if (expectedClaudeCodeVersion && expectedClaudeCodeVersion !== "null") { + assertEqual( + dependencyPackageJson.claudeCodeVersion, + expectedClaudeCodeVersion, + `${dependencyPackageName} claudeCodeVersion`, + ); +} +assertEqual( + packageLockEntry(lock, dependencyPackageName).integrity, + expectedDependencyIntegrity, + `${dependencyPackageName} integrity`, +); + +const nativePackageJson = readJson(packagePath(nativePackageName, "package.json")); +assertEqual( + nativePackageJson.name, + expectedNativePackageName, + `${nativePackageName} package name`, +); +assertEqual( + nativePackageJson.version, + expectedNativeVersion, + `${nativePackageName} version`, +); +fs.accessSync(packagePath(nativePackageName, nativeExecutable), fs.constants.X_OK); +assertEqual( + packageLockEntry(lock, nativePackageName).integrity, + expectedNativeIntegrity, + `${nativePackageName} integrity`, +); +NODE +} + +node -e ' +const entries = JSON.parse(process.argv[1]); +for (const entry of entries) { + console.log([ + entry.id, + entry.binary, + entry.package, + entry.version, + entry.integrity, + entry.tarball, + entry.npmOs, + entry.npmCpu, + entry.npmLibc ?? "", + entry.nodeEngine ?? ">=22", + entry.dependencyPackage, + entry.dependencyVersion, + entry.dependencyIntegrity, + entry.dependencyTarball, + entry.nativePackage, + entry.nativePackageName, + entry.nativeVersion, + entry.nativeIntegrity, + entry.nativeTarball, + entry.nativeExecutable, + entry.claudeCodeVersion ?? "", + ].join("\x1f")); +} +' "$lock_entries" | while IFS=$'\x1f' read -r id binary package version integrity tarball npm_os npm_cpu npm_libc node_engine dependency_package dependency_version dependency_integrity dependency_tarball native_package native_package_name native_version native_integrity native_tarball native_executable claude_code_version; do + [[ -n "$id" ]] || continue + + tool_dir="$cache_root/$target/$id/$version" + install_dir="$tool_dir/npm" + package_dir="$install_dir/node_modules/$package" + entrypoint="$package_dir/dist/index.js" + native_binary="$install_dir/node_modules/$native_package/$native_executable" + staged_bin="$bin_dir/$binary" + # The staged output is shared across lock versions, so its freshness stamp + # must live next to it, not in the per-version tool_dir: a per-version stamp + # stays self-consistent after a lock revert and would skip re-staging. + stamp="$staged_bin.stamp" + if [[ -x "$staged_bin" && -f "$stamp" && -f "$entrypoint" && -x "$native_binary" ]]; then + # shellcheck disable=SC1090 + source "$stamp" + if [[ "${STAMP_PACKAGE:-}" == "$package" && "${STAMP_VERSION:-}" == "$version" && "${STAMP_INTEGRITY:-}" == "$integrity" && "${STAMP_DEPENDENCY_PACKAGE:-}" == "$dependency_package" && "${STAMP_DEPENDENCY_VERSION:-}" == "$dependency_version" && "${STAMP_DEPENDENCY_INTEGRITY:-}" == "$dependency_integrity" && "${STAMP_NATIVE_PACKAGE:-}" == "$native_package" && "${STAMP_NATIVE_PACKAGE_NAME:-}" == "$native_package_name" && "${STAMP_NATIVE_VERSION:-}" == "$native_version" && "${STAMP_NATIVE_INTEGRITY:-}" == "$native_integrity" && "${STAMP_NATIVE_EXECUTABLE:-}" == "$native_executable" ]]; then + continue + fi + fi + + echo "Installing ACP tool $id $version from npm for $target..." >&2 + rm -rf "$install_dir" + mkdir -p "$install_dir" "$bin_dir" + npm_args=( + install + --prefix "$install_dir" + --omit=dev + --include=optional + --ignore-scripts + --no-audit + --no-fund + --os "$npm_os" + --cpu "$npm_cpu" + ) + if [[ -n "$npm_libc" ]]; then + npm_args+=(--libc "$npm_libc") + fi + npm_args+=("$package@$version") + npm "${npm_args[@]}" >&2 + + validate_npm_install "$install_dir" "$package" "$version" "$integrity" "$dependency_package" "$dependency_version" "$dependency_integrity" "$native_package" "$native_package_name" "$native_version" "$native_integrity" "$native_executable" "$claude_code_version" + write_node_wrapper "$staged_bin" "$entrypoint" "$node_engine" + { + printf 'STAMP_TARGET=%q\n' "$target" + printf 'STAMP_PACKAGE=%q\n' "$package" + printf 'STAMP_VERSION=%q\n' "$version" + printf 'STAMP_INTEGRITY=%q\n' "$integrity" + printf 'STAMP_TARBALL=%q\n' "$tarball" + printf 'STAMP_NPM_OS=%q\n' "$npm_os" + printf 'STAMP_NPM_CPU=%q\n' "$npm_cpu" + printf 'STAMP_NPM_LIBC=%q\n' "$npm_libc" + printf 'STAMP_NODE_ENGINE=%q\n' "$node_engine" + printf 'STAMP_DEPENDENCY_PACKAGE=%q\n' "$dependency_package" + printf 'STAMP_DEPENDENCY_VERSION=%q\n' "$dependency_version" + printf 'STAMP_DEPENDENCY_INTEGRITY=%q\n' "$dependency_integrity" + printf 'STAMP_DEPENDENCY_TARBALL=%q\n' "$dependency_tarball" + printf 'STAMP_CLAUDE_CODE_VERSION=%q\n' "$claude_code_version" + printf 'STAMP_NATIVE_PACKAGE=%q\n' "$native_package" + printf 'STAMP_NATIVE_PACKAGE_NAME=%q\n' "$native_package_name" + printf 'STAMP_NATIVE_VERSION=%q\n' "$native_version" + printf 'STAMP_NATIVE_INTEGRITY=%q\n' "$native_integrity" + printf 'STAMP_NATIVE_TARBALL=%q\n' "$native_tarball" + printf 'STAMP_NATIVE_EXECUTABLE=%q\n' "$native_executable" + printf 'STAMP_BINARY=%q\n' "$binary" + } > "$stamp" +done + +# bin_dir is prepended to the agent spawn PATH and the desktop's command +# resolution sweep, so binaries (and stamps) for tools no longer in the lock +# must be pruned, not just left behind. +locked_binaries="$(node -e ' +const entries = JSON.parse(process.argv[1]); +for (const entry of entries) console.log(entry.binary); +' "$lock_entries" | sort -u)" +find "$bin_dir" -type f -print0 | while IFS= read -r -d '' staged_file; do + name="$(basename "$staged_file")" + if ! printf '%s\n' "$locked_binaries" | grep -Fxq -- "${name%.stamp}"; then + rm -f -- "$staged_file" + fi +done + +if [[ "$print_bin_dir" == "1" ]]; then + printf '%s\n' "$bin_dir" +fi diff --git a/desktop/scripts/lib/acp-node-wrapper.sh b/desktop/scripts/lib/acp-node-wrapper.sh new file mode 100644 index 0000000000..7d99839ee1 --- /dev/null +++ b/desktop/scripts/lib/acp-node-wrapper.sh @@ -0,0 +1,61 @@ +# Shared Node wrapper generation for ACP bridge tools staged from npm. +# Sourced by ensure-acp-tools.sh and prepare-acp-tools-resource.sh so the +# wrapper staged into the dev cache and the wrapper bundled into app +# resources cannot drift (a drift would make dev and bundled installs fail +# differently on the same missing/old Node runtime). +# +# acp_required_node_major +# Prints the minimum Node.js major version implied by a ">=N..." engine +# range, defaulting to 22 when the range is not in that form. Shared by +# the wrapper shim below and the node-runtime.json manifest consumed by +# the app's Node.js runtime doctor check, so the version the wrapper +# enforces at spawn time and the version the doctor reports at setup +# time cannot disagree. +acp_required_node_major() { + local node_engine="$1" + local major + major="$(printf '%s\n' "$node_engine" | sed -n 's/^>=\([0-9][0-9]*\).*$/\1/p')" + if [[ -z "$major" ]]; then + major=22 + fi + printf '%s\n' "$major" +} + +# write_node_wrapper [node-engine] +# Writes an executable bash shim at that verifies a Node.js +# runtime satisfying (default ">=22") is on PATH, then +# execs node on . An absolute is embedded +# verbatim; a relative one is resolved against the wrapper's directory +# at run time. + +write_node_wrapper() { + local wrapper="$1" + local entrypoint="$2" + local node_engine="${3:->=22}" + local required_node_major + required_node_major="$(acp_required_node_major "$node_engine")" + + mkdir -p "$(dirname "$wrapper")" + { + printf '#!/usr/bin/env bash\n' + printf 'set -euo pipefail\n' + printf 'if ! command -v node >/dev/null 2>&1; then\n' + printf ' echo "%s requires Node.js %s on PATH." >&2\n' "$(basename "$wrapper")" "$node_engine" + printf ' exit 127\n' + printf 'fi\n' + printf 'required_node_major=%q\n' "$required_node_major" + printf 'node_major="$(node -p '\''process.versions.node.split(".")[0]'\'' 2>/dev/null || true)"\n' + printf 'if [[ -z "$node_major" || "$node_major" -lt "$required_node_major" ]]; then\n' + printf ' echo "%s requires Node.js %s on PATH." >&2\n' "$(basename "$wrapper")" "$node_engine" + printf ' exit 1\n' + printf 'fi\n' + if [[ "$entrypoint" == /* ]]; then + printf 'entrypoint=%q\n' "$entrypoint" + else + printf 'wrapper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n' + printf 'entrypoint="$wrapper_dir"/%q\n' "$entrypoint" + fi + printf 'exec node "$entrypoint" "$@"\n' + } > "$wrapper" + chmod +x "$wrapper" +} diff --git a/desktop/scripts/prepare-acp-tools-resource.sh b/desktop/scripts/prepare-acp-tools-resource.sh new file mode 100755 index 0000000000..df7f840472 --- /dev/null +++ b/desktop/scripts/prepare-acp-tools-resource.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +app_root="$(cd "$script_dir/.." && pwd)" +lock_file="${ACP_TOOLS_LOCK_FILE:-$app_root/acp-tools.lock.json}" + +# shellcheck source=lib/acp-node-wrapper.sh +source "$script_dir/lib/acp-node-wrapper.sh" + +usage() { + cat <<'USAGE' +Usage: desktop/scripts/prepare-acp-tools-resource.sh [target-triple] + +Stages the locked ACP bridge tools into src-tauri/resources/acp so Tauri can +bundle them as application resources: vendored npm package trees under +resources/acp/node and executable wrappers under resources/acp/bin. The +optional target triple defaults to the Rust host target. + +Note: resources/acp/bin holds a single target at a time, so staging must stay +tied to the build target. +USAGE +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +target="${1:-}" +ensure_args=() +if [[ -n "$target" ]]; then + ensure_args+=(--target "$target") +else + target="$(rustc -vV | sed -n 's|host: ||p')" +fi +if [[ -z "$target" ]]; then + echo "Could not determine rust host target." >&2 + exit 1 +fi + +cache_bin_dir="$("$script_dir/ensure-acp-tools.sh" ${ensure_args[@]+"${ensure_args[@]}"} --print-bin-dir)" +cache_root="$(dirname "$(dirname "$cache_bin_dir")")" +resource_root="$app_root/src-tauri/resources/acp" +resource_bin_dir="$resource_root/bin" +resource_node_dir="$resource_root/node" +mkdir -p "$resource_bin_dir" + +# Keep .gitkeep but refresh any staged tools from the lock. +find "$resource_bin_dir" -type f ! -name ".gitkeep" -delete +rm -rf "$resource_node_dir" +mkdir -p "$resource_node_dir" + +# Manifest for the app's Node.js runtime doctor check, staged next to the +# bin dir so the app can resolve it as the bin dir's parent. Removed up +# front so locks with no npm-sourced tools ship no manifest and the doctor +# check stays silent. +node_runtime_manifest="$resource_root/node-runtime.json" +rm -f "$node_runtime_manifest" +node_runtime_entries=() + +codesign_if_darwin() { + local file="$1" + if [[ "$(uname -s)" == "Darwin" ]] && command -v codesign >/dev/null 2>&1; then + codesign --force --sign - "$file" >/dev/null 2>&1 || true + fi +} + +while IFS=$'\t' read -r id binary package version node_engine; do + [[ -n "$id" ]] || continue + install_dir="$cache_root/$target/$id/$version/npm" + entrypoint="$install_dir/node_modules/$package/dist/index.js" + if [[ ! -f "$entrypoint" ]]; then + echo "Locked npm ACP tool missing from cache: $package@$version" >&2 + exit 1 + fi + resource_package_dir="$resource_node_dir/$id" + mkdir -p "$resource_package_dir" + cp -R "$install_dir/." "$resource_package_dir/" + resource_entrypoint="$resource_package_dir/node_modules/$package/dist/index.js" + if [[ ! -f "$resource_entrypoint" ]]; then + echo "Failed to stage npm ACP tool: $package@$version" >&2 + exit 1 + fi + write_node_wrapper "$resource_bin_dir/$binary" "../node/$id/node_modules/$package/dist/index.js" "$node_engine" + node_runtime_entries+=("$id"$'\t'"$binary"$'\t'"$node_engine"$'\t'"$(acp_required_node_major "$node_engine")") + # Ad-hoc sign every Mach-O in the staged package, not just the main CLIs: + # the codex native package also vendors executables like rg and zsh, and + # unsigned nested Mach-Os are killed by Gatekeeper. Darwin only, so Linux + # staging skips the file(1) scan. + if [[ "$(uname -s)" == "Darwin" ]]; then + while IFS= read -r -d '' candidate; do + if file -b "$candidate" | grep -q "Mach-O"; then + codesign_if_darwin "$candidate" + fi + done < <(find "$resource_package_dir" -type f -print0) + fi +done < <(node - "$lock_file" "$target" <<'NODE' +const fs = require("node:fs"); +const [lockFile, target] = process.argv.slice(2); +const data = JSON.parse(fs.readFileSync(lockFile, "utf8")); +for (const entry of data.tools ?? []) { + if (entry.target !== target || typeof entry.binary !== "string") continue; + if (entry.source !== "npm") { + throw new Error(`Unsupported ACP tool source: ${entry.source}`); + } + console.log([entry.id, entry.binary, entry.package, entry.version, entry.nodeEngine ?? ">=22"].join("\t")); +} +NODE +) + +# One manifest entry per npm-sourced bridge, each carrying its own required +# Node major, so bridges with different engine ranges surface distinct +# requirements in the doctor check. +if ((${#node_runtime_entries[@]} > 0)); then + node -e ' +const fs = require("node:fs"); +const [manifestFile, ...entries] = process.argv.slice(1); +const tools = entries.map((line) => { + const [id, binary, nodeEngine, requiredNodeMajor] = line.split("\t"); + return { id, binary, nodeEngine, requiredNodeMajor: Number(requiredNodeMajor) }; +}); +fs.writeFileSync(manifestFile, `${JSON.stringify({ tools }, null, 2)}\n`); +' "$node_runtime_manifest" ${node_runtime_entries[@]+"${node_runtime_entries[@]}"} + echo "Wrote ACP Node runtime manifest: $node_runtime_manifest" +fi + +echo "Staged ACP tools resource: $resource_bin_dir" diff --git a/desktop/scripts/update-acp-tools-lock.mjs b/desktop/scripts/update-acp-tools-lock.mjs new file mode 100755 index 0000000000..bffcf0c320 --- /dev/null +++ b/desktop/scripts/update-acp-tools-lock.mjs @@ -0,0 +1,363 @@ +#!/usr/bin/env node +import { execFile } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { promisify } from "node:util"; + +const appRoot = path.resolve(import.meta.dirname, ".."); +const defaultLockFile = path.join(appRoot, "acp-tools.lock.json"); + +const SUPPORTED_TARGETS = [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", +]; + +// The Codex ACP executable stays `codex-acp`, but bundled installs must come +// from the maintained Agent Client Protocol package rather than the stale +// Zed package. +const CODEX_ACP_PACKAGE = "@agentclientprotocol/codex-acp"; + +const TOOL_SPECS = [ + { + id: "claude-acp", + binary: "claude-agent-acp", + package: "@agentclientprotocol/claude-agent-acp", + dependencyPackage: "@anthropic-ai/claude-agent-sdk", + nativePackageKey: "claudeAgentSdk", + includeClaudeCodeVersion: true, + }, + { + id: "codex-acp", + binary: "codex-acp", + package: CODEX_ACP_PACKAGE, + dependencyPackage: "@openai/codex", + nativePackageKey: "openaiCodex", + }, +]; + +const NPM_TARGET_CONFIG = { + "aarch64-apple-darwin": { + npmOs: "darwin", + npmCpu: "arm64", + nativePackages: { + claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-darwin-arm64", + openaiCodex: "@openai/codex-darwin-arm64", + }, + nativeExecutables: { + claudeAgentSdk: "claude", + openaiCodex: "vendor/aarch64-apple-darwin/bin/codex", + }, + }, + "x86_64-apple-darwin": { + npmOs: "darwin", + npmCpu: "x64", + nativePackages: { + claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-darwin-x64", + openaiCodex: "@openai/codex-darwin-x64", + }, + nativeExecutables: { + claudeAgentSdk: "claude", + openaiCodex: "vendor/x86_64-apple-darwin/bin/codex", + }, + }, + "aarch64-unknown-linux-gnu": { + npmOs: "linux", + npmCpu: "arm64", + npmLibc: "glibc", + nativePackages: { + claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-linux-arm64", + openaiCodex: "@openai/codex-linux-arm64", + }, + nativeExecutables: { + claudeAgentSdk: "claude", + openaiCodex: "vendor/aarch64-unknown-linux-musl/bin/codex", + }, + }, + "x86_64-unknown-linux-gnu": { + npmOs: "linux", + npmCpu: "x64", + npmLibc: "glibc", + nativePackages: { + claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-linux-x64", + openaiCodex: "@openai/codex-linux-x64", + }, + nativeExecutables: { + claudeAgentSdk: "claude", + openaiCodex: "vendor/x86_64-unknown-linux-musl/bin/codex", + }, + }, +}; + +const npmViewCache = new Map(); +const execFileAsync = promisify(execFile); + +function usage() { + console.log(`Usage: desktop/scripts/update-acp-tools-lock.mjs [--target ]... [--lock-file ] + +Queries npm for the latest release of each supported ACP bridge tool and +writes acp-tools.lock.json. Fails loudly when a package or one of its +per-target native dependencies cannot be resolved — never silently pins an +older version. + +Supported targets: + ${SUPPORTED_TARGETS.join("\n ")} + +Environment: + npm registry config used to resolve packages + ACP_TOOLS_LOCK_FILE lockfile path override +`); +} + +function parseArgs(argv) { + const targets = []; + let lockFile = process.env.ACP_TOOLS_LOCK_FILE ?? defaultLockFile; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "-h" || arg === "--help") { + usage(); + process.exit(0); + } + if (arg === "--target") { + const value = argv[++i]; + if (!value) throw new Error("--target requires a value"); + targets.push(value); + continue; + } + if (arg === "--lock-file") { + const value = argv[++i]; + if (!value) throw new Error("--lock-file requires a value"); + lockFile = path.resolve(value); + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + const selectedTargets = targets.length ? targets : SUPPORTED_TARGETS; + for (const target of selectedTargets) { + if (!SUPPORTED_TARGETS.includes(target)) { + throw new Error(`Unsupported target '${target}'`); + } + } + return { targets: selectedTargets, lockFile }; +} + +async function npmView(spec, fields) { + const cacheKey = `${spec}\0${fields.join("\0")}`; + if (!npmViewCache.has(cacheKey)) { + npmViewCache.set( + cacheKey, + execFileAsync("npm", ["view", spec, ...fields, "--json"], { + maxBuffer: 10 * 1024 * 1024, + }).then(({ stdout }) => { + try { + return JSON.parse(stdout); + } catch (error) { + throw new Error( + `npm view ${spec} returned invalid JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }), + ); + } + return npmViewCache.get(cacheKey); +} + +function requireString(value, label) { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`Missing ${label}`); + } + return value; +} + +function packageDist(metadata, label) { + const dist = metadata?.dist; + return { + tarball: requireString(dist?.tarball, `${label} dist.tarball`), + integrity: requireString(dist?.integrity, `${label} dist.integrity`), + }; +} + +function compareSemver(left, right) { + const leftCore = left.split("-", 1)[0].split(".").map(Number); + const rightCore = right.split("-", 1)[0].split(".").map(Number); + for (let i = 0; i < 3; i += 1) { + if ((leftCore[i] ?? 0) !== (rightCore[i] ?? 0)) { + return (leftCore[i] ?? 0) - (rightCore[i] ?? 0); + } + } + // A release outranks any prerelease of the same core version. + return (left.includes("-") ? 0 : 1) - (right.includes("-") ? 0 : 1); +} + +// npm view returns a single object when a spec matches one version, but an +// array of per-version objects when a range matches several. Pick the highest +// matching version so a ranged dependency still pins the newest release. +function pickLatestMatch(metadata, label) { + if (!Array.isArray(metadata)) return metadata; + if (metadata.length === 0) { + throw new Error(`No versions match ${label}`); + } + return metadata.reduce((best, candidate) => + compareSemver( + requireString(candidate?.version, `${label} version`), + requireString(best?.version, `${label} version`), + ) > 0 + ? candidate + : best, + ); +} + +function parseNpmAliasSpec(spec, fallbackPackage) { + if (!spec.startsWith("npm:")) { + return { packageName: fallbackPackage, version: spec }; + } + const aliased = spec.slice("npm:".length); + const versionSeparator = aliased.lastIndexOf("@"); + if (versionSeparator <= 0) { + throw new Error(`Unsupported npm alias spec: ${spec}`); + } + return { + packageName: aliased.slice(0, versionSeparator), + version: aliased.slice(versionSeparator + 1), + }; +} + +async function lockToolForTarget(tool, target) { + const npmTarget = NPM_TARGET_CONFIG[target]; + if (!npmTarget) { + throw new Error(`No npm target mapping for ${target}`); + } + + const packageName = tool.package; + const packageMetadata = await npmView(`${packageName}@latest`, [ + "name", + "version", + "dist", + "dependencies", + "engines", + "bin", + ]); + + if (packageMetadata.name !== packageName) { + throw new Error( + `npm package ${packageName} resolved to ${packageMetadata.name}`, + ); + } + + const version = requireString( + packageMetadata.version, + `${packageName} version`, + ); + const packageInfo = packageDist(packageMetadata, `${packageName}@${version}`); + const entry = { + id: tool.id, + binary: tool.binary, + source: "npm", + package: packageName, + version, + integrity: packageInfo.integrity, + tarball: packageInfo.tarball, + target, + npmOs: npmTarget.npmOs, + npmCpu: npmTarget.npmCpu, + ...(npmTarget.npmLibc ? { npmLibc: npmTarget.npmLibc } : {}), + nodeEngine: packageMetadata.engines?.node ?? ">=22", + }; + + const dependencyRange = requireString( + packageMetadata.dependencies?.[tool.dependencyPackage], + `${packageName} dependency ${tool.dependencyPackage}`, + ); + const dependencyMetadata = pickLatestMatch( + await npmView(`${tool.dependencyPackage}@${dependencyRange}`, [ + "name", + "version", + "dist", + "optionalDependencies", + "claudeCodeVersion", + ]), + `${tool.dependencyPackage}@${dependencyRange}`, + ); + const dependencyVersion = requireString( + dependencyMetadata.version, + `${tool.dependencyPackage}@${dependencyRange} version`, + ); + const dependencyInfo = packageDist( + dependencyMetadata, + `${tool.dependencyPackage}@${dependencyVersion}`, + ); + entry.dependencyPackage = tool.dependencyPackage; + entry.dependencyVersion = dependencyVersion; + entry.dependencyIntegrity = dependencyInfo.integrity; + entry.dependencyTarball = dependencyInfo.tarball; + if (tool.includeClaudeCodeVersion) { + entry.claudeCodeVersion = dependencyMetadata.claudeCodeVersion ?? null; + } + + const nativePackage = requireString( + npmTarget.nativePackages?.[tool.nativePackageKey], + `${target} native package for ${tool.nativePackageKey}`, + ); + const nativeExecutable = requireString( + npmTarget.nativeExecutables?.[tool.nativePackageKey], + `${target} native executable for ${tool.nativePackageKey}`, + ); + const nativeSpec = requireString( + dependencyMetadata.optionalDependencies?.[nativePackage], + `${tool.dependencyPackage}@${dependencyVersion} optional dependency ${nativePackage}`, + ); + const nativeAlias = parseNpmAliasSpec(nativeSpec, nativePackage); + const nativeMetadata = await npmView( + `${nativeAlias.packageName}@${nativeAlias.version}`, + ["name", "version", "dist"], + ); + const nativeVersion = requireString( + nativeMetadata.version, + `${nativeAlias.packageName}@${nativeAlias.version} version`, + ); + if (nativeVersion !== nativeAlias.version) { + throw new Error( + `${nativeAlias.packageName}@${nativeAlias.version} resolved to ${nativeVersion}`, + ); + } + const nativeInfo = packageDist( + nativeMetadata, + `${nativeAlias.packageName}@${nativeVersion}`, + ); + + return { + ...entry, + nativePackage, + nativePackageName: nativeMetadata.name ?? nativePackage, + nativeVersion, + nativeIntegrity: nativeInfo.integrity, + nativeTarball: nativeInfo.tarball, + nativeExecutable, + }; +} + +async function main() { + const { targets, lockFile } = parseArgs(process.argv.slice(2)); + const tools = []; + for (const tool of TOOL_SPECS) { + for (const target of targets) { + tools.push(await lockToolForTarget(tool, target)); + } + } + tools.sort((left, right) => + `${left.id}:${left.target}`.localeCompare(`${right.id}:${right.target}`), + ); + await mkdir(path.dirname(lockFile), { recursive: true }); + await writeFile(lockFile, `${JSON.stringify({ tools }, null, 2)}\n`); + console.log(`Updated ${path.relative(process.cwd(), lockFile)}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/desktop/src-tauri/resources/acp/bin/.gitkeep b/desktop/src-tauri/resources/acp/bin/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 25b0ad9b8f..ccb11ec123 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -59,6 +59,7 @@ "binaries/git-credential-nostr", "binaries/buzz" ], + "resources": ["resources/acp"], "icon": [ "icons/32x32.png", "icons/128x128.png", From 26ac0768116920eaa52f9631ec23171e83d97726 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 13 Jul 2026 13:22:02 +1000 Subject: [PATCH 02/18] feat(desktop): resolve bundled ACP bridge tools ahead of user installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register the staged ACP tools bin dir (BUZZ_ACP_TOOLS_DIR dev override, else the resources/acp/bin Tauri resource dir) once at app setup, and consult it everywhere bridge commands are resolved: - New managed_agents::acp_tools module: OnceLock registration plus command_in_bundled_dir, which resolves bare command names only — path-like commands name a specific binary the user picked and are never redirected into the bundle (Path::join with an absolute path would replace the bundled dir entirely). - resolve_command_uncached consults the bundled dir first, so the discovery sweep, readiness find_command, and spawn-time agent-command resolution (BUZZ_ACP_AGENT_COMMAND) all prefer the pinned bridges. Registration runs at the top of .setup() because resolutions are cached for the app lifetime. - build_augmented_path gains the bundled dir as its highest-priority segment, covering both the agent spawn PATH and the CLI auth-probe PATH, and now joins entries best-effort: an entry embedding the PATH separator (legal in macOS paths) is dropped with a log line instead of collapsing the entire augmented PATH to None. The donor's GOOSE_SEARCH_PATHS pinning is deliberately not ported — Buzz spawns bridges via its own buzz-acp harness, which takes the resolved command from BUZZ_ACP_AGENT_COMMAND and the augmented PATH. The discovery.rs file-size ratchet is bumped 1171 -> 1178 for the new sweep check; the codex version-gate retirement later in this series shrinks the file and ratchets it back down. Ports block/builderbot apps/staged 55eb2772 (resolve bundled ACP tools at runtime) and afa524e7 (best-effort PATH join), themselves ports of squareup/berd 2add3727 and bb912c96. Verification: - cargo test --manifest-path desktop/src-tauri/Cargo.toml --lib: 1382 passed, 0 failed, 11 ignored (14 new tests cover env-override precedence, bare-name-only bundled resolution, executable checks, bundled-dir PATH priority, and un-joinable entry handling) - cargo clippy --all-targets: clean; cargo fmt --check: clean Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/scripts/check-file-sizes.mjs | 5 +- desktop/src-tauri/src/lib.rs | 6 + .../src-tauri/src/managed_agents/acp_tools.rs | 162 ++++++++++++++++++ .../src-tauri/src/managed_agents/discovery.rs | 13 +- desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src/managed_agents/readiness/cli_probe.rs | 4 +- .../src-tauri/src/managed_agents/runtime.rs | 2 + .../src/managed_agents/runtime/path.rs | 110 +++++++++++- 8 files changed, 290 insertions(+), 13 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/acp_tools.rs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index cf2ea09cf2..cce1f081e6 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -261,7 +261,10 @@ const overrides = new Map([ // + updated adapter_availability_cached() signature (Option return, cold=None) // prevents false restart badge on newly restarted agents. Correctness fix; // load-bearing — required by Thufir's IMPORTANT findings. (+15 lines) - ["src-tauri/src/managed_agents/discovery.rs", 1245], + // bundle-acps: bundled ACP bridge check at the top of the resolution sweep + // (+7 lines). Temporary — the codex version-gate retirement later in the + // same series deletes far more from this file and ratchets this back down. + ["src-tauri/src/managed_agents/discovery.rs", 1252], // rebase over codex-acp-package-swap: its version-probe tests union with the // doctor-install-reliability nvm/login-shell/semver tests — each side alone // stayed under the 1000 default; the union exceeds it. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a60fe7326c..43761fd81d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -420,6 +420,12 @@ pub fn run() { let app_handle = app.handle().clone(); let shutdown_started = Arc::clone(&restore_shutdown_started); + // Register the bundled ACP bridge tools dir before anything can + // resolve agent commands — resolutions are cached for the app + // lifetime, so a resolve that runs before registration would pin + // the user-installed copy instead of the bundled one. + managed_agents::acp_tools::register_bundled_acp_tools_dir(&app_handle); + // Run all pre-identity data migrations before state loads from disk. migration::run_boot_migrations(&app_handle); diff --git a/desktop/src-tauri/src/managed_agents/acp_tools.rs b/desktop/src-tauri/src/managed_agents/acp_tools.rs new file mode 100644 index 0000000000..e7b4e444b6 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/acp_tools.rs @@ -0,0 +1,162 @@ +//! Bundled ACP bridge tool resolution. +//! +//! Buzz ships pinned ACP bridge CLIs (`claude-agent-acp`, `codex-acp`) as +//! Tauri application resources (see `desktop/acp-tools.lock.json` and +//! `desktop/scripts/prepare-acp-tools-resource.sh`). This module resolves the +//! staged bin directory once at app setup so the command resolution sweep and +//! the spawn-time PATH augmentation both prefer the bundled bridges over +//! user-installed copies, while everything else on the user's PATH (including +//! installed harness CLIs and their auth state) stays discoverable. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use tauri::path::BaseDirectory; +use tauri::Manager; + +use super::discovery::{command_looks_like_path, executable_basename, is_executable_file}; + +/// Dev-mode override exported by `just dev` / `just staging`, pointing at the +/// freshly staged `src-tauri/resources/acp/bin` in the working tree. +pub const ACP_TOOLS_DIR_ENV: &str = "BUZZ_ACP_TOOLS_DIR"; +/// Bundled resource path, relative to the Tauri resource dir (mirrors the +/// `resources/acp` entry in `tauri.conf.json`). +const ACP_TOOLS_RESOURCE_DIR: &str = "resources/acp/bin"; + +static BUNDLED_ACP_TOOLS_DIR: OnceLock> = OnceLock::new(); + +/// Resolve and register the bundled ACP tools bin dir for the app lifetime: +/// the dev env override wins, then the Tauri resource dir for packaged apps. +/// Called once during app setup, before anything can resolve agent commands. +pub fn register_bundled_acp_tools_dir(app_handle: &tauri::AppHandle) { + let resolved = bundled_acp_tools_dir_from_parts( + std::env::var_os(ACP_TOOLS_DIR_ENV).as_deref(), + app_handle + .path() + .resolve(ACP_TOOLS_RESOURCE_DIR, BaseDirectory::Resource) + .ok() + .as_deref(), + ); + let _ = BUNDLED_ACP_TOOLS_DIR.set(resolved); +} + +/// The registered bundled ACP tools bin dir, if the app ships one. `None` +/// until [`register_bundled_acp_tools_dir`] runs (e.g. in unit tests), so +/// every consumer degrades to the pre-bundling resolution order. +pub(crate) fn bundled_acp_tools_dir() -> Option { + BUNDLED_ACP_TOOLS_DIR.get().cloned().flatten() +} + +/// Resolve `command` inside the bundled tools dir. Bare command names only — +/// a path-like command (absolute or multi-component) names a specific binary +/// the user picked and must never be redirected into the bundle. +pub(in crate::managed_agents) fn command_in_bundled_dir(command: &str) -> Option { + command_in_dir(&bundled_acp_tools_dir()?, command) +} + +fn command_in_dir(dir: &Path, command: &str) -> Option { + if command_looks_like_path(command) { + return None; + } + let candidate = dir.join(executable_basename(command)); + is_executable_file(&candidate).then_some(candidate) +} + +fn bundled_acp_tools_dir_from_parts( + env_override: Option<&OsStr>, + resource_dir: Option<&Path>, +) -> Option { + if let Some(value) = env_override { + if !value.is_empty() { + return Some(PathBuf::from(value)); + } + } + resource_dir.map(Path::to_path_buf) +} + +#[cfg(test)] +mod tests { + use super::{bundled_acp_tools_dir_from_parts, command_in_dir}; + use std::ffi::OsStr; + use std::path::Path; + + #[test] + fn env_override_wins_over_resource_dir() { + assert_eq!( + bundled_acp_tools_dir_from_parts( + Some(OsStr::new("/dev/acp/bin")), + Some(Path::new("/bundle/resources/acp/bin")), + ) + .as_deref(), + Some(Path::new("/dev/acp/bin")), + ); + } + + #[test] + fn empty_env_override_falls_back_to_resource_dir() { + assert_eq!( + bundled_acp_tools_dir_from_parts( + Some(OsStr::new("")), + Some(Path::new("/bundle/resources/acp/bin")), + ) + .as_deref(), + Some(Path::new("/bundle/resources/acp/bin")), + ); + } + + #[test] + fn missing_inputs_resolve_to_none() { + assert!(bundled_acp_tools_dir_from_parts(None, None).is_none()); + } + + #[cfg(unix)] + #[test] + fn command_in_dir_finds_executable_by_bare_name() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let tool = temp.path().join("claude-agent-acp"); + fs::write(&tool, "#!/bin/sh\n").expect("write tool"); + fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).expect("chmod tool"); + + assert_eq!( + command_in_dir(temp.path(), "claude-agent-acp").as_deref(), + Some(tool.as_path()), + ); + assert!(command_in_dir(temp.path(), "codex-acp").is_none()); + } + + #[cfg(unix)] + #[test] + fn command_in_dir_rejects_path_like_commands() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let tool = temp.path().join("codex-acp"); + fs::write(&tool, "#!/bin/sh\n").expect("write tool"); + fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).expect("chmod tool"); + + // An absolute path joined onto the bundled dir would *replace* it + // (Path::join semantics) — path-like commands must pass through to + // the regular resolution order untouched. + assert!(command_in_dir(temp.path(), tool.to_str().expect("utf8")).is_none()); + assert!(command_in_dir(temp.path(), "custom/codex-acp").is_none()); + } + + #[cfg(unix)] + #[test] + fn command_in_dir_skips_non_executable_files() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let tool = temp.path().join("claude-agent-acp"); + fs::write(&tool, "not executable").expect("write tool"); + fs::set_permissions(&tool, fs::Permissions::from_mode(0o644)).expect("chmod tool"); + + assert!(command_in_dir(temp.path(), "claude-agent-acp").is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 2c4821a4c5..a6680373c2 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -227,12 +227,12 @@ fn workspace_root_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") } -fn command_looks_like_path(command: &str) -> bool { +pub(in crate::managed_agents) fn command_looks_like_path(command: &str) -> bool { let path = Path::new(command); path.is_absolute() || path.components().count() > 1 } -fn executable_basename(command: &str) -> String { +pub(in crate::managed_agents) fn executable_basename(command: &str) -> String { let suffix = std::env::consts::EXE_SUFFIX; if suffix.is_empty() || command.ends_with(suffix) { command.to_string() @@ -428,7 +428,7 @@ fn command_search_dirs() -> Vec { unique } -fn is_executable_file(path: &Path) -> bool { +pub(in crate::managed_agents) fn is_executable_file(path: &Path) -> bool { let Ok(metadata) = path.metadata() else { return false; }; @@ -568,6 +568,13 @@ pub(crate) fn availability_drift( } fn resolve_command_uncached(command: &str) -> Option { + // Bundled ACP bridge tools (see `managed_agents::acp_tools`) win over + // every other source, so the pinned bridges shipped with the app are + // preferred to user-installed copies. + if let Some(path) = super::acp_tools::command_in_bundled_dir(command) { + return Some(path); + } + if let Some(path) = resolve_workspace_command(command) { return Some(path); } diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index c8037f463c..6a21d50fd7 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod acp_tools; mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs index 8e2f866b7e..703f0d00f3 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs @@ -2,7 +2,8 @@ use std::path::Path; use crate::managed_agents::runtime::build_augmented_path; -/// Build the augmented PATH for CLI probes, including nvm's default Node.js +/// Build the augmented PATH for CLI probes, including the bundled ACP bridge +/// tools dir (pinned bridges shipped with the app) and nvm's default Node.js /// bin directory so `#!/usr/bin/env node` shims (e.g. codex-acp) resolve. pub(crate) fn augmented_path() -> Option { let home = dirs::home_dir(); @@ -10,6 +11,7 @@ pub(crate) fn augmented_path() -> Option { .as_deref() .and_then(crate::managed_agents::find_nvm_default_bin); build_augmented_path( + crate::managed_agents::acp_tools::bundled_acp_tools_dir(), home, std::env::current_exe() .ok() diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 44856c206a..b7f633b56f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1539,6 +1539,7 @@ pub fn spawn_agent_child( }; // Augment PATH for DMG launches so child processes can find: + // - bundled ACP bridge tools (pinned versions shipped with the app) // - bundled CLI via ~/.local/bin symlink // - nvm-managed node/npm (nvm initializes only in interactive shells) // - bundled sidecars (buzz, buzz-acp, etc.) via exe parent (Contents/MacOS/) @@ -1547,6 +1548,7 @@ pub fn spawn_agent_child( .as_deref() .and_then(super::find_nvm_default_bin); let augmented_path = build_augmented_path( + super::acp_tools::bundled_acp_tools_dir(), dirs::home_dir(), std::env::current_exe() .ok() diff --git a/desktop/src-tauri/src/managed_agents/runtime/path.rs b/desktop/src-tauri/src/managed_agents/runtime/path.rs index af2d2e96da..4db6e62dbd 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/path.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/path.rs @@ -5,10 +5,12 @@ use std::path::PathBuf; /// Assemble the augmented `PATH` for a launched managed-agent child process. /// /// Concatenates, in priority order: -/// 1. `/.local/bin` — bundled CLI symlink -/// 2. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm) -/// 3. exe parent dir — DMG sidecars under `Contents/MacOS/` -/// 4. user's login-shell `PATH` — runtimes like node/python from other managers +/// 1. `bundled_acp_bin` — bundled ACP bridge tools dir, so pinned bridges +/// shipped with the app win over user-installed copies +/// 2. `/.local/bin` — bundled CLI symlink +/// 3. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm) +/// 4. exe parent dir — DMG sidecars under `Contents/MacOS/` +/// 5. user's login-shell `PATH` — runtimes like node/python from other managers /// /// `shell_path` is the raw colon-delimited string from a login shell, so it is /// split into individual entries before joining. Pushing it as a single segment @@ -17,12 +19,16 @@ use std::path::PathBuf; /// guards against, which left managed agents unable to find `buzz`. Returns /// `None` only when no entries exist. pub(in crate::managed_agents) fn build_augmented_path( + bundled_acp_bin: Option, home: Option, exe_parent: Option, shell_path: Option, nvm_bin: Option, ) -> Option { let mut parts: Vec = Vec::new(); + if let Some(bundled) = bundled_acp_bin { + parts.push(bundled); + } if let Some(home) = home { parts.push(home.join(".local").join("bin")); } @@ -35,11 +41,37 @@ pub(in crate::managed_agents) fn build_augmented_path( if let Some(shell_path) = shell_path { parts.extend(std::env::split_paths(&shell_path)); } - if parts.is_empty() { + join_paths_best_effort(parts) +} + +/// Join PATH entries, degrading to a best-effort join when a single entry +/// embeds the platform separator (legal in macOS paths): `join_paths` rejects +/// the whole list for one such entry, which would collapse the entire +/// augmented `PATH` to `None` and hand child processes a bare GUI PATH. Drop +/// the un-joinable entries (logging each) instead of erasing every search +/// path. Returns `None` only when no joinable entries exist. +fn join_paths_best_effort(mut paths: Vec) -> Option { + if paths.is_empty() { return None; } // join_paths uses the platform separator (':' on Unix, ';' on Windows). - std::env::join_paths(parts) + if let Ok(joined) = std::env::join_paths(&paths) { + return Some(joined.to_string_lossy().into_owned()); + } + paths.retain(|path| { + let joinable = std::env::join_paths(std::iter::once(path)).is_ok(); + if !joinable { + eprintln!( + "buzz-desktop: dropping un-joinable PATH entry: {}", + path.display() + ); + } + joinable + }); + if paths.is_empty() { + return None; + } + std::env::join_paths(paths) .ok() .map(|s| s.to_string_lossy().into_owned()) } @@ -57,6 +89,7 @@ mod tests { // it and the whole augmented PATH collapses to None (managed agents then // lose `buzz`). let result = build_augmented_path( + None, Some(PathBuf::from("/home/agent")), Some(PathBuf::from("/Applications/Buzz.app/Contents/MacOS")), Some("/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin".to_string()), @@ -73,20 +106,46 @@ mod tests { #[test] fn none_when_no_inputs() { - assert_eq!(build_augmented_path(None, None, None, None), None); + assert_eq!(build_augmented_path(None, None, None, None, None), None); } #[cfg(unix)] #[test] fn shell_path_only() { - let result = build_augmented_path(None, None, Some("/usr/bin:/bin".to_string()), None); + let result = + build_augmented_path(None, None, None, Some("/usr/bin:/bin".to_string()), None); assert_eq!(result.as_deref(), Some("/usr/bin:/bin")); } + #[cfg(unix)] + #[test] + fn bundled_acp_bin_is_highest_priority_segment() { + let result = build_augmented_path( + Some(PathBuf::from( + "/Applications/Buzz.app/Contents/Resources/resources/acp/bin", + )), + Some(PathBuf::from("/home/user")), + Some(PathBuf::from("/Applications/Buzz.app/Contents/MacOS")), + Some("/usr/bin:/bin".to_string()), + Some(PathBuf::from("/home/user/.nvm/versions/node/v20.0.0/bin")), + ); + assert_eq!( + result.as_deref(), + Some( + "/Applications/Buzz.app/Contents/Resources/resources/acp/bin:\ +/home/user/.local/bin:\ +/home/user/.nvm/versions/node/v20.0.0/bin:\ +/Applications/Buzz.app/Contents/MacOS:\ +/usr/bin:/bin" + ), + ); + } + #[cfg(unix)] #[test] fn nvm_bin_inserted_after_local_bin_before_exe_parent() { let result = build_augmented_path( + None, Some(PathBuf::from("/home/user")), Some(PathBuf::from("/Applications/Buzz.app/Contents/MacOS")), Some("/usr/bin:/bin".to_string()), @@ -107,6 +166,7 @@ mod tests { #[test] fn nvm_bin_none_does_not_add_segment() { let result = build_augmented_path( + None, Some(PathBuf::from("/home/user")), Some(PathBuf::from("/usr/local/bin")), None, @@ -117,4 +177,38 @@ mod tests { Some("/home/user/.local/bin:/usr/local/bin"), ); } + + #[cfg(unix)] + #[test] + fn unjoinable_entry_is_dropped_instead_of_emptying_path() { + // A dir embedding the separator (legal in macOS paths) can't be joined + // into PATH; it must be dropped, not collapse the whole augmented PATH + // to None. + let result = build_augmented_path( + Some(PathBuf::from("/weird:dir/bin")), + None, + None, + Some("/shell/bin:/user/bin".to_string()), + None, + ); + let path = result.expect("PATH should survive an un-joinable entry"); + let paths: Vec<_> = std::env::split_paths(&path).collect(); + assert_eq!( + paths, + vec![PathBuf::from("/shell/bin"), PathBuf::from("/user/bin")] + ); + } + + #[cfg(unix)] + #[test] + fn none_when_all_entries_unjoinable() { + let result = build_augmented_path( + Some(PathBuf::from("/weird:dir/bin")), + None, + None, + None, + None, + ); + assert_eq!(result, None); + } } From d59db7f1df3fd15d5cffcb7c1b341bae2ed0f533 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 13 Jul 2026 13:35:59 +1000 Subject: [PATCH 03/18] feat(desktop): surface bundled-bridge Node.js requirement in Doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled ACP bridges are shell shims that exec node; without a suitable Node.js on the spawn PATH the first agent session dies with a bare exit 127. Surface the requirement in the Doctor panel instead: - New managed_agents::node_runtime module reads the staged resources/acp/node-runtime.json manifest (one entry per npm-sourced bridge, each with its own required Node major), resolves node from the same augmented PATH the agent spawn and CLI auth probes use (so the check cannot disagree with what the wrapper shims find at spawn time), probes `node -p process.versions.node` with a 10s timeout, and reports pass/warn with a per-bridge satisfied/unmet/unknown verdict list. A missing manifest keeps the check silent (no npm-sourced bridges bundled); an unreadable one warns instead of hiding a packaging break. - New check_acp_node_runtime Tauri command plus a Doctor panel section (message, node path, per-bridge requirements, Install Node.js fix link on warn) that the Re-run button refreshes alongside the runtime rows. The e2e mock bridge grows a nodeRuntimeCheck fixture knob. - tokio gains the "process" feature for the async node probe. Readiness gating for bundled bridges needs no code here: Buzz's classify_runtime already reports adapter_missing when find_command (which prefers the bundle since the previous commit) cannot resolve the bridge, and cli_login_requirements resolves the adapter the same way — so a broken bundle reads not_installed instead of ready. This is the behaviour squareup/berd 17c5e9e5 had to add explicitly. Ports block/builderbot apps/staged adea4017 (node-runtime doctor manifest), itself a port of squareup/berd 07087303. Verification: - cargo test --manifest-path desktop/src-tauri/Cargo.toml --lib: 1392 passed, 0 failed, 11 ignored (10 new tests: version parsing, requirement labels, all four check states, manifest missing/invalid/ loaded including the exact staging-script JSON shape) - cargo clippy --all-targets: clean; cargo fmt --check: clean - desktop: tsc --noEmit clean; biome clean; pnpm test 2744 passed - playwright doctor-states.spec.ts (smoke): 8 passed, including new 06-node-runtime-pass / 07-node-runtime-warn / 08-node-runtime-hidden-when-not-bundled Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/scripts/check-file-sizes.mjs | 15 +- desktop/src-tauri/Cargo.toml | 2 +- .../src-tauri/src/commands/agent_discovery.rs | 9 + desktop/src-tauri/src/lib.rs | 1 + .../src-tauri/src/managed_agents/acp_tools.rs | 22 + desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src/managed_agents/node_runtime.rs | 484 ++++++++++++++++++ desktop/src/features/agents/hooks.ts | 11 + .../settings/ui/DoctorSettingsPanel.tsx | 82 ++- desktop/src/shared/api/tauri.ts | 35 ++ desktop/src/shared/api/types.ts | 23 + desktop/src/testing/e2eBridge.ts | 6 + desktop/tests/e2e/doctor-states.spec.ts | 118 +++++ 13 files changed, 803 insertions(+), 6 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/node_runtime.rs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index cce1f081e6..74fcd0ace4 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -196,7 +196,11 @@ const overrides = new Map([ // RawInstallRuntimeResult + fromRawInstallRuntimeResult mapper (+2). // Git Bash Doctor discovery adds the raw Tauri response and its camelCase // mapper. This is the existing API boundary; split remains queued. - ["src/shared/api/tauri.ts", 1304], + // bundle-acps: RawNodeRuntimeCheck type + fromRawNodeRuntimeCheck mapper + + // checkAcpNodeRuntime wrapper for the bundled-bridge Node.js doctor check + // (+35 lines on rebase union with Git Bash Doctor discovery). Queued to + // split with the rest of this file. + ["src/shared/api/tauri.ts", 1339], // doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line). // codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line). // doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/ @@ -206,7 +210,10 @@ const overrides = new Map([ // mcp-readonly-view rebase: PR2 MCP config surface FE-type fields force +1 over the grandfathered ceiling. // Git Bash prerequisite payload adds four fields to the shared Tauri API // contract. This is the canonical type location; split remains queued. - ["src/shared/api/types.ts", 1038], + // bundle-acps: NodeRuntimeCheck + NodeRuntimeRequirement types for the + // bundled-bridge Node.js doctor check (+23 lines on rebase union with + // main's Git Bash prerequisite type growth). + ["src/shared/api/types.ts", 1061], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog // shows required markers and credential amber rows (parity with @@ -398,7 +405,9 @@ const overrides = new Map([ // Git Bash Doctor discovery exposes a narrow async Tauri command at the // existing discovery boundary. The ten-line addition preserves the platform // neutral frontend contract; split remains queued. - ["src-tauri/src/commands/agent_discovery.rs", 1357], + // bundle-acps: node runtime check wiring for the bundled-bridge Doctor + // requirement (+9 lines on rebase union with Git Bash Doctor discovery). + ["src-tauri/src/commands/agent_discovery.rs", 1366], // draft-persistence predicate: submit-time `loadDraft` check + inline comment // + deps-array entry in submitMessage closes the never-persisted-boundary // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 2824f40271..0e19e72f16 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -66,7 +66,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" infer = "0.19" hex = "0.4" -tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time"] } +tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time", "process"] } tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } tokio-util = { version = "0.7", features = ["rt"] } bytes = "1" diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 3ddd226ad0..0d63b9a7e9 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -69,6 +69,15 @@ pub async fn discover_acp_providers() -> Result, Str .map_err(|e| format!("spawn_blocking failed: {e}")) } +/// Doctor check for the bundled ACP bridges' Node.js runtime requirement. +/// `None` when the app bundles no npm-sourced bridges — the Doctor panel +/// hides the section entirely. +#[tauri::command] +pub async fn check_acp_node_runtime( +) -> Option { + crate::managed_agents::node_runtime::run_node_runtime_check().await +} + #[tauri::command] pub async fn install_acp_runtime( runtime_id: String, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 43761fd81d..ae4d117acf 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -704,6 +704,7 @@ pub fn run() { fetch_link_preview_title, discover_acp_providers, discover_git_bash_prerequisite, + check_acp_node_runtime, install_acp_runtime, discover_managed_agent_prereqs, sign_event, diff --git a/desktop/src-tauri/src/managed_agents/acp_tools.rs b/desktop/src-tauri/src/managed_agents/acp_tools.rs index e7b4e444b6..ca15f4ce29 100644 --- a/desktop/src-tauri/src/managed_agents/acp_tools.rs +++ b/desktop/src-tauri/src/managed_agents/acp_tools.rs @@ -23,6 +23,9 @@ pub const ACP_TOOLS_DIR_ENV: &str = "BUZZ_ACP_TOOLS_DIR"; /// Bundled resource path, relative to the Tauri resource dir (mirrors the /// `resources/acp` entry in `tauri.conf.json`). const ACP_TOOLS_RESOURCE_DIR: &str = "resources/acp/bin"; +/// Node runtime manifest staged by `desktop/scripts/prepare-acp-tools-resource.sh` +/// next to the bundled bin dir. +const NODE_RUNTIME_MANIFEST_FILE: &str = "node-runtime.json"; static BUNDLED_ACP_TOOLS_DIR: OnceLock> = OnceLock::new(); @@ -55,6 +58,16 @@ pub(in crate::managed_agents) fn command_in_bundled_dir(command: &str) -> Option command_in_dir(&bundled_acp_tools_dir()?, command) } +/// Path of the Node runtime manifest staged by +/// `desktop/scripts/prepare-acp-tools-resource.sh`: it lives next to the +/// tools bin dir (`acp/node-runtime.json` beside `acp/bin`), so it resolves +/// for both the bundled resource dir and a `BUZZ_ACP_TOOLS_DIR` dev override. +pub(in crate::managed_agents) fn node_runtime_manifest_path(bin_dir: &Path) -> Option { + bin_dir + .parent() + .map(|dir| dir.join(NODE_RUNTIME_MANIFEST_FILE)) +} + fn command_in_dir(dir: &Path, command: &str) -> Option { if command_looks_like_path(command) { return None; @@ -110,6 +123,15 @@ mod tests { assert!(bundled_acp_tools_dir_from_parts(None, None).is_none()); } + #[test] + fn node_runtime_manifest_sits_beside_bin_dir() { + assert_eq!( + super::node_runtime_manifest_path(Path::new("/bundle/resources/acp/bin")).as_deref(), + Some(Path::new("/bundle/resources/acp/node-runtime.json")), + ); + assert!(super::node_runtime_manifest_path(Path::new("/")).is_none()); + } + #[cfg(unix)] #[test] fn command_in_dir_finds_executable_by_bare_name() { diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 6a21d50fd7..47caf33f84 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -13,6 +13,7 @@ mod env_vars; mod git_bash; pub(crate) mod global_config; mod nest; +pub(crate) mod node_runtime; mod persona_avatars; mod persona_card; pub(crate) mod persona_events; diff --git a/desktop/src-tauri/src/managed_agents/node_runtime.rs b/desktop/src-tauri/src/managed_agents/node_runtime.rs new file mode 100644 index 0000000000..59bf776536 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/node_runtime.rs @@ -0,0 +1,484 @@ +//! Doctor check for the bundled ACP bridges' Node.js runtime requirement. +//! +//! The bundled bridges are shell shims that `exec node` (see +//! `desktop/scripts/lib/acp-node-wrapper.sh`); without a suitable Node.js on +//! the spawn PATH the first agent session dies with a bare exit 127. This +//! check surfaces the requirement in the Doctor panel ahead of time. The +//! manifest (`resources/acp/node-runtime.json`) is written at staging time by +//! `desktop/scripts/prepare-acp-tools-resource.sh`, one entry per npm-sourced +//! bridge, each carrying its own required Node major. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +const NODE_RUNTIME_FIX_URL: &str = "https://nodejs.org/en/download"; +const NODE_PROBE_TIMEOUT: Duration = Duration::from_secs(10); + +/// On-disk shape of `resources/acp/node-runtime.json`. Each tool carries its +/// own required Node major so bridges with different engine ranges are +/// checked independently. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NodeRuntimeManifest { + #[serde(default)] + tools: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NodeRuntimeTool { + binary: String, + #[serde(default)] + node_engine: Option, + required_node_major: u32, +} + +impl NodeRuntimeTool { + fn requirement_label(&self) -> String { + self.node_engine + .clone() + .unwrap_or_else(|| format!(">={}", self.required_node_major)) + } +} + +/// Wire type for the Doctor panel (snake_case JSON like the rest of the +/// commands surface). +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct NodeRuntimeCheck { + pub status: NodeRuntimeCheckStatus, + pub message: String, + pub manifest_path: String, + pub node_path: Option, + pub node_version: Option, + pub requirements: Vec, + pub fix_url: String, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum NodeRuntimeCheckStatus { + Pass, + Warn, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct NodeRuntimeRequirement { + pub binary: String, + pub requirement: String, + pub verdict: NodeRequirementVerdict, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum NodeRequirementVerdict { + Satisfied, + Unmet, + Unknown, +} + +enum NodeRuntimeManifestState { + /// No manifest next to the bundled tools dir: no npm-sourced bridges are + /// bundled, so the check stays silent. + Missing, + Invalid { + path: PathBuf, + error: String, + }, + Loaded { + path: PathBuf, + manifest: NodeRuntimeManifest, + }, +} + +fn load_node_runtime_manifest(bundled_bin_dir: Option<&Path>) -> NodeRuntimeManifestState { + let Some(path) = bundled_bin_dir.and_then(super::acp_tools::node_runtime_manifest_path) else { + return NodeRuntimeManifestState::Missing; + }; + let contents = match std::fs::read(&path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return NodeRuntimeManifestState::Missing; + } + Err(error) => { + return NodeRuntimeManifestState::Invalid { + path, + error: format!("failed to read manifest: {error}"), + }; + } + }; + match serde_json::from_slice::(&contents) { + Ok(manifest) => NodeRuntimeManifestState::Loaded { path, manifest }, + Err(error) => NodeRuntimeManifestState::Invalid { + path, + error: format!("failed to parse manifest JSON: {error}"), + }, + } +} + +/// Surface the bundled ACP bridges' Node.js runtime requirement in the Doctor +/// panel instead of letting the first session spawn die with a bare exit 127. +/// Returns `None` when no npm-sourced bridges are bundled; an unreadable +/// manifest warns instead of silently hiding a packaging break. +pub(crate) async fn run_node_runtime_check() -> Option { + let bundled_dir = super::acp_tools::bundled_acp_tools_dir(); + let (manifest_path, manifest) = match load_node_runtime_manifest(bundled_dir.as_deref()) { + NodeRuntimeManifestState::Missing => return None, + NodeRuntimeManifestState::Invalid { path, error } => { + return Some(NodeRuntimeCheck { + status: NodeRuntimeCheckStatus::Warn, + message: format!( + "Bundled ACP bridge Node.js manifest is unreadable; bridge runtime \ + requirements cannot be verified ({error})" + ), + manifest_path: path.display().to_string(), + node_path: None, + node_version: None, + requirements: Vec::new(), + fix_url: NODE_RUNTIME_FIX_URL.to_string(), + }); + } + NodeRuntimeManifestState::Loaded { path, manifest } => (path, manifest), + }; + if manifest.tools.is_empty() { + return None; + } + + // Resolve node from the same augmented PATH the agent spawn and the CLI + // auth probes use, so this check cannot disagree with what the bundled + // wrapper shims will find at spawn time. When no augmented PATH can be + // built, spawned children inherit the process PATH — mirror that too. + let path_value = super::readiness::cli_probe::augmented_path() + .or_else(|| std::env::var("PATH").ok()) + .unwrap_or_default(); + let node_path = resolve_node_from_path_value(&path_value); + let node_version = match node_path.as_deref() { + Some(path) => query_node_version(path).await, + None => None, + }; + + Some(build_node_runtime_check( + &manifest_path, + &manifest.tools, + node_path, + node_version, + )) +} + +fn resolve_node_from_path_value(path_value: &str) -> Option { + let file_name = super::discovery::executable_basename("node"); + std::env::split_paths(path_value) + .map(|dir| dir.join(&file_name)) + .find(|candidate| super::discovery::is_executable_file(candidate)) + .map(|path| path.display().to_string()) +} + +async fn query_node_version(node_path: &str) -> Option { + let mut command = tokio::process::Command::new(node_path); + command + .args(["-p", "process.versions.node"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let output = tokio::time::timeout(NODE_PROBE_TIMEOUT, command.output()) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(String::from) +} + +fn parse_node_major(version: &str) -> Option { + version + .trim() + .trim_start_matches('v') + .split('.') + .next()? + .parse() + .ok() +} + +fn node_requirement_summary<'a>(tools: impl IntoIterator) -> String { + tools + .into_iter() + .map(|tool| format!("{} needs Node.js {}", tool.binary, tool.requirement_label())) + .collect::>() + .join(", ") +} + +fn build_node_runtime_check( + manifest_path: &Path, + tools: &[NodeRuntimeTool], + node_path: Option, + node_version: Option, +) -> NodeRuntimeCheck { + let node_major = node_version.as_deref().and_then(parse_node_major); + let unmet: Vec<&NodeRuntimeTool> = match node_major { + Some(major) => tools + .iter() + .filter(|tool| major < tool.required_node_major) + .collect(), + None => Vec::new(), + }; + + let (status, message) = if node_path.is_none() { + ( + NodeRuntimeCheckStatus::Warn, + format!( + "Node.js was not found on PATH; bundled ACP bridges require it ({})", + node_requirement_summary(tools) + ), + ) + } else if node_major.is_none() { + ( + NodeRuntimeCheckStatus::Warn, + format!( + "Could not determine the Node.js version; bundled ACP bridges require it ({})", + node_requirement_summary(tools) + ), + ) + } else if unmet.is_empty() { + ( + NodeRuntimeCheckStatus::Pass, + format!( + "Node.js {} satisfies the bundled ACP bridge requirements", + node_version.as_deref().unwrap_or("unknown") + ), + ) + } else { + ( + NodeRuntimeCheckStatus::Warn, + format!( + "Node.js {} is too old for bundled ACP bridges: {}", + node_version.as_deref().unwrap_or("unknown"), + node_requirement_summary(unmet.iter().copied()) + ), + ) + }; + + let requirements = tools + .iter() + .map(|tool| NodeRuntimeRequirement { + binary: tool.binary.clone(), + requirement: tool.requirement_label(), + verdict: match node_major { + Some(major) if major >= tool.required_node_major => { + NodeRequirementVerdict::Satisfied + } + Some(_) => NodeRequirementVerdict::Unmet, + None => NodeRequirementVerdict::Unknown, + }, + }) + .collect(); + + NodeRuntimeCheck { + status, + message, + manifest_path: manifest_path.display().to_string(), + node_path, + node_version, + requirements, + fix_url: NODE_RUNTIME_FIX_URL.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::{ + build_node_runtime_check, load_node_runtime_manifest, parse_node_major, + NodeRequirementVerdict, NodeRuntimeCheckStatus, NodeRuntimeManifestState, NodeRuntimeTool, + }; + use std::path::Path; + + fn tool(binary: &str, node_engine: Option<&str>, required_node_major: u32) -> NodeRuntimeTool { + NodeRuntimeTool { + binary: binary.to_string(), + node_engine: node_engine.map(str::to_string), + required_node_major, + } + } + + #[test] + fn parse_node_major_handles_plain_and_v_prefixed_versions() { + assert_eq!(parse_node_major("22.11.0"), Some(22)); + assert_eq!(parse_node_major("v20.1.2"), Some(20)); + assert_eq!(parse_node_major(" 18.0.0 \n"), Some(18)); + assert_eq!(parse_node_major("not-a-version"), None); + assert_eq!(parse_node_major(""), None); + } + + #[test] + fn requirement_label_falls_back_to_required_major() { + assert_eq!(tool("codex-acp", None, 22).requirement_label(), ">=22"); + assert_eq!( + tool("claude-agent-acp", Some(">=18"), 18).requirement_label(), + ">=18" + ); + } + + #[test] + fn node_missing_warns_with_unknown_verdicts() { + let tools = vec![tool("claude-agent-acp", Some(">=18"), 18)]; + let check = + build_node_runtime_check(Path::new("/acp/node-runtime.json"), &tools, None, None); + assert_eq!(check.status, NodeRuntimeCheckStatus::Warn); + assert!( + check.message.contains("not found on PATH"), + "{}", + check.message + ); + assert!(check + .message + .contains("claude-agent-acp needs Node.js >=18")); + assert_eq!(check.requirements.len(), 1); + assert_eq!( + check.requirements[0].verdict, + NodeRequirementVerdict::Unknown + ); + } + + #[test] + fn unparseable_version_warns() { + let tools = vec![tool("codex-acp", None, 20)]; + let check = build_node_runtime_check( + Path::new("/acp/node-runtime.json"), + &tools, + Some("/usr/local/bin/node".to_string()), + Some("garbage".to_string()), + ); + assert_eq!(check.status, NodeRuntimeCheckStatus::Warn); + assert!( + check.message.contains("Could not determine"), + "{}", + check.message + ); + assert_eq!( + check.requirements[0].verdict, + NodeRequirementVerdict::Unknown + ); + } + + #[test] + fn old_node_warns_listing_only_unmet_tools() { + let tools = vec![ + tool("claude-agent-acp", Some(">=18"), 18), + tool("codex-acp", Some(">=22"), 22), + ]; + let check = build_node_runtime_check( + Path::new("/acp/node-runtime.json"), + &tools, + Some("/usr/local/bin/node".to_string()), + Some("20.10.0".to_string()), + ); + assert_eq!(check.status, NodeRuntimeCheckStatus::Warn); + assert!( + check.message.contains("codex-acp needs Node.js >=22"), + "{}", + check.message + ); + assert!( + !check.message.contains("claude-agent-acp"), + "satisfied tools must not appear in the warn summary: {}", + check.message + ); + assert_eq!( + check.requirements[0].verdict, + NodeRequirementVerdict::Satisfied + ); + assert_eq!(check.requirements[1].verdict, NodeRequirementVerdict::Unmet); + } + + #[test] + fn new_enough_node_passes() { + let tools = vec![ + tool("claude-agent-acp", Some(">=18"), 18), + tool("codex-acp", Some(">=22"), 22), + ]; + let check = build_node_runtime_check( + Path::new("/acp/node-runtime.json"), + &tools, + Some("/usr/local/bin/node".to_string()), + Some("22.11.0".to_string()), + ); + assert_eq!(check.status, NodeRuntimeCheckStatus::Pass); + assert!(check + .requirements + .iter() + .all(|req| req.verdict == NodeRequirementVerdict::Satisfied),); + } + + #[test] + fn manifest_missing_when_no_bin_dir_or_file() { + assert!(matches!( + load_node_runtime_manifest(None), + NodeRuntimeManifestState::Missing + )); + let temp = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp.path().join("bin"); + std::fs::create_dir_all(&bin_dir).expect("bin dir"); + assert!(matches!( + load_node_runtime_manifest(Some(&bin_dir)), + NodeRuntimeManifestState::Missing + )); + } + + #[test] + fn manifest_invalid_when_unparseable() { + let temp = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp.path().join("bin"); + std::fs::create_dir_all(&bin_dir).expect("bin dir"); + std::fs::write(temp.path().join("node-runtime.json"), "not json").expect("write manifest"); + let NodeRuntimeManifestState::Invalid { error, .. } = + load_node_runtime_manifest(Some(&bin_dir)) + else { + panic!("expected Invalid state"); + }; + assert!(error.contains("parse"), "{error}"); + } + + #[test] + fn manifest_loads_the_staging_script_shape() { + // Mirrors the exact JSON prepare-acp-tools-resource.sh writes. + let temp = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp.path().join("bin"); + std::fs::create_dir_all(&bin_dir).expect("bin dir"); + std::fs::write( + temp.path().join("node-runtime.json"), + r#"{ + "tools": [ + { + "id": "claude-agent-acp", + "binary": "claude-agent-acp", + "nodeEngine": ">=18.0.0", + "requiredNodeMajor": 18 + }, + { + "id": "codex-acp", + "binary": "codex-acp", + "nodeEngine": ">=22", + "requiredNodeMajor": 22 + } + ] +} +"#, + ) + .expect("write manifest"); + let NodeRuntimeManifestState::Loaded { manifest, .. } = + load_node_runtime_manifest(Some(&bin_dir)) + else { + panic!("expected Loaded state"); + }; + assert_eq!(manifest.tools.len(), 2); + assert_eq!(manifest.tools[0].binary, "claude-agent-acp"); + assert_eq!(manifest.tools[0].requirement_label(), ">=18.0.0"); + assert_eq!(manifest.tools[1].required_node_major, 22); + } +} diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 93edb30d12..cd2bd81567 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -10,6 +10,7 @@ import { channelsQueryKey } from "@/features/channels/hooks"; import { resolveSnapshotAvatarPng } from "@/features/agents/ui/snapshotAvatarPng"; import { evictUsersBatchEntries } from "@/features/profile/hooks"; import { + checkAcpNodeRuntime, createManagedAgent, deleteManagedAgent, discoverAcpRuntimes, @@ -91,6 +92,7 @@ export const managedAgentsQueryKey = ["managed-agents"] as const; export const personasQueryKey = ["personas"] as const; export const teamsQueryKey = ["teams"] as const; export const acpRuntimesQueryKey = ["acp-runtimes"] as const; +export const nodeRuntimeCheckQueryKey = ["node-runtime-check"] as const; export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const; export const backendProvidersQueryKey = ["backend-providers"] as const; export const gitBashPrerequisiteQueryKey = ["git-bash-prerequisite"] as const; @@ -148,6 +150,15 @@ export function useAcpRuntimesQuery(options?: { enabled?: boolean }) { }); } +export function useNodeRuntimeCheckQuery(options?: { enabled?: boolean }) { + return useQuery({ + enabled: options?.enabled ?? true, + queryKey: nodeRuntimeCheckQueryKey, + queryFn: checkAcpNodeRuntime, + staleTime: 60_000, + }); +} + export function useAvailableAcpRuntimes(options?: { enabled?: boolean }) { const query = useAcpRuntimesQuery(options); const available = React.useMemo( diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index b588fdc5ac..84501ea7cd 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -14,9 +14,14 @@ import { useAcpRuntimesQuery, useInstallAcpRuntimeMutation, useGitBashPrerequisiteQuery, + useNodeRuntimeCheckQuery, } from "@/features/agents/hooks"; import { describeResolvedCommand } from "@/features/agents/ui/agentUi"; -import type { AcpRuntimeCatalogEntry, AuthStatus } from "@/shared/api/types"; +import type { + AcpRuntimeCatalogEntry, + AuthStatus, + NodeRuntimeCheck, +} from "@/shared/api/types"; import { getInstallErrorMessage } from "@/shared/lib/installError"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -408,11 +413,79 @@ function GitBashRow({ ); } +/** + * Node.js runtime requirement of the bundled ACP bridges (shell shims that + * `exec node`). Rendered only when the app ships npm-sourced bridges — the + * check query returns null otherwise. + */ +function NodeRuntimeSection({ check }: { check: NodeRuntimeCheck }) { + return ( +
+
+ {check.status === "pass" ? ( + + ) : ( + + )} +
+ +
+

Node.js runtime

+

+ {check.message}. +

+ {check.nodePath ? ( +

+ {check.nodePath} +

+ ) : null} + {check.requirements.length > 0 ? ( +
    + {check.requirements.map((req) => ( +
  • + + {req.binary} + {" "} + requires Node.js {req.requirement} —{" "} + {req.verdict === "satisfied" + ? "satisfied" + : req.verdict === "unmet" + ? "unmet" + : "unknown"} +
  • + ))} +
+ ) : null} + {check.status === "warn" ? ( + + ) : null} +
+
+ ); +} + export function DoctorSettingsPanel() { const runtimesQuery = useAcpRuntimesQuery(); const gitBashQuery = useGitBashPrerequisiteQuery(); const runtimes = runtimesQuery.data ?? []; - const isRefreshing = runtimesQuery.isFetching; + const nodeRuntimeQuery = useNodeRuntimeCheckQuery(); + const isRefreshing = runtimesQuery.isFetching || nodeRuntimeQuery.isFetching; const installMutation = useInstallAcpRuntimeMutation(); const [installResults, setInstallResults] = React.useState< Record @@ -479,6 +552,7 @@ export function DoctorSettingsPanel() { setInstallResults({}); void runtimesQuery.refetch(); void gitBashQuery.refetch(); + void nodeRuntimeQuery.refetch(); }} size="sm" type="button" @@ -534,6 +608,10 @@ export function DoctorSettingsPanel() { )} + {nodeRuntimeQuery.data ? ( + + ) : null} + {runtimesQuery.error instanceof Error ? (

{runtimesQuery.error.message} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 1779e974fc..a97db6b418 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -41,6 +41,8 @@ import type { CommandAvailability, InstallRuntimeResult, GitBashPrerequisite, + NodeRuntimeCheck, + NodeRuntimeRequirementVerdict, OpenDmInput, RuntimeConfigSurface, } from "@/shared/api/types"; @@ -235,6 +237,20 @@ export type RawAcpRuntimeCatalogEntry = { login_hint?: string; }; +export type RawNodeRuntimeCheck = { + status: "pass" | "warn"; + message: string; + manifest_path: string; + node_path: string | null; + node_version: string | null; + requirements: { + binary: string; + requirement: string; + verdict: NodeRuntimeRequirementVerdict; + }[]; + fix_url: string; +}; + export type RawInstallStepResult = { step: string; command: string; @@ -915,6 +931,18 @@ function fromRawAcpRuntimeCatalogEntry( }; } +function fromRawNodeRuntimeCheck(raw: RawNodeRuntimeCheck): NodeRuntimeCheck { + return { + status: raw.status, + message: raw.message, + manifestPath: raw.manifest_path, + nodePath: raw.node_path, + nodeVersion: raw.node_version, + requirements: raw.requirements, + fixUrl: raw.fix_url, + }; +} + function fromRawInstallRuntimeResult( raw: RawInstallRuntimeResult, ): InstallRuntimeResult { @@ -1092,6 +1120,13 @@ export async function discoverAcpRuntimes(): Promise { ).map(fromRawAcpRuntimeCatalogEntry); } +export async function checkAcpNodeRuntime(): Promise { + const raw = await invokeTauri( + "check_acp_node_runtime", + ); + return raw ? fromRawNodeRuntimeCheck(raw) : null; +} + export async function installAcpRuntime( runtimeId: string, ): Promise { diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 3e3a9c69d8..eee5bae058 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -591,6 +591,29 @@ export type AcpRuntime = AcpRuntimeCatalogEntry & { binaryPath: string; }; +export type NodeRuntimeRequirementVerdict = "satisfied" | "unmet" | "unknown"; + +export type NodeRuntimeRequirement = { + binary: string; + requirement: string; + verdict: NodeRuntimeRequirementVerdict; +}; + +/** + * Doctor check for the bundled ACP bridges' Node.js runtime requirement + * (`check_acp_node_runtime`). Null when the app bundles no npm-sourced + * bridges — the Doctor panel hides the section entirely. + */ +export type NodeRuntimeCheck = { + status: "pass" | "warn"; + message: string; + manifestPath: string; + nodePath: string | null; + nodeVersion: string | null; + requirements: NodeRuntimeRequirement[]; + fixUrl: string; +}; + export type InstallStepResult = { step: string; command: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 41054deab2..19ec38a098 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -43,6 +43,7 @@ import { import type { RawAcpRuntimeCatalogEntry, RawInstallRuntimeResult, + RawNodeRuntimeCheck, } from "@/shared/api/tauri"; type TestIdentity = { @@ -109,6 +110,9 @@ type E2eConfig = { mock?: { acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[]; activePersonaIds?: string[]; + /** Bundled-bridge Node.js runtime doctor check; null (default) hides the + * Doctor panel section, matching an app with no npm-sourced bridges. */ + nodeRuntimeCheck?: RawNodeRuntimeCheck | null; installAcpRuntimeResult?: RawInstallRuntimeResult; /** Sequence of results for successive `install_acp_runtime` calls. * Call N returns results[N]; when exhausted the last entry repeats. @@ -8792,6 +8796,8 @@ export function maybeInstallE2eTauriMocks() { return getRelayHttpUrl(activeConfig); case "discover_acp_providers": return handleDiscoverAcpRuntimes(activeConfig); + case "check_acp_node_runtime": + return activeConfig?.mock?.nodeRuntimeCheck ?? null; case "install_acp_runtime": return handleInstallAcpRuntime( payload as { runtimeId?: string }, diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index a1aed04900..80043393fc 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -328,4 +328,122 @@ test.describe("Doctor panel state screenshots", () => { await waitForAnimations(page); await row.screenshot({ path: `${SHOTS}/05-retry-success.png` }); }); + + /** + * 06 — bundled-bridge Node.js runtime check passing: green section listing + * each bundled bridge's requirement as satisfied, no fix link. + */ + test("06-node-runtime-pass", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + CODEX_NOT_INSTALLED, + BUZZ_AGENT_AVAILABLE, + ], + nodeRuntimeCheck: { + status: "pass", + message: + "Node.js 22.11.0 satisfies the bundled ACP bridge requirements", + manifest_path: + "/Applications/Buzz.app/Contents/Resources/resources/acp/node-runtime.json", + node_path: "/opt/homebrew/bin/node", + node_version: "22.11.0", + requirements: [ + { + binary: "claude-agent-acp", + requirement: ">=18.0.0", + verdict: "satisfied", + }, + { binary: "codex-acp", requirement: ">=22", verdict: "satisfied" }, + ], + fix_url: "https://nodejs.org/en/download", + }, + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "doctor"); + + const section = page.getByTestId("doctor-node-runtime"); + await expect(section).toBeVisible({ timeout: 10_000 }); + await expect(section).toContainText("Node.js runtime"); + await expect(section).toContainText( + "Node.js 22.11.0 satisfies the bundled ACP bridge requirements", + ); + await expect(section).toContainText("claude-agent-acp"); + await expect(section).toContainText("codex-acp"); + await expect( + section.getByRole("button", { name: "Install Node.js" }), + ).toHaveCount(0); + + await section.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await section.screenshot({ path: `${SHOTS}/06-node-runtime-pass.png` }); + }); + + /** + * 07 — bundled-bridge Node.js runtime check warning (node too old): amber + * section with the unmet requirement and an "Install Node.js" fix link. + */ + test("07-node-runtime-warn", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + CODEX_NOT_INSTALLED, + BUZZ_AGENT_AVAILABLE, + ], + nodeRuntimeCheck: { + status: "warn", + message: + "Node.js 20.10.0 is too old for bundled ACP bridges: codex-acp needs Node.js >=22", + manifest_path: + "/Applications/Buzz.app/Contents/Resources/resources/acp/node-runtime.json", + node_path: "/usr/local/bin/node", + node_version: "20.10.0", + requirements: [ + { + binary: "claude-agent-acp", + requirement: ">=18.0.0", + verdict: "satisfied", + }, + { binary: "codex-acp", requirement: ">=22", verdict: "unmet" }, + ], + fix_url: "https://nodejs.org/en/download", + }, + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "doctor"); + + const section = page.getByTestId("doctor-node-runtime"); + await expect(section).toBeVisible({ timeout: 10_000 }); + await expect(section).toContainText("too old for bundled ACP bridges"); + await expect(section).toContainText("unmet"); + await expect( + section.getByRole("button", { name: "Install Node.js" }), + ).toBeVisible(); + + await section.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await section.screenshot({ path: `${SHOTS}/07-node-runtime-warn.png` }); + }); + + /** + * 08 — no bundled bridges (nodeRuntimeCheck omitted → null): the Doctor + * panel renders no Node.js runtime section at all. + */ + test("08-node-runtime-hidden-when-not-bundled", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [GOOSE_AVAILABLE, BUZZ_AGENT_AVAILABLE], + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "doctor"); + + await expect(page.getByTestId("doctor-runtime-goose")).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByTestId("doctor-node-runtime")).toHaveCount(0); + }); }); From 21dabae2ff96d73b203979b215e1c39302e069d8 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 13 Jul 2026 13:54:22 +1000 Subject: [PATCH 04/18] refactor(desktop): retire codex version gate and bridge npm-install flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the ACP bridges (@agentclientprotocol/claude-agent-acp, @agentclientprotocol/codex-acp) bundled with the app at pinned versions and resolved ahead of user installs, three pieces of machinery are dead code for those two runtimes: - The codex 0.16.x version gate: probe_codex_acp_major_version, codex_adapter_availability / codex_adapter_is_outdated, and the AdapterOutdated availability status (Rust enum variant plus the frontend "adapter_outdated" union member and all its UI branches). The bundled adapter is always the pinned 1.x package, so probing for the deprecated @zed-industries/codex-acp package has no trigger left. - The EEXIST uninstall-then-reinstall two-step in plan_adapter_install, which existed only to swap the deprecated codex package for the new one. The plan is now the simple missing -> catalog-commands mapping, and the function drops its runtime_id parameter. - The in-app `npm install -g` flow for the two bundled bridges: their catalog entries now carry empty adapter_install_commands and a hint that the adapter ships with the Buzz desktop app. Goose keeps its npm install flow untouched — its adapter is not bundled. cli_login_requirements now classifies availability purely via classify_runtime (which resolves bundle-first), replacing the codex version-probe special case. The configNudge validator rejects the retired "adapter_outdated" literal, so stale nudge JSON emitted by an older app version cannot render a card the current UI has no branch for (regression test inverted accordingly). This is a Buzz-specific retirement enabled by the bundling series; the donor series (block/builderbot apps/staged, berd) had no equivalent version gate to remove. File-size ledger entries for discovery.rs (1178 -> 1056), discovery/tests.rs (1029 -> 825), and readiness.rs (1754 -> 1583) are ratcheted down to bank the deletions, per the note left in the bundled-resolution commit. Verification: - cargo test --lib (desktop/src-tauri): 1381 passed, 0 failed - cargo clippy --lib --tests -D warnings: clean - cargo fmt --check: clean - tsc --noEmit: clean; biome check src: no new diagnostics - pnpm test (desktop): 2744 passed, 0 failed - node scripts/check-file-sizes.mjs: pass with ratcheted limits Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/scripts/check-file-sizes.mjs | 39 ++-- .../src-tauri/src/commands/agent_discovery.rs | 150 +++---------- .../src-tauri/src/managed_agents/discovery.rs | 141 +----------- .../src/managed_agents/discovery/tests.rs | 212 +----------------- .../discovery/tests/codex_version.rs | 48 ---- .../src-tauri/src/managed_agents/readiness.rs | 169 +------------- desktop/src-tauri/src/managed_agents/types.rs | 2 - .../agents/ui/AgentDefinitionDialog.tsx | 8 +- .../agents/ui/personaDialogPickers.tsx | 14 +- .../src/features/onboarding/ui/SetupStep.tsx | 23 -- .../settings/ui/DoctorSettingsPanel.tsx | 35 --- desktop/src/shared/api/types.ts | 1 - desktop/src/shared/lib/configNudge.test.mjs | 15 +- desktop/src/shared/lib/configNudge.ts | 2 - .../src/shared/ui/config-nudge-attachment.tsx | 2 - 15 files changed, 84 insertions(+), 777 deletions(-) delete mode 100644 desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 74fcd0ace4..08fd68f935 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -151,19 +151,16 @@ const overrides = new Map([ // +18: CliConfigInvalid requirement surface for config-parse probe classification — // new Requirement variant + updated cli_login_requirements + 3 new probe-layer tests. // Load-bearing UX fix (bad config → clear diagnostic, not "run codex login"). - // codex-acp-package-swap: AdapterOutdated version-probe in cli_login_requirements - // (+22 lines). Load-bearing — blocks login gate for deprecated 0.16.x adapter. - // code-reviewer fix-round: codex readiness gate tests — 2 new tests for - // outdated-adapter and garbage-version-output paths through the codex id gate - // (+140 lines: make_codex_runtime helper, PATH_MUTEX serializer, 2 test fns). - // Load-bearing test coverage; queued to split with the file generally. // +1: pub(crate) mod cli_probe declaration for doctor auth probe access. // +3: auth_probe_args: None + login_hint: None added to make_cli_runtime and // make_codex_runtime stubs (new KnownAcpRuntime fields). // Git Bash readiness is intentionally colocated with buzz-agent's other // setup-mode requirements. The Windows-only requirement and serialization // test add eight lines; split remains queued with the existing file debt. - ["src-tauri/src/managed_agents/readiness.rs", 1762], + // bundle-acps: codex version-gate retirement removes the AdapterOutdated + // probe from cli_login_requirements and its gate tests, both obsolete now + // that the bridges ship bundled; ratcheting 1762 -> 1597 to bank the headroom. + ["src-tauri/src/managed_agents/readiness.rs", 1597], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the // harness-persona-sync `harnessOverride` create-input bit — load-bearing @@ -202,7 +199,6 @@ const overrides = new Map([ // split with the rest of this file. ["src/shared/api/tauri.ts", 1339], // doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line). - // codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line). // doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/ // loginHint fields on AcpRuntimeCatalogEntry (+14 lines). Load-bearing new feature. // agent-lifecycle-fixes: GlobalAgentConfigSaveResult type grows with @@ -212,8 +208,9 @@ const overrides = new Map([ // contract. This is the canonical type location; split remains queued. // bundle-acps: NodeRuntimeCheck + NodeRuntimeRequirement types for the // bundled-bridge Node.js doctor check (+23 lines on rebase union with - // main's Git Bash prerequisite type growth). - ["src/shared/api/types.ts", 1061], + // main's Git Bash prerequisite type growth); "adapter_outdated" + // availability retired with the codex version gate (-1 line). + ["src/shared/api/types.ts", 1060], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog // shows required markers and credential amber rows (parity with @@ -247,14 +244,6 @@ const overrides = new Map([ // agent-config-propagation: the agent_command_override decision family // (divergent / create-time / update-time / apply) moved to // discovery/overrides.rs; ratcheting 802 -> 685 to bank the headroom. - // codex-acp-package-swap: probe_codex_acp_major_version (+24 lines) + - // AdapterOutdated version-gate in discover_acp_runtimes (+22 lines). Both - // load-bearing — required to detect the deprecated 0.16.x adapter and - // prevent silent relay breakage after the spawn-contract change. - // codex-acp-package-swap follow-up: tempfile-based bounded stdout read - // (+18 lines), codex_adapter_availability/is_outdated helpers (+16 lines), - // cross-platform probe contract. All load-bearing — required for correct - // probe behaviour on Windows and descendant-process edge cases. // doctor-install-reliability: refreshable login_shell_path cache, // find_nvm_default_bin + parse_semver_tag helpers, auth probe cache + // probe_auth_status/cached_auth_status, runtime_needs_npm, probe_args_for, @@ -268,14 +257,18 @@ const overrides = new Map([ // + updated adapter_availability_cached() signature (Option return, cold=None) // prevents false restart badge on newly restarted agents. Correctness fix; // load-bearing — required by Thufir's IMPORTANT findings. (+15 lines) - // bundle-acps: bundled ACP bridge check at the top of the resolution sweep - // (+7 lines). Temporary — the codex version-gate retirement later in the - // same series deletes far more from this file and ratchets this back down. - ["src-tauri/src/managed_agents/discovery.rs", 1252], + // bundle-acps: bundled ACP bridge check at the top of the resolution sweep, + // then the codex version-gate retirement (probe_codex_acp_major_version, + // codex_adapter_availability/is_outdated, AdapterOutdated arm) made + // obsolete by pinned bundling; ratcheted down to bank the deletions + // (main's codex-install-auto-restart cache-warm growth stays). + ["src-tauri/src/managed_agents/discovery.rs", 1130], // rebase over codex-acp-package-swap: its version-probe tests union with the // doctor-install-reliability nvm/login-shell/semver tests — each side alone // stayed under the 1000 default; the union exceeds it. - ["src-tauri/src/managed_agents/discovery/tests.rs", 1029], + // bundle-acps: version-gate retirement deletes the probe/availability test + // sections; ratcheting 1029 -> 825 (under the 1000 default; kept as a ratchet). + ["src-tauri/src/managed_agents/discovery/tests.rs", 825], // identity-import-keyring: the identity resolution state machine's behavioral // matrix (46 tests over FakeIdentityStore — probe × marker × file cells, // adoption / read-back-corruption / marker-failure arms, recovery-mode diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 0d63b9a7e9..2a97d62f00 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -19,40 +19,24 @@ fn active_installs() -> &'static std::sync::Mutex( - runtime_id: &str, adapter_path: Option<&std::path::Path>, adapter_install_commands: &'c [&'c str], ) -> Option> { match adapter_path { - // Adapter present and current — no install needed. - Some(_) if runtime_id != "codex" => None, - Some(path) if !crate::managed_agents::codex_adapter_is_outdated(path) => None, - // Codex adapter is outdated: uninstall the old package first so npm - // doesn't hit EEXIST on the shared `codex-acp` bin-link, then install. - Some(_) => Some(vec![ - "npm uninstall -g @zed-industries/codex-acp", - "npm install -g @agentclientprotocol/codex-acp", - ]), + // Adapter present — no install needed. + Some(_) => None, // Adapter missing: use the catalog's install commands directly. None => Some(adapter_install_commands.to_vec()), } @@ -177,19 +161,16 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result &'static std::sync::Mutex> { @@ -976,114 +976,6 @@ pub(crate) fn classify_runtime( } } -/// Probe the major version of a `codex-acp` binary by running `--version`. -/// -/// The 1.x adapter (`@agentclientprotocol/codex-acp`) outputs -/// `@agentclientprotocol/codex-acp ..` on stdout and exits 0. -/// The old 0.16.x adapter (`@zed-industries/codex-acp`) is a Rust binary that does -/// not recognise `--version` and exits non-zero. -/// -/// Returns the major version on success, `None` on any failure (non-zero exit, -/// unparseable output, timeout, or missing binary). -/// -/// The probe is bounded by a 5-second deadline. The child is polled with -/// [`std::process::Child::try_wait`] (the repo's standard deadline pattern) and -/// killed if it does not exit in time. -/// -/// Stdout is redirected to a temporary file rather than a pipe, so forked -/// descendants cannot hold EOF open. Reads from a regular file return EOF at its -/// current write position regardless of inherited file descriptors, cross-platform. -pub(crate) fn probe_codex_acp_major_version(binary_path: &Path) -> Option { - probe_codex_acp_major_version_with_path( - binary_path, - crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(), - ) -} -pub(crate) fn probe_codex_acp_major_version_with_path( - binary_path: &Path, - augmented_path: Option<&str>, -) -> Option { - use std::io::{Read as _, Seek as _, SeekFrom}; - use std::time::{Duration, Instant}; - const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5); - - // A regular file returns EOF at its current size even when a descendant - // inherits its descriptor, bounding the post-exit read cross-platform. - let mut tmp = tempfile::tempfile().ok()?; - - let mut command = Command::new(binary_path); - command.arg("--version"); - if let Some(path) = augmented_path { - command.env("PATH", path); - } - let mut child = command - .stdout(tmp.try_clone().ok()?) - .stderr(std::process::Stdio::null()) - .spawn() - .ok()?; - - // Poll until the deadline rather than blocking on stdout EOF. - let deadline = Instant::now() + VERSION_PROBE_TIMEOUT; - let exit_status = loop { - match child.try_wait() { - Ok(Some(status)) => break status, - Ok(None) => { - if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - std::thread::sleep(Duration::from_millis(50)); - } - Err(_) => { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - } - }; - - if !exit_status.success() { - return None; - } - - // Read at most 4 KiB from the regular file without blocking. - tmp.seek(SeekFrom::Start(0)).ok()?; - let mut buf = Vec::with_capacity(128); - let _ = (&mut tmp as &mut dyn std::io::Read) - .take(4096) - .read_to_end(&mut buf); - - let stdout = String::from_utf8_lossy(&buf); - // Output format: " .." - let version_str = stdout.split_whitespace().last()?; - let major_str = version_str.split('.').next()?; - major_str.parse::().ok() -} - -/// Classifies a resolved codex-acp binary path as [`AcpAvailabilityStatus::Available`] -/// or [`AcpAvailabilityStatus::AdapterOutdated`]. -/// -/// The 0.16.x adapter (`@zed-industries/codex-acp`) does not recognise `--version` -/// and exits non-zero — that probe failure yields `AdapterOutdated`. The 1.x adapter -/// (`@agentclientprotocol/codex-acp`) prints its version and exits 0; major ≥ 1 -/// yields `Available`. -/// -/// Used by `discover_acp_runtimes`, `cli_login_requirements`, and -/// `install_acp_runtime_blocking` so the version-gate logic is not duplicated. -pub(crate) fn codex_adapter_availability(path: &Path) -> AcpAvailabilityStatus { - match probe_codex_acp_major_version(path) { - Some(major) if major >= 1 => AcpAvailabilityStatus::Available, - _ => AcpAvailabilityStatus::AdapterOutdated, - } -} - -/// Returns `true` when the codex-acp binary at `path` is outdated (major version < 1) -/// or cannot be probed. Thin wrapper around [`codex_adapter_availability`]. -pub(crate) fn codex_adapter_is_outdated(path: &Path) -> bool { - codex_adapter_availability(path) == AcpAvailabilityStatus::AdapterOutdated -} - /// Intermediate struct built before the (potentially slow) auth probe phase. struct PartialEntry { runtime: &'static KnownAcpRuntime, @@ -1104,21 +996,9 @@ pub fn discover_acp_runtimes() -> Vec { .underlying_cli .map(|cli| find_command(cli).is_some()) .unwrap_or(false); - let (mut availability, command, binary_path) = + let (availability, command, binary_path) = classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); - // For codex-acp: when the adapter resolves as Available, probe the - // version. An adapter with major version < 1 is treated as outdated — - // the CODEX_CONFIG spawn contract requires 1.x. - if runtime.id == "codex" - && availability == AcpAvailabilityStatus::Available - && command.as_deref() == Some("codex-acp") - { - if let Some(path_str) = &binary_path { - availability = codex_adapter_availability(&PathBuf::from(path_str)); - } - } - // Warm the adapter-availability cache for the badge fallback. // The cache is scoped to the codex runtime; other runtimes leave it // unchanged. Invalidated by `clear_resolve_cache`. @@ -1145,7 +1025,6 @@ pub fn discover_acp_runtimes() -> Vec { AcpAvailabilityStatus::Available => cli_hint.to_string(), AcpAvailabilityStatus::CliMissing => cli_hint.to_string(), AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(), - AcpAvailabilityStatus::AdapterOutdated => adapter_hint.to_string(), AcpAvailabilityStatus::NotInstalled => { if !cli_hint.is_empty() && !adapter_hint.is_empty() { format!("{cli_hint} {adapter_hint}") diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index d1c1cc18d8..8a8392a43a 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -2,13 +2,11 @@ use std::path::PathBuf; use super::overrides::{divergent_agent_command_override, update_time_agent_command_override}; use super::{ - apply_agent_command_update, classify_runtime, codex_adapter_availability, - codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command, - effective_agent_command, find_nvm_default_bin, find_via_login_shell, + apply_agent_command_update, classify_runtime, create_time_agent_command_override, + default_agent_command, effective_agent_command, find_nvm_default_bin, find_via_login_shell, is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, - parse_semver_tag, probe_codex_acp_major_version, record_agent_command, - refresh_login_shell_path, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, - GOOSE_AVATAR_URL, + parse_semver_tag, record_agent_command, refresh_login_shell_path, BUZZ_AGENT_AVATAR_URL, + CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -606,208 +604,6 @@ fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() { assert_eq!(record_agent_command(&record, &personas), "codex-acp"); } -// ── probe_codex_acp_major_version ───────────────────────────────────────────── - -#[cfg(unix)] -#[test] -fn probe_codex_acp_major_version_parses_1x_output() { - use std::os::unix::fs::PermissionsExt; - - // Simulate `@agentclientprotocol/codex-acp 1.1.2` output (1.x adapter) - let dir = std::env::temp_dir().join(format!("buzz-probe-1x-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - let bin = dir.join("codex-acp"); - std::fs::write( - &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\nexit 0\n", - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - - let major = probe_codex_acp_major_version(&bin); - let _ = std::fs::remove_dir_all(dir); - - assert_eq!(major, Some(1), "1.x adapter must return major version 1"); -} - -mod codex_version; - -#[cfg(unix)] -#[test] -fn probe_codex_acp_major_version_returns_none_for_nonzero_exit() { - use std::os::unix::fs::PermissionsExt; - - // Simulate old 0.16.x adapter: `--version` is unrecognised, exits non-zero - let dir = std::env::temp_dir().join(format!("buzz-probe-0x-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - let bin = dir.join("codex-acp"); - std::fs::write(&bin, "#!/bin/sh\nexit 1\n").expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - - let major = probe_codex_acp_major_version(&bin); - let _ = std::fs::remove_dir_all(dir); - - assert_eq!( - major, None, - "old 0.16.x adapter (non-zero exit) must return None" - ); -} - -#[cfg(unix)] -#[test] -fn probe_codex_acp_major_version_returns_none_for_missing_binary() { - let path = std::path::Path::new("/nonexistent/path/codex-acp-does-not-exist"); - let major = probe_codex_acp_major_version(path); - assert_eq!(major, None, "missing binary must return None"); -} - -// ── codex_adapter_availability / codex_adapter_is_outdated ─────────────────── -// -// Outcome-level classification: verify helpers map probe results to the correct -// AcpAvailabilityStatus and boolean without duplicating version-gate logic. - -#[cfg(unix)] -#[test] -fn codex_adapter_availability_available_for_1x_binary() { - use std::os::unix::fs::PermissionsExt; - - let dir = std::env::temp_dir().join(format!("buzz-avail-1x-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - let bin = dir.join("codex-acp"); - std::fs::write( - &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\nexit 0\n", - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - - let status = codex_adapter_availability(&bin); - let _ = std::fs::remove_dir_all(dir); - - assert_eq!( - status, - AcpAvailabilityStatus::Available, - "1.x adapter must classify as Available" - ); -} - -#[cfg(unix)] -#[test] -fn codex_adapter_availability_outdated_for_0x_binary() { - use std::os::unix::fs::PermissionsExt; - - // Simulate old 0.16.x: `--version` exits non-zero (unrecognised flag) - let dir = std::env::temp_dir().join(format!("buzz-avail-0x-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - let bin = dir.join("codex-acp"); - std::fs::write(&bin, "#!/bin/sh\nexit 1\n").expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - - let status = codex_adapter_availability(&bin); - let _ = std::fs::remove_dir_all(dir); - - assert_eq!( - status, - AcpAvailabilityStatus::AdapterOutdated, - "0.x adapter (non-zero exit) must classify as AdapterOutdated" - ); -} - -#[cfg(unix)] -#[test] -fn codex_adapter_availability_outdated_for_missing_binary() { - let path = std::path::Path::new("/nonexistent/codex-acp-probe-test"); - assert_eq!( - codex_adapter_availability(path), - AcpAvailabilityStatus::AdapterOutdated, - "missing binary must classify as AdapterOutdated" - ); - // Thin wrapper consistency - assert!( - codex_adapter_is_outdated(path), - "missing binary must be classified as outdated via thin wrapper" - ); -} - -#[cfg(unix)] -#[test] -fn probe_codex_acp_major_version_returns_none_for_hung_direct_child() { - use std::os::unix::fs::PermissionsExt; - use std::time::Instant; - - // Simulate a process that writes version to stdout then blocks forever. - // The probe reads stdout only after the child exits, so it will time out. - // `exec sleep 300` replaces the shell so killing the child reaps `sleep` too. - let dir = std::env::temp_dir().join(format!("buzz-probe-hung-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - let bin = dir.join("codex-acp"); - std::fs::write( - &bin, - "#!/bin/sh\nprintf '@agentclientprotocol/codex-acp 1.1.2\\n'\nexec sleep 300\n", - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - - let start = Instant::now(); - let major = probe_codex_acp_major_version(&bin); - let elapsed = start.elapsed(); - let _ = std::fs::remove_dir_all(dir); - - assert_eq!( - major, None, - "hung binary must return None (timeout kills child)" - ); - // The timeout is 5 s; give a 10 s margin for parallel pre-push suites. - assert!( - elapsed.as_secs() < 15, - "probe must complete within timeout bound; elapsed: {elapsed:?}" - ); -} - -#[cfg(unix)] -#[test] -fn probe_codex_acp_major_version_returns_version_when_descendant_holds_pipe_open() { - use std::os::unix::fs::PermissionsExt; - use std::time::Instant; - - // Simulate a process that forks a background child which inherits stdout - // and stays alive, while the parent writes version and exits 0. - // - // The probe writes the child's stdout to a temp file, then reads from the - // file after the parent process exits. Because the file has reached EOF - // (the parent closed its write end), read_to_end() returns immediately - // without waiting for the descendant to close its inherited fd. - // - // `(exec sleep 60 &)` forks a subshell that execs `sleep 60`; the subshell - // inherits the parent's stdout fd and keeps it open. - let dir = std::env::temp_dir().join(format!("buzz-probe-descendant-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - let bin = dir.join("codex-acp"); - std::fs::write( - &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\n(exec sleep 60 &)\nexit 0\n", - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - - let start = Instant::now(); - let major = probe_codex_acp_major_version(&bin); - let elapsed = start.elapsed(); - let _ = std::fs::remove_dir_all(dir); - - // Must return within ~1 s: non-blocking read, no waiting for descendant. - // Give a 9 s margin for parallel pre-push suites. - assert!( - elapsed.as_secs() < 10, - "probe must not block on descendant pipe; elapsed: {elapsed:?}" - ); - assert_eq!( - major, - Some(1), - "1.x version must be parsed even when descendant holds pipe open" - ); -} - // ── parse_semver_tag ────────────────────────────────────────────────────────── #[test] diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs deleted file mode 100644 index 5886a43990..0000000000 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs +++ /dev/null @@ -1,48 +0,0 @@ -use super::super::probe_codex_acp_major_version_with_path; - -#[cfg(unix)] -#[test] -fn probe_codex_acp_major_version_uses_augmented_path_for_env_shebang_interpreter() { - use std::fs; - use std::os::unix::fs::PermissionsExt; - let temp = tempfile::tempdir().expect("temp dir"); - let script_dir = temp.path().join("script-bin"); - let interpreter_dir = temp.path().join("interpreter-bin"); - let empty_path_dir = temp.path().join("empty-bin"); - fs::create_dir_all(&script_dir).expect("script dir"); - fs::create_dir_all(&interpreter_dir).expect("interpreter dir"); - fs::create_dir_all(&empty_path_dir).expect("empty path dir"); - - let interpreter_path = interpreter_dir.join("node"); - fs::write( - &interpreter_path, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\n", - ) - .expect("write interpreter"); - fs::set_permissions(&interpreter_path, fs::Permissions::from_mode(0o755)) - .expect("chmod interpreter"); - - let shim_path = script_dir.join("codex-acp"); - fs::write(&shim_path, "#!/usr/bin/env node\n").expect("write shim"); - fs::set_permissions(&shim_path, fs::Permissions::from_mode(0o755)).expect("chmod shim"); - - let scrubbed_path = std::env::join_paths([empty_path_dir.as_path()]) - .expect("join scrubbed PATH") - .to_string_lossy() - .into_owned(); - assert_eq!( - probe_codex_acp_major_version_with_path(&shim_path, Some(&scrubbed_path)), - None, - "with a scrubbed PATH, /usr/bin/env should not find node" - ); - - let augmented_path = std::env::join_paths([interpreter_dir.as_path()]) - .expect("join augmented PATH") - .to_string_lossy() - .into_owned(); - assert_eq!( - probe_codex_acp_major_version_with_path(&shim_path, Some(&augmented_path)), - Some(1), - "the injected augmented PATH should allow /usr/bin/env to find node" - ); -} diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 8349cff014..771f98a515 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -39,7 +39,6 @@ //! UI display only. use std::collections::BTreeMap; -use std::path::Path; use serde::{Deserialize, Serialize}; @@ -47,8 +46,7 @@ use crate::managed_agents::{ agent_env::baked_build_env, config_bridge::read_goose_file_config, discovery::{ - classify_runtime, codex_adapter_availability, find_command, known_acp_runtime, - resolve_command, KnownAcpRuntime, + classify_runtime, find_command, known_acp_runtime, resolve_command, KnownAcpRuntime, }, env_vars::merged_user_env, global_config::GlobalAgentConfig, @@ -510,26 +508,9 @@ fn cli_login_requirements( .map(|cli| find_command(cli).is_some()) .unwrap_or(false); - let (availability, cmd, adapter_path) = + let (availability, _cmd, _adapter_path) = classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); - // For codex-acp: if the adapter resolved as Available, probe the version. - // An adapter with major version < 1 is the deprecated package and must be - // treated as outdated (blocks login probe — the agent can't reach the relay). - // Guard on `cmd == "codex-acp"` to match the discovery path and avoid - // probing when the runtime resolves via an alias command. - let availability = if runtime.id == "codex" - && availability == AcpAvailabilityStatus::Available - && cmd.as_deref() == Some("codex-acp") - { - adapter_path - .as_deref() - .map(|path_str| codex_adapter_availability(Path::new(path_str))) - .unwrap_or(availability) - } else { - availability - }; - match availability { AcpAvailabilityStatus::Available => { // Both adapter and CLI are present — probe login status. @@ -1131,152 +1112,6 @@ mod tests { } } - // ── codex readiness version gate ─────────────────────────────────────── - - /// Build a minimal `KnownAcpRuntime` for testing the codex version gate. - /// `adapter_commands` are the exact strings passed to `find_command` — use - /// `&["codex-acp"]` when the binary is on PATH, or `&[]` - /// when resolving via absolute path. `underlying_cli` is a portable - /// stand-in so the adapter is not misclassified as `CliMissing`. - fn make_codex_runtime( - adapter_commands: &'static [&'static str], - underlying_cli: Option<&'static str>, - ) -> KnownAcpRuntime { - KnownAcpRuntime { - id: "codex", - label: "Codex", - commands: adapter_commands, - aliases: &[], - avatar_url: "", - mcp_command: None, - mcp_hooks: false, - underlying_cli, - cli_install_commands: &[], - adapter_install_commands: &[], - install_instructions_url: "", - cli_install_hint: "", - adapter_install_hint: "", - skill_dir: None, - supports_acp_model_switching: false, - config_file_path: None, - config_file_format: None, - model_env_var: None, - provider_env_var: None, - provider_locked: false, - default_env: &[], - supports_acp_native_config: false, - thinking_env_var: None, - max_tokens_env_var: None, - context_limit_env_var: None, - required_normalized_fields: &[], - login_hint: None, - auth_probe_args: None, - } - } - - /// Build a temp dir containing a `codex-acp` script with the given body, - /// prepend it to PATH, and clear the resolve cache. Returns the temp dir - /// and the original PATH string for restoration. - #[cfg(unix)] - fn setup_temp_codex_acp(script_body: &str) -> (tempfile::TempDir, String) { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().expect("create temp dir"); - let bin = dir.path().join("codex-acp"); - std::fs::write(&bin, script_body).expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) - .expect("chmod script"); - - let original_path = std::env::var("PATH").unwrap_or_default(); - let new_path = format!("{}:{}", dir.path().display(), original_path); - std::env::set_var("PATH", &new_path); - crate::managed_agents::clear_resolve_cache(); - - (dir, original_path) - } - - /// Restore PATH and clear the resolve cache after a PATH-mutating test. - #[cfg(unix)] - fn restore_path(original: &str) { - std::env::set_var("PATH", original); - crate::managed_agents::clear_resolve_cache(); - } - - /// Codex readiness: outdated adapter (exits non-zero) → AdapterOutdated, - /// login probe skipped. - #[cfg(unix)] - #[test] - fn cli_login_requirements_codex_outdated_adapter_emits_adapter_outdated() { - let _guard = crate::managed_agents::lock_path_mutex(); - - let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\nexit 1\n"); - let exe = present_binary_str(); - // underlying_cli = running test binary (always present, never probed) - let rt = make_codex_runtime(&["codex-acp"], Some(exe)); - let reqs = cli_login_requirements( - &[exe, "--buzz-probe-must-not-run-xyz"], - "run `codex login`", - &rt, - ); - - restore_path(&orig); - drop(dir); - - assert!( - !reqs.is_empty(), - "outdated codex adapter must produce a requirement; got {reqs:?}" - ); - if let Requirement::CliLogin { - ref availability, .. - } = reqs[0] - { - assert_eq!( - *availability, - crate::managed_agents::AcpAvailabilityStatus::AdapterOutdated, - "0.x codex adapter must yield AdapterOutdated; got {availability:?}" - ); - } else { - panic!("expected CliLogin requirement; got {:?}", reqs[0]); - } - } - - /// Codex readiness: adapter exits 0 but output is not a parseable version - /// → AdapterOutdated (garbage output treated as outdated, same as non-zero). - #[cfg(unix)] - #[test] - fn cli_login_requirements_codex_garbage_version_output_emits_adapter_outdated() { - let _guard = crate::managed_agents::lock_path_mutex(); - - let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\necho 'not a version string'\nexit 0\n"); - let exe = present_binary_str(); - let rt = make_codex_runtime(&["codex-acp"], Some(exe)); - let reqs = cli_login_requirements( - &[exe, "--buzz-probe-must-not-run-xyz"], - "run `codex login`", - &rt, - ); - - restore_path(&orig); - drop(dir); - - assert!( - !reqs.is_empty(), - "garbage version output must produce a requirement; got {reqs:?}" - ); - if let Requirement::CliLogin { - ref availability, .. - } = reqs[0] - { - assert_eq!( - *availability, - crate::managed_agents::AcpAvailabilityStatus::AdapterOutdated, - "unparseable version output must yield AdapterOutdated; got {availability:?}" - ); - } else { - panic!("expected CliLogin requirement; got {:?}", reqs[0]); - } - } - // ── custom/unknown command ───────────────────────────────────────────── #[test] diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 9c1c5cc677..6fc0146f30 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -530,8 +530,6 @@ pub struct ManagedAgentLogResponse { pub enum AcpAvailabilityStatus { Available, AdapterMissing, - /// Adapter binary is present but is from the deprecated package (< 1.0). Reinstall required. - AdapterOutdated, CliMissing, NotInstalled, } diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index e6cedca297..ff74c1dca4 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -552,11 +552,9 @@ export function AgentDefinitionDialog({

{selectedRuntime.availability === "adapter_missing" ? `${selectedRuntime.label} CLI is installed but the ACP adapter is missing.` - : selectedRuntime.availability === "adapter_outdated" - ? `${selectedRuntime.label} ACP adapter is outdated — reinstall to continue.` - : selectedRuntime.availability === "cli_missing" - ? `${selectedRuntime.label} ACP adapter is installed but the CLI is missing.` - : `${selectedRuntime.label} is not installed.`}{" "} + : selectedRuntime.availability === "cli_missing" + ? `${selectedRuntime.label} ACP adapter is installed but the CLI is missing.` + : `${selectedRuntime.label} is not installed.`}{" "} Visit Settings > Doctor to set it up.

) : null; diff --git a/desktop/src/features/agents/ui/personaDialogPickers.tsx b/desktop/src/features/agents/ui/personaDialogPickers.tsx index f7d53213a7..f027408b81 100644 --- a/desktop/src/features/agents/ui/personaDialogPickers.tsx +++ b/desktop/src/features/agents/ui/personaDialogPickers.tsx @@ -386,13 +386,11 @@ export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) { const suffix = runtime.availability === "adapter_missing" ? " (adapter missing)" - : runtime.availability === "adapter_outdated" - ? " (adapter outdated)" - : runtime.availability === "cli_missing" - ? " (CLI missing)" - : runtime.availability === "not_installed" - ? " (not installed)" - : ""; + : runtime.availability === "cli_missing" + ? " (CLI missing)" + : runtime.availability === "not_installed" + ? " (not installed)" + : ""; return `${runtime.label}${suffix}`; } @@ -408,8 +406,6 @@ function runtimeAvailabilitySortRank( return 2; case "adapter_missing": return 3; - case "adapter_outdated": - return 3; } } diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 603643a6bf..3559538086 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -353,29 +353,6 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { ); } - if (runtime.availability === "adapter_outdated") { - return ( - <> -

- ACP adapter detected but outdated — reinstall required. -

-

- This updates the machine-global{" "} - codex-acp{" "} - adapter. Older Buzz releases using the legacy adapter contract may - lose relay access until{" "} - - @zed-industries/codex-acp@0.16.0 - {" "} - is restored. -

-

- {runtime.installHint} -

- - ); - } - if (runtime.availability === "cli_missing") { return ( <> diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 84501ea7cd..2c70a453c0 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -38,8 +38,6 @@ function StatusIcon({ return ; case "adapter_missing": return ; - case "adapter_outdated": - return ; case "cli_missing": return ; case "not_installed": @@ -183,7 +181,6 @@ function RuntimeRow({ runtime.availability === "available" ? "bg-background/60" : runtime.availability === "adapter_missing" || - runtime.availability === "adapter_outdated" || runtime.availability === "cli_missing" ? "bg-amber-500/5" : "bg-muted/20", @@ -283,38 +280,6 @@ function RuntimeRow({ runtime={runtime} /> - ) : runtime.availability === "adapter_outdated" ? ( - <> -

- ACP adapter found at{" "} - - {runtime.binaryPath ?? "unknown path"} - {" "} - but it is from the deprecated package. Reinstall to enable relay - connectivity. -

-

- This updates the machine-global{" "} - - codex-acp - {" "} - adapter. Older Buzz releases using the legacy adapter contract may - lose relay access until{" "} - - @zed-industries/codex-acp@0.16.0 - {" "} - is restored. -

-

- {runtime.installHint} -

- - ) : runtime.availability === "cli_missing" ? ( <>

diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index eee5bae058..a4ebdddc51 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -551,7 +551,6 @@ export type GitBashPrerequisite = { export type AcpAvailabilityStatus = | "available" | "adapter_missing" - | "adapter_outdated" | "cli_missing" | "not_installed"; diff --git a/desktop/src/shared/lib/configNudge.test.mjs b/desktop/src/shared/lib/configNudge.test.mjs index f616d78298..8e1603e235 100644 --- a/desktop/src/shared/lib/configNudge.test.mjs +++ b/desktop/src/shared/lib/configNudge.test.mjs @@ -79,10 +79,11 @@ test("extractConfigNudge parses cli_login requirement", () => { assert.deepEqual(extractConfigNudge(withSentinel("prose", payload)), payload); }); -test("extractConfigNudge parses cli_login with adapter_outdated availability", () => { - // Backend emits availability: "adapter_outdated" when codex-acp is old (0.16.x). - // isConfigNudgeRequirement must accept this literal — regression test for the - // validator omission that caused it to be silently rejected. +test("extractConfigNudge rejects retired adapter_outdated availability", () => { + // The codex version gate was retired when the ACP bridges started shipping + // bundled with the app — "adapter_outdated" is no longer a valid + // availability. Stale nudge JSON emitted by an older app version must not + // parse into a card the current UI has no rendering for. const payload = { agent_name: "Codex", agent_pubkey: CODEX_PUBKEY, @@ -95,10 +96,10 @@ test("extractConfigNudge parses cli_login with adapter_outdated availability", ( }, ], }; - assert.deepEqual( + assert.equal( extractConfigNudge(withSentinel("prose", payload)), - payload, - "adapter_outdated availability must be accepted by the validator", + null, + "retired adapter_outdated availability must be rejected by the validator", ); }); diff --git a/desktop/src/shared/lib/configNudge.ts b/desktop/src/shared/lib/configNudge.ts index 7893236c28..901bb22737 100644 --- a/desktop/src/shared/lib/configNudge.ts +++ b/desktop/src/shared/lib/configNudge.ts @@ -32,7 +32,6 @@ export type ConfigNudgeRequirement = * Determines which message and CTA the nudge card shows: * - "available" → tooling installed, needs login * - "adapter_missing" → CLI installed but ACP adapter missing - * - "adapter_outdated" → ACP adapter present but from deprecated package; reinstall required * - "cli_missing" → ACP adapter installed but CLI missing * - "not_installed" → neither adapter nor CLI found */ @@ -142,7 +141,6 @@ function isConfigNudgeRequirement(v: unknown): v is ConfigNudgeRequirement { typeof r.setup_copy === "string" && (r.availability === "available" || r.availability === "adapter_missing" || - r.availability === "adapter_outdated" || r.availability === "cli_missing" || r.availability === "not_installed") ); diff --git a/desktop/src/shared/ui/config-nudge-attachment.tsx b/desktop/src/shared/ui/config-nudge-attachment.tsx index 4be3e63980..f21e5a3291 100644 --- a/desktop/src/shared/ui/config-nudge-attachment.tsx +++ b/desktop/src/shared/ui/config-nudge-attachment.tsx @@ -107,8 +107,6 @@ function cliLoginMessage( return `${harness} CLI is missing`; case "adapter_missing": return `${harness} ACP adapter isn't installed`; - case "adapter_outdated": - return `${harness} ACP adapter is outdated — reinstall required`; case "available": // Tooling is present but authentication is needed — fall back to // the backend-supplied copy which has the exact login command. From 8d89b518e9b4e6255f3b4498ca42e2a0c52f12e9 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 13 Jul 2026 13:59:12 +1000 Subject: [PATCH 05/18] feat(desktop): verify the ACP adapter resolves after runtime install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install_acp_runtime_blocking previously reported success whenever every install step it ran succeeded. For the bundled bridges (claude, codex) the adapter install plan is empty, so a broken bundle would run zero steps and report success — discovery would immediately classify the runtime as not installed again, an install-succeeds/still-broken loop with nothing actionable surfaced to the user. Add a Phase 3 verification gate after the final resolve-cache clear: a pure adapter_verification_step seam re-resolves the runtime's adapter commands and, when none resolves, appends a failed synthetic "verify" step and flips the result to success:false. The hint points at reinstalling Buzz when the adapter is bundled (the catalog carries no install commands) and at the install step output for npm-installed adapters like goose. Ports the install-verification gate from the berd donor series ("require the bundled bridge in install verification", berd 4028e34c, squashed into berd 09388d7): post-fix verification must apply the same resolved-bundled-bridge gate as readiness, otherwise an install can verify as success while the card immediately flips back to not-installed. The readiness half of that gate is already structural in Buzz — classify_runtime yields AdapterMissing/NotInstalled when the bundle-first resolution finds no adapter. Verification: - cargo test --lib (desktop/src-tauri): 1385 passed, 0 failed (4 new adapter_verification_step tests: resolves -> None, bundled failure carries the reinstall-Buzz hint, unbundled failure does not, empty command list -> nothing to verify) - cargo clippy --lib --tests -D warnings: clean - cargo fmt --check: clean - node desktop/scripts/check-file-sizes.mjs: pass Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/scripts/check-file-sizes.mjs | 5 +- .../src-tauri/src/commands/agent_discovery.rs | 126 +++++++++++++++++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 08fd68f935..5f9c712dce 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -400,7 +400,10 @@ const overrides = new Map([ // neutral frontend contract; split remains queued. // bundle-acps: node runtime check wiring for the bundled-bridge Doctor // requirement (+9 lines on rebase union with Git Bash Doctor discovery). - ["src-tauri/src/commands/agent_discovery.rs", 1366], + // bundle-acps: adapter_verification_step post-install gate + its test + // quartet (+120 lines, partly offset by the version-gate retirement's + // deletions in this file). Queued to split. + ["src-tauri/src/commands/agent_discovery.rs", 1410], // draft-persistence predicate: submit-time `loadDraft` check + inline comment // + deps-array entry in submitMessage closes the never-persisted-boundary // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 2a97d62f00..a9e355462b 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -26,7 +26,9 @@ fn active_installs() -> &'static std::sync::Mutex( } } +/// Post-install verification that the runtime's ACP adapter actually resolves. +/// +/// `install_acp_runtime_blocking` runs its install phases and then re-resolves +/// the adapter through `resolve`. For the bundled bridges (claude, codex) the +/// install plan is empty, so without this gate a broken bundle would run zero +/// steps and report success — discovery would immediately classify the runtime +/// as not installed again, an install-succeeds/still-broken loop. +/// +/// Returns `None` when there is nothing to verify (`commands` is empty) or any +/// adapter command resolves. Otherwise returns a failed synthetic "verify" +/// step whose hint points at reinstalling Buzz when the adapter is bundled +/// (`bundled`, i.e. the catalog carries no install commands) or at the install +/// step output otherwise. +fn adapter_verification_step( + commands: &[&str], + label: &str, + bundled: bool, + resolve: impl Fn(&str) -> Option, +) -> Option { + if commands.is_empty() || commands.iter().any(|cmd| resolve(cmd).is_some()) { + return None; + } + let hint = if bundled { + format!( + "The {label} ACP adapter ships with the Buzz desktop app but could not be found in \ + this installation. Reinstall Buzz to restore the bundled adapter." + ) + } else { + format!( + "The {label} ACP adapter still could not be found after the install steps completed. \ + Check the step output above and your npm global prefix." + ) + }; + Some(InstallStepResult { + step: "verify".to_string(), + command: format!("resolve {}", commands.join(" | ")), + success: false, + stdout: String::new(), + stderr: format!("no {label} ACP adapter binary found on the resolution path"), + exit_code: None, + hint: Some(hint), + }) +} + #[tauri::command] pub async fn discover_acp_providers() -> Result, String> { tokio::task::spawn_blocking(|| { @@ -163,7 +209,7 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result Result Date: Mon, 13 Jul 2026 17:14:54 +1000 Subject: [PATCH 06/18] chore(desktop): repair pre-existing clippy and file-size gate breakage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two quality gates fail at the branch head with no working-tree changes, blocking any new commit behind the pre-commit/pre-push hooks: - clippy 1.95 flags the redundant closure around process_is_running in should-restart discovery wiring (agent_discovery.rs); pass the function directly. - agent_discovery.rs measures 1400 lines against its 1398 file-size ledger limit (two lines landed without a matching ratchet bump); ratchet the ledger to match reality — this change adds no lines. The ledger also carries the ratchet bumps (discovery.rs 1130 -> 1134, tauri.ts 1317 -> 1320, types.ts 1049 -> 1051) for the Doctor bundled-adapter copy change that lands in the next commit: lefthook checks out the staged half of a partially staged file while running pre-commit, so splitting the ledger hunks across the two commits makes the gate judge the next commit's files against the old limits. Verification: cargo clippy --lib --tests -D warnings (desktop/src-tauri) clean; node desktop/scripts/check-file-sizes.mjs passes. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/scripts/check-file-sizes.mjs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 5f9c712dce..6f437a5f11 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -197,7 +197,9 @@ const overrides = new Map([ // checkAcpNodeRuntime wrapper for the bundled-bridge Node.js doctor check // (+35 lines on rebase union with Git Bash Doctor discovery). Queued to // split with the rest of this file. - ["src/shared/api/tauri.ts", 1339], + // bundled-adapter-doctor-copy: adapter_bundled field on + // RawAcpRuntimeCatalogEntry + mapper passthrough (+3 lines). + ["src/shared/api/tauri.ts", 1342], // doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line). // doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/ // loginHint fields on AcpRuntimeCatalogEntry (+14 lines). Load-bearing new feature. @@ -210,7 +212,9 @@ const overrides = new Map([ // bundled-bridge Node.js doctor check (+23 lines on rebase union with // main's Git Bash prerequisite type growth); "adapter_outdated" // availability retired with the codex version gate (-1 line). - ["src/shared/api/types.ts", 1060], + // bundled-adapter-doctor-copy: adapterBundled field on + // AcpRuntimeCatalogEntry (+2 lines). + ["src/shared/api/types.ts", 1062], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog // shows required markers and credential amber rows (parity with @@ -262,7 +266,9 @@ const overrides = new Map([ // codex_adapter_availability/is_outdated, AdapterOutdated arm) made // obsolete by pinned bundling; ratcheted down to bank the deletions // (main's codex-install-auto-restart cache-warm growth stays). - ["src-tauri/src/managed_agents/discovery.rs", 1130], + // bundled-adapter-doctor-copy: adapter_bundled computed in the discovery + // sweep so the Doctor UI can hide the bundle path (+4 lines). + ["src-tauri/src/managed_agents/discovery.rs", 1134], // rebase over codex-acp-package-swap: its version-probe tests union with the // doctor-install-reliability nvm/login-shell/semver tests — each side alone // stayed under the 1000 default; the union exceeds it. From f2c43e5151a6a56b90a6564672091e444ef8e54e Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 13 Jul 2026 17:15:22 +1000 Subject: [PATCH 07/18] feat(desktop): say the ACP bridge is bundled in Doctor instead of its path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For the bundled claude/codex bridges, the Doctor panel rendered the resolved adapter path — a deep resource-dir path like /Applications/Buzz.app/Contents/Resources/resources/acp/bin/… — plus an "Available via installed on PATH" description that is wrong for a bundled tool. Replace both with the plain statement "ACP bridge bundled with Buzz." and keep showing only the user's CLI path. - AcpRuntimeCatalogEntry gains adapter_bundled, computed in the discovery sweep via the new acp_tools::path_is_in_bundled_dir (component-wise starts_with against the registered bundled tools bin dir, false when no bundle is registered). It covers both states that surface an adapter path: available and cli_missing. - Doctor RuntimeRow: when adapter_bundled, the "Available via …" line becomes "ACP bridge bundled with Buzz.", the mono adapter-path line is dropped (the CLI path stays), and the cli_missing copy reads "ACP bridge bundled with Buzz, but the

- Available via{" "} - {describeResolvedCommand(runtime.command, runtime.binaryPath)}. + {runtime.adapterBundled + ? "ACP bridge bundled with Buzz." + : `Available via ${describeResolvedCommand(runtime.command, runtime.binaryPath)}.`}

{runtime.defaultArgs.length > 0 ? (

@@ -224,12 +225,16 @@ function RuntimeRow({ CLI:{" "} {runtime.underlyingCliPath}

-

- ACP adapter:{" "} - {runtime.binaryPath} -

+ {/* The bundled bridge's resource-dir path is noise — the + "ACP bridge bundled with Buzz." line above covers it. */} + {runtime.adapterBundled ? null : ( +

+ ACP adapter:{" "} + {runtime.binaryPath} +

+ )} - ) : ( + ) : runtime.adapterBundled ? null : ( <>

{runtime.binaryPath} @@ -283,11 +288,20 @@ function RuntimeRow({ ) : runtime.availability === "cli_missing" ? ( <>

- ACP adapter found at{" "} - - {runtime.binaryPath ?? "unknown path"} - {" "} - but the {runtime.label} CLI is not installed. + {runtime.adapterBundled ? ( + <> + ACP bridge bundled with Buzz, but the {runtime.label} CLI is + not installed. + + ) : ( + <> + ACP adapter found at{" "} + + {runtime.binaryPath ?? "unknown path"} + {" "} + but the {runtime.label} CLI is not installed. + + )}

{runtime.installHint} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index a97db6b418..94185de6c6 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -225,6 +225,8 @@ export type RawAcpRuntimeCatalogEntry = { availability: AcpAvailabilityStatus; command: string | null; binary_path: string | null; + /** Always sent by the backend; optional so e2e fixtures can omit it. */ + adapter_bundled?: boolean; default_args: string[]; mcp_command: string | null; install_hint: string; @@ -919,6 +921,7 @@ function fromRawAcpRuntimeCatalogEntry( availability: entry.availability, command: entry.command, binaryPath: entry.binary_path, + adapterBundled: entry.adapter_bundled ?? false, defaultArgs: entry.default_args, mcpCommand: entry.mcp_command, installHint: entry.install_hint, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index a4ebdddc51..5f0fa33a83 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -569,6 +569,8 @@ export type AcpRuntimeCatalogEntry = { availability: AcpAvailabilityStatus; command: string | null; binaryPath: string | null; + /** True when `binaryPath` is the bridge bundled with the Buzz app rather than a user install. */ + adapterBundled: boolean; defaultArgs: string[]; mcpCommand: string | null; installHint: string; diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index 80043393fc..5bcb893904 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -446,4 +446,40 @@ test.describe("Doctor panel state screenshots", () => { }); await expect(page.getByTestId("doctor-node-runtime")).toHaveCount(0); }); + + /** + * 09 — available runtime whose adapter is the bridge bundled with the app: + * the row says "ACP bridge bundled with Buzz" instead of rendering the + * resource-dir path; the user's CLI path still renders. + */ + test("09-bundled-adapter", async ({ page }) => { + const bundledPath = + "/Applications/Buzz.app/Contents/Resources/resources/acp/bin/claude-agent-acp"; + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + { + ...CLAUDE_AVAILABLE_LOGGED_IN, + binary_path: bundledPath, + adapter_bundled: true, + }, + CODEX_NOT_INSTALLED, + BUZZ_AGENT_AVAILABLE, + ], + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "doctor"); + + const row = page.getByTestId("doctor-runtime-claude"); + await expect(row).toBeVisible({ timeout: 10_000 }); + await expect(row).toContainText("ACP bridge bundled with Buzz."); + await expect(row).toContainText("/usr/local/bin/claude"); + await expect(row).not.toContainText(bundledPath); + await expect(row).not.toContainText("installed on PATH"); + + await row.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await row.screenshot({ path: `${SHOTS}/09-bundled-adapter.png` }); + }); }); From 3f9fc4a7f5b8c7b24ca5b64deacd3313258a28d6 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 13 Jul 2026 17:23:18 +1000 Subject: [PATCH 08/18] refactor(desktop): retire the claude-code-acp catalog resolution fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claude catalog entry still listed the retired Zed-era claude-code-acp adapter as a resolution fallback after claude-agent-acp. Since the bundling series, the bundled claude-agent-acp always resolves first, so the fallback is unreachable in the discovery sweep, readiness classification, and install verification — and were the bundle ever broken, silently spawning a stray install of the deprecated package is exactly the drift the series exists to prevent. Reporting adapter_missing (with the reinstall-Buzz hint) is the correct outcome there. Move the name from the claude entry's commands to its aliases: known_acp_runtime still maps stored records, override pins, and avatar lookups carrying the legacy command to the claude runtime (the override tests feeding claude-code-acp all pass unchanged), but no resolution path sweeps for it anymore. The commands/aliases split this makes load-bearing is now documented on the KnownAcpRuntime fields, and comments claiming discovery "may select an installed alias" are reworded to the stored-record reality. The default_agent_args normalization arm and the frontend/buzz-acp normalization paths (agentReuse.ts, buzz-acp config.rs) deliberately keep the name for stored records, as does KNOWN_AGENT_BINARIES for orphan-process cleanup. Completes item B.3 of the post-bundling cleanup plan. The discovery.rs file-size ledger is ratcheted 1134 -> 1139 for the five new comment lines (the file sat exactly at its limit). Verification: - cargo test --lib (desktop/src-tauri): 1423 passed, 0 failed, 11 ignored — including the avatar-url and override-pin tests that exercise claude-code-acp identity mapping via the alias path - cargo clippy --lib --tests -D warnings: clean - cargo fmt --check: clean - node desktop/scripts/check-file-sizes.mjs: pass Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/scripts/check-file-sizes.mjs | 5 ++++- .../src-tauri/src/managed_agents/discovery.rs | 9 +++++++-- .../src/managed_agents/discovery/overrides.rs | 6 +++--- .../src/managed_agents/discovery/tests.rs | 16 ++++++++-------- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 6f437a5f11..16306e66d4 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -268,7 +268,10 @@ const overrides = new Map([ // (main's codex-install-auto-restart cache-warm growth stays). // bundled-adapter-doctor-copy: adapter_bundled computed in the discovery // sweep so the Doctor UI can hide the bundle path (+4 lines). - ["src-tauri/src/managed_agents/discovery.rs", 1134], + // claude-code-acp-fallback-retirement: the legacy command moved from the + // resolution sweep to identity-only aliases; +5 comment lines documenting + // the commands/aliases split that move makes load-bearing. + ["src-tauri/src/managed_agents/discovery.rs", 1139], // rebase over codex-acp-package-swap: its version-probe tests union with the // doctor-install-reliability nvm/login-shell/semver tests — each side alone // stayed under the 1000 default; the union exceeds it. diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 8fca32e1eb..a1e6001351 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -10,7 +10,10 @@ use crate::managed_agents::{ pub(crate) struct KnownAcpRuntime { pub id: &'static str, pub label: &'static str, + /// Adapter binaries swept for resolution; the first is the default command. pub commands: &'static [&'static str], + /// Identity-only names (stored records, overrides) — recognized by + /// [`known_acp_runtime`], never resolved or spawned. pub aliases: &'static [&'static str], pub avatar_url: &'static str, /// Legacy MCP server binary field. Vestigial — all agents now use the bundled CLI. @@ -128,8 +131,10 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ KnownAcpRuntime { id: "claude", label: "Claude Code", - commands: &["claude-agent-acp", "claude-code-acp"], - aliases: &["claude-code", "claudecode"], + commands: &["claude-agent-acp"], + // The retired `claude-code-acp` adapter stays recognized for stored + // records from older installs, but is never resolved or spawned. + aliases: &["claude-code-acp", "claude-code", "claudecode"], avatar_url: CLAUDE_CODE_AVATAR_URL, mcp_command: None, mcp_hooks: false, diff --git a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs index 5140bb2cdd..6ce9a2361b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs @@ -11,8 +11,8 @@ use super::{effective_agent_command, known_acp_runtime}; /// inherits. /// /// Comparison is by RUNTIME IDENTITY, not raw string: a persona on the `claude` -/// runtime resolves to `claude-agent-acp`, but a client with only the -/// `claude-code-acp` adapter installed sends that command instead. Both map to +/// runtime resolves to `claude-agent-acp`, but a record created by an older +/// Buzz may echo the legacy `claude-code-acp` command instead. Both map to /// the same `claude` runtime, so neither is a real divergence — string equality /// would wrongly bake a pin. An unknown/custom command (no matching runtime) /// only inherits when it exactly equals the persona command. @@ -116,7 +116,7 @@ pub fn apply_agent_command_update( /// - DELIBERATE OVERRIDE (`harness_override` true): the user explicitly picked a /// runtime command in UI that exposes a runtime selector. This is a real pin /// and is preserved when it differs from the command inheritance would spawn, -/// including installed aliases such as `claude-code-acp`. +/// including legacy adapter names such as `claude-code-acp`. /// - MISSING-RUNTIME FALLBACK (`harness_override` false): the persona's runtime /// isn't installed locally, so `resolvePersonaRuntime` substitutes a fallback /// default. This is NOT a pin — baking it would freeze the agent on the fallback diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 8a8392a43a..28df53daeb 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -363,10 +363,10 @@ fn divergent_override_none_when_picked_matches_persona_runtime() { #[test] fn divergent_override_none_for_alternate_command_of_same_runtime() { - // A client with only `claude-code-acp` installed sends that command for - // a `claude` persona whose primary command is `claude-agent-acp`. Both - // map to the `claude` runtime, so it inherits — string equality would - // wrongly bake a pin (CRITICAL-3). + // A record created by an older Buzz echoes the legacy `claude-code-acp` + // command for a `claude` persona whose primary command is + // `claude-agent-acp`. Both map to the `claude` runtime, so it inherits — + // string equality would wrongly bake a pin (CRITICAL-3). let personas = vec![persona_with_runtime("p1", Some("claude"))]; assert_eq!( divergent_agent_command_override(Some("p1"), &personas, Some("claude-code-acp")), @@ -445,9 +445,9 @@ fn create_time_override_none_when_persona_runtime_installed() { #[test] fn create_time_override_preserves_selected_runtime_alias() { // A `claude` persona inherits the primary command `claude-agent-acp`, - // but discovery may select an installed alias such as `claude-code-acp`. - // When UI marks that create-time selection as explicit, preserve the - // alias so the first spawn uses a command known to be installed. + // but the UI may send a legacy adapter name such as `claude-code-acp` + // (e.g. copied forward from an older record). When UI marks that + // create-time selection as explicit, preserve it verbatim. let personas = vec![persona_with_runtime("p1", Some("claude"))]; assert_eq!( create_time_agent_command_override(Some("p1"), &personas, Some("claude-code-acp"), true), @@ -508,7 +508,7 @@ fn update_time_override_preserves_exact_persona_command_when_overriding() { #[test] fn update_time_override_preserves_alias_pin_when_overriding() { - // A `claude` persona with an installed `claude-code-acp` alias: picking + // A `claude` persona with the legacy `claude-code-acp` name: picking // it as a Custom pin is a deliberate divergence from the primary // command and must be preserved when overriding. let personas = vec![persona_with_runtime("p1", Some("claude"))]; From d0505b2329b491b9cb6e4dc919530ad93c66170e Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 11:47:41 +1000 Subject: [PATCH 09/18] refactor(desktop): de-jargon the Doctor panel's page-level framing copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Doctor page's framing copy — header description, section heading, loading and empty states — spoke in protocol terms ("ACP runtime commands", "Agent CLIs and ACP runtimes") that mean nothing to users scanning the page. Rewrite the five page-level strings in DoctorSettingsPanel.tsx to plain "AI agents" language: - Header description: "Check that everything needed to run AI agents is set up on this computer." (was "Verify the ACP runtime commands available to the desktop app.") - Section heading: "AI agents" (was "Agent CLIs and ACP runtimes") - Section description: "Whether each supported agent is installed and signed in." - Loading state: "Checking for installed agents…" - Empty state: "No supported AI agents found on this computer." Row-level and diagnostic detail deliberately keeps its ACP wording — the mono "ACP adapter:" path label, per-runtime status sentences ("ACP bridge bundled with Buzz.", the cli_missing variants), and all backend-sourced hints — since users digging into a specific runtime's row can handle the protocol name. The onboarding SetupStep framing and the config-nudge card title have the same split and are out of scope for this pass. Copy-only change: no test, fixture, or e2e spec asserts any of the five old strings. Verification: - tsc --noEmit: clean - biome check DoctorSettingsPanel.tsx: clean - node scripts/check-file-sizes.mjs + check-px-text.mjs: pass (file shrank by one line; no ledger change needed) Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../src/features/settings/ui/DoctorSettingsPanel.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index be4941e312..653e8056e2 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -523,7 +523,7 @@ export function DoctorSettingsPanel() {

) : null}
-

Agent CLIs and ACP runtimes

+

AI agents

- Installation status of supported agent CLIs and their ACP - runtimes. + Whether each supported agent is installed and signed in.

{runtimesQuery.isLoading ? (
- Looking for ACP runtimes... + Checking for installed agents…
) : runtimes.length > 0 ? ( runtimes.map((runtime) => ( @@ -583,7 +582,7 @@ export function DoctorSettingsPanel() { )) ) : (
- No known ACP runtimes found. + No supported AI agents found on this computer.
)} From 55a80e5c01d4cf954404b097c7469cb9ad4b6927 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 12:38:57 +1000 Subject: [PATCH 10/18] feat(desktop): redirect auth probes to bundled CLIs and retire the cli_missing gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the ACP bundles vendor full native CLIs (Claude Code inside @anthropic-ai/claude-agent-sdk-*, codex inside @openai/codex-*), agent sessions never touch a user-installed claude/codex. Yet the auth probes still resolved those CLIs from the user's PATH, and a missing user CLI put the runtime behind a CliMissing availability gate — blocking agents that would have run fine on the bundle. Point the probes at the vendored CLIs and delete the gate plus everything downstream of it (curl-pipe CLI install commands, Doctor's user-CLI path row, the cli_missing nudge card). The vendored CLIs are deliberately NOT staged into resources/acp/bin: that dir is the highest-priority segment of the agent-spawn PATH, and CLIs there would shadow the user's (possibly newer) claude/codex inside every session. Instead a probe-only manifest maps CLI names to paths inside the staged node trees. - prepare-acp-tools-resource.sh reads nativePackage/nativeExecutable from acp-tools.lock.json, verifies the vendored binary exists in the staged tree (chmod +x), and writes harness-clis.json next to node-runtime.json: {"clis":[{id,cli,path}]} with resource-root-relative paths. The manifest is gitignored like its sibling. - acp_tools::bundled_harness_cli() resolves a CLI name through the manifest — bare names only, relative non-escaping paths only, must be an executable file. Absent manifest/entry degrades to None. - discovery::resolve_probe_binary() tries the bundled CLI first, then the user's PATH — auth probes in the discovery sweep and readiness::cli_login_requirements both go through it. Auth state now reflects the CLI the sessions actually use, while dev builds without a staged bundle keep working via the PATH fallback. - classify_runtime: a resolving adapter is Available unconditionally; underlying_cli now only disambiguates AdapterMissing vs NotInstalled. CliMissing is removed from the desktop enum (runtime-only, never persisted) and from the TS union; buzz-acp's wire mirror keeps the variant so payloads from older desktops still parse (AdapterOutdated precedent), while the FE nudge validator rejects the retired literal so stale JSON can't render a card the UI has no branch for. - claude/codex catalog entries drop underlying_cli and the curl-pipe cli_install_commands; login provisioning copy stays as-is. - UI: cli_missing branches removed from Doctor (StatusIcon, RuntimeRow, user-CLI path row), SetupStep, persona pickers ("(CLI missing)" suffix + sort rank), AgentDefinitionDialog warning, and the nudge card; retired the cli_missing nudge screenshot spec. Verification: - cargo test --lib (desktop/src-tauri): 1436 passed, 0 failed (new bundled_harness_cli manifest/escape/absent tests, probe-runs-without- underlying-CLI readiness test, adapter-implies-Available discovery test) - cargo test -p buzz-acp --lib: 497 passed (cli_missing round-trip kept) - cargo clippy --lib --tests -D warnings (both crates): clean; cargo fmt --check (both crates): clean - tsc --noEmit: clean; biome check + file-size/px-text/pubkey guards: pass - pnpm test (desktop): 2792 passed, 0 failed (new validator-rejects- cli_missing test) - playwright doctor-states.spec.ts + doctor-cta-screenshots.spec.ts: 12 passed (Doctor shows no CLI path row; nudge cards unaffected) - prepare-acp-tools-resource.sh: manifest written; vendored claude --version → 2.1.205 (Claude Code); vendored codex login status → exit 0 Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .gitignore | 1 + crates/buzz-acp/src/setup_mode.rs | 4 + desktop/scripts/check-file-sizes.mjs | 13 +- desktop/scripts/prepare-acp-tools-resource.sh | 52 +++++++- .../src-tauri/src/managed_agents/acp_tools.rs | 124 +++++++++++++++++- .../src-tauri/src/managed_agents/discovery.rs | 62 +++++---- .../src/managed_agents/discovery/tests.rs | 6 +- .../src-tauri/src/managed_agents/readiness.rs | 38 +++--- desktop/src-tauri/src/managed_agents/types.rs | 4 +- .../agents/ui/AgentDefinitionDialog.tsx | 4 +- .../agents/ui/personaDialogPickers.test.mjs | 10 +- .../agents/ui/personaDialogPickers.tsx | 14 +- .../src/features/onboarding/ui/SetupStep.tsx | 13 -- .../ui/UserProfilePanelPersonaSubmit.test.mjs | 2 +- .../settings/ui/DoctorSettingsPanel.tsx | 58 +------- desktop/src/shared/api/types.ts | 6 +- desktop/src/shared/lib/configNudge.test.mjs | 24 ++++ desktop/src/shared/lib/configNudge.ts | 7 +- .../src/shared/ui/config-nudge-attachment.tsx | 2 - .../tests/e2e/doctor-cta-screenshots.spec.ts | 43 +----- desktop/tests/e2e/doctor-states.spec.ts | 13 +- 21 files changed, 308 insertions(+), 192 deletions(-) diff --git a/.gitignore b/.gitignore index bbf5358839..f23cb358ba 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ desktop/src-tauri/resources/acp/bin/* !desktop/src-tauri/resources/acp/bin/.gitkeep desktop/src-tauri/resources/acp/node/ desktop/src-tauri/resources/acp/node-runtime.json +desktop/src-tauri/resources/acp/harness-clis.json # sqlx offline query data (generated, not portable) .sqlx/ diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 16b89fce7e..45f943509a 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -63,8 +63,12 @@ pub(crate) enum AcpAvailabilityStatus { /// ACP adapter binary missing; underlying CLI may be present. AdapterMissing, /// ACP adapter binary is from the deprecated package (< 1.0). Reinstall required. + /// Retired on current desktops; kept so payloads from older app versions + /// still parse. AdapterOutdated, /// CLI binary missing; ACP adapter may be present. + /// Retired on current desktops (the bundled bridges vendor their own + /// CLI); kept so payloads from older app versions still parse. CliMissing, /// Neither adapter nor CLI found. NotInstalled, diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 16306e66d4..f748c12acc 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -214,7 +214,9 @@ const overrides = new Map([ // availability retired with the codex version gate (-1 line). // bundled-adapter-doctor-copy: adapterBundled field on // AcpRuntimeCatalogEntry (+2 lines). - ["src/shared/api/types.ts", 1062], + // bundled-cli-probes: "cli_missing" availability retired; +4 doc lines on + // AcpAvailabilityStatus explaining why the state no longer exists. + ["src/shared/api/types.ts", 1066], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog // shows required markers and credential amber rows (parity with @@ -271,13 +273,18 @@ const overrides = new Map([ // claude-code-acp-fallback-retirement: the legacy command moved from the // resolution sweep to identity-only aliases; +5 comment lines documenting // the commands/aliases split that move makes load-bearing. - ["src-tauri/src/managed_agents/discovery.rs", 1139], + // bundled-cli-probes: resolve_probe_binary (bundled-CLI-first probe + // resolution) + classify_runtime/underlying_cli doc rewrites for the + // CliMissing retirement (+8 lines). + ["src-tauri/src/managed_agents/discovery.rs", 1147], // rebase over codex-acp-package-swap: its version-probe tests union with the // doctor-install-reliability nvm/login-shell/semver tests — each side alone // stayed under the 1000 default; the union exceeds it. // bundle-acps: version-gate retirement deletes the probe/availability test // sections; ratcheting 1029 -> 825 (under the 1000 default; kept as a ratchet). - ["src-tauri/src/managed_agents/discovery/tests.rs", 825], + // bundled-cli-probes: CliMissing test reworked to assert Available when the + // adapter resolves without an underlying CLI (+2 comment lines). + ["src-tauri/src/managed_agents/discovery/tests.rs", 827], // identity-import-keyring: the identity resolution state machine's behavioral // matrix (46 tests over FakeIdentityStore — probe × marker × file cells, // adoption / read-back-corruption / marker-failure arms, recovery-mode diff --git a/desktop/scripts/prepare-acp-tools-resource.sh b/desktop/scripts/prepare-acp-tools-resource.sh index df7f840472..4e90265646 100755 --- a/desktop/scripts/prepare-acp-tools-resource.sh +++ b/desktop/scripts/prepare-acp-tools-resource.sh @@ -59,6 +59,16 @@ node_runtime_manifest="$resource_root/node-runtime.json" rm -f "$node_runtime_manifest" node_runtime_entries=() +# Manifest of the native harness CLIs vendored inside the bundled bridges +# (e.g. `claude` inside the claude-agent-sdk native package, `codex` inside +# @openai/codex). The app resolves auth probes against these pinned binaries +# instead of user installs. Kept OUT of resources/acp/bin on purpose: that +# dir is the highest-priority segment of the agent-spawn PATH, and staging +# `claude`/`codex` there would shadow the user's CLIs inside every session. +harness_cli_manifest="$resource_root/harness-clis.json" +rm -f "$harness_cli_manifest" +harness_cli_entries=() + codesign_if_darwin() { local file="$1" if [[ "$(uname -s)" == "Darwin" ]] && command -v codesign >/dev/null 2>&1; then @@ -66,7 +76,7 @@ codesign_if_darwin() { fi } -while IFS=$'\t' read -r id binary package version node_engine; do +while IFS=$'\t' read -r id binary package version node_engine native_package native_executable; do [[ -n "$id" ]] || continue install_dir="$cache_root/$target/$id/$version/npm" entrypoint="$install_dir/node_modules/$package/dist/index.js" @@ -84,6 +94,20 @@ while IFS=$'\t' read -r id binary package version node_engine; do fi write_node_wrapper "$resource_bin_dir/$binary" "../node/$id/node_modules/$package/dist/index.js" "$node_engine" node_runtime_entries+=("$id"$'\t'"$binary"$'\t'"$node_engine"$'\t'"$(acp_required_node_major "$node_engine")") + # Record the vendored native harness CLI (relative to the acp resource + # root) for the auth-probe manifest. Fail loudly if the lock names one + # that is not in the staged tree — a silent miss would quietly send auth + # probes back to unpinned user installs. + if [[ -n "$native_package" && -n "$native_executable" ]]; then + cli_relpath="node/$id/node_modules/$native_package/$native_executable" + cli_abspath="$resource_root/$cli_relpath" + if [[ ! -f "$cli_abspath" ]]; then + echo "Locked native harness CLI missing from staged tree: $cli_relpath" >&2 + exit 1 + fi + chmod +x "$cli_abspath" + harness_cli_entries+=("$id"$'\t'"$(basename "$native_executable")"$'\t'"$cli_relpath") + fi # Ad-hoc sign every Mach-O in the staged package, not just the main CLIs: # the codex native package also vendors executables like rg and zsh, and # unsigned nested Mach-Os are killed by Gatekeeper. Darwin only, so Linux @@ -104,7 +128,15 @@ for (const entry of data.tools ?? []) { if (entry.source !== "npm") { throw new Error(`Unsupported ACP tool source: ${entry.source}`); } - console.log([entry.id, entry.binary, entry.package, entry.version, entry.nodeEngine ?? ">=22"].join("\t")); + console.log([ + entry.id, + entry.binary, + entry.package, + entry.version, + entry.nodeEngine ?? ">=22", + entry.nativePackage ?? "", + entry.nativeExecutable ?? "", + ].join("\t")); } NODE ) @@ -125,4 +157,20 @@ fs.writeFileSync(manifestFile, `${JSON.stringify({ tools }, null, 2)}\n`); echo "Wrote ACP Node runtime manifest: $node_runtime_manifest" fi +# One manifest entry per vendored native harness CLI, keyed by the bare CLI +# name the app's auth probes use (`claude`, `codex`). Paths are relative to +# the acp resource root (the bin dir's parent). +if ((${#harness_cli_entries[@]} > 0)); then + node -e ' +const fs = require("node:fs"); +const [manifestFile, ...entries] = process.argv.slice(1); +const clis = entries.map((line) => { + const [id, cli, path] = line.split("\t"); + return { id, cli, path }; +}); +fs.writeFileSync(manifestFile, `${JSON.stringify({ clis }, null, 2)}\n`); +' "$harness_cli_manifest" ${harness_cli_entries[@]+"${harness_cli_entries[@]}"} + echo "Wrote ACP harness CLI manifest: $harness_cli_manifest" +fi + echo "Staged ACP tools resource: $resource_bin_dir" diff --git a/desktop/src-tauri/src/managed_agents/acp_tools.rs b/desktop/src-tauri/src/managed_agents/acp_tools.rs index 472e650fad..a328ecb3ca 100644 --- a/desktop/src-tauri/src/managed_agents/acp_tools.rs +++ b/desktop/src-tauri/src/managed_agents/acp_tools.rs @@ -26,6 +26,11 @@ const ACP_TOOLS_RESOURCE_DIR: &str = "resources/acp/bin"; /// Node runtime manifest staged by `desktop/scripts/prepare-acp-tools-resource.sh` /// next to the bundled bin dir. const NODE_RUNTIME_MANIFEST_FILE: &str = "node-runtime.json"; +/// Harness CLI manifest staged next to the bin dir: one entry per native CLI +/// vendored inside a bundled bridge (`claude` inside the claude-agent-sdk +/// native package, `codex` inside @openai/codex), with paths relative to the +/// acp resource root. +const HARNESS_CLI_MANIFEST_FILE: &str = "harness-clis.json"; static BUNDLED_ACP_TOOLS_DIR: OnceLock> = OnceLock::new(); @@ -79,6 +84,52 @@ pub(in crate::managed_agents) fn node_runtime_manifest_path(bin_dir: &Path) -> O .map(|dir| dir.join(NODE_RUNTIME_MANIFEST_FILE)) } +/// On-disk shape of `resources/acp/harness-clis.json`. +#[derive(serde::Deserialize)] +struct HarnessCliManifest { + #[serde(default)] + clis: Vec, +} + +#[derive(serde::Deserialize)] +struct HarnessCliEntry { + cli: String, + path: String, +} + +/// Resolve the pinned native harness CLI (`claude`, `codex`) vendored inside +/// a bundled bridge, via the staged `harness-clis.json` manifest. This is the +/// same binary the bridge itself runs, so auth probes against it can never +/// drift from what agent sessions see. Bare command names only, mirroring +/// [`command_in_bundled_dir`]. Deliberately separate from the bin dir: these +/// CLIs must never join the agent-spawn PATH, where they would shadow the +/// user's (possibly newer) installs inside every session. +pub(in crate::managed_agents) fn bundled_harness_cli(cli: &str) -> Option { + let bin_dir = bundled_acp_tools_dir()?; + bundled_harness_cli_in_root(bin_dir.parent()?, cli) +} + +fn bundled_harness_cli_in_root(acp_root: &Path, cli: &str) -> Option { + if command_looks_like_path(cli) { + return None; + } + let manifest = std::fs::read_to_string(acp_root.join(HARNESS_CLI_MANIFEST_FILE)).ok()?; + let manifest: HarnessCliManifest = serde_json::from_str(&manifest).ok()?; + let entry = manifest.clis.into_iter().find(|entry| entry.cli == cli)?; + let relative = PathBuf::from(entry.path); + // Manifest paths are acp-root-relative by contract; anything absolute or + // escaping the root is malformed and must not resolve. + let escapes = relative.is_absolute() + || relative + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)); + if escapes { + return None; + } + let candidate = acp_root.join(relative); + is_executable_file(&candidate).then_some(candidate) +} + fn command_in_dir(dir: &Path, command: &str) -> Option { if command_looks_like_path(command) { return None; @@ -101,7 +152,10 @@ fn bundled_acp_tools_dir_from_parts( #[cfg(test)] mod tests { - use super::{bundled_acp_tools_dir_from_parts, command_in_dir, path_is_in_dir}; + use super::{ + bundled_acp_tools_dir_from_parts, bundled_harness_cli_in_root, command_in_dir, + path_is_in_dir, + }; use std::ffi::OsStr; use std::path::Path; @@ -201,6 +255,74 @@ mod tests { assert!(command_in_dir(temp.path(), "custom/codex-acp").is_none()); } + #[cfg(unix)] + #[test] + fn bundled_harness_cli_resolves_manifest_relative_path() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let vendored = temp.path().join("node/claude-acp/node_modules/sdk-native"); + fs::create_dir_all(&vendored).expect("vendored dir"); + let cli = vendored.join("claude"); + fs::write(&cli, "#!/bin/sh\n").expect("write cli"); + fs::set_permissions(&cli, fs::Permissions::from_mode(0o755)).expect("chmod cli"); + fs::write( + temp.path().join("harness-clis.json"), + r#"{"clis":[{"id":"claude-acp","cli":"claude","path":"node/claude-acp/node_modules/sdk-native/claude"}]}"#, + ) + .expect("write manifest"); + + assert_eq!( + bundled_harness_cli_in_root(temp.path(), "claude").as_deref(), + Some(cli.as_path()), + ); + assert!( + bundled_harness_cli_in_root(temp.path(), "codex").is_none(), + "a CLI absent from the manifest must not resolve" + ); + } + + #[test] + fn bundled_harness_cli_without_manifest_is_none() { + let temp = tempfile::tempdir().expect("temp dir"); + assert!(bundled_harness_cli_in_root(temp.path(), "claude").is_none()); + } + + #[cfg(unix)] + #[test] + fn bundled_harness_cli_rejects_escaping_paths_and_path_like_names() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let outside = temp.path().join("outside"); + fs::write(&outside, "#!/bin/sh\n").expect("write outside"); + fs::set_permissions(&outside, fs::Permissions::from_mode(0o755)).expect("chmod outside"); + let root = temp.path().join("acp"); + fs::create_dir_all(&root).expect("acp root"); + fs::write( + root.join("harness-clis.json"), + format!( + r#"{{"clis":[{{"id":"a","cli":"escape","path":"../outside"}},{{"id":"b","cli":"absolute","path":"{}"}}]}}"#, + outside.display() + ), + ) + .expect("write manifest"); + + assert!( + bundled_harness_cli_in_root(&root, "escape").is_none(), + "a ..-escaping manifest path must not resolve" + ); + assert!( + bundled_harness_cli_in_root(&root, "absolute").is_none(), + "an absolute manifest path must not resolve" + ); + // Path-like probe names bypass the manifest entirely — they name a + // specific binary and must fall through to regular resolution. + assert!(bundled_harness_cli_in_root(&root, "some/claude").is_none()); + } + #[cfg(unix)] #[test] fn command_in_dir_skips_non_executable_files() { diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index a1e6001351..0c993702f9 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -21,7 +21,10 @@ pub(crate) struct KnownAcpRuntime { pub mcp_command: Option<&'static str>, /// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent. pub mcp_hooks: bool, - /// CLI binary that indicates partial install (e.g. `"claude"` when `claude-agent-acp` is missing). + /// CLI binary whose presence distinguishes `AdapterMissing` from + /// `NotInstalled` when the adapter is absent. `None` for the bundled + /// bridges (claude, codex): they ship their own vendored CLI, so the + /// user's install neither gates availability nor serves auth probes. pub underlying_cli: Option<&'static str>, /// Shell commands to install the runtime CLI itself (run sequentially). pub cli_install_commands: &'static [&'static str], @@ -138,12 +141,12 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ avatar_url: CLAUDE_CODE_AVATAR_URL, mcp_command: None, mcp_hooks: false, - underlying_cli: Some("claude"), - cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], + underlying_cli: None, + cli_install_commands: &[], adapter_install_commands: &[], install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", - cli_install_hint: "Install the Claude Code CLI via the official install script.", - adapter_install_hint: "The Claude Code ACP adapter ships with the Buzz desktop app.", + cli_install_hint: "", + adapter_install_hint: "The Claude Code ACP adapter ships with the Buzz desktop app. Reinstall Buzz to restore it.", skill_dir: Some(".claude/skills"), supports_acp_model_switching: false, model_env_var: None, @@ -157,7 +160,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ max_tokens_env_var: None, context_limit_env_var: None, required_normalized_fields: &[], - login_hint: Some("Run the Claude CLI to complete authentication."), + login_hint: Some("Run the Claude CLI to complete authentication (install it first if needed)."), auth_probe_args: Some(&["claude", "auth", "status"]), }, KnownAcpRuntime { @@ -168,12 +171,12 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ avatar_url: CODEX_AVATAR_URL, mcp_command: Some("buzz-dev-mcp"), mcp_hooks: false, - underlying_cli: Some("codex"), - cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], + underlying_cli: None, + cli_install_commands: &[], adapter_install_commands: &[], install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", - cli_install_hint: "Install the Codex CLI via the official install script.", - adapter_install_hint: "The Codex ACP adapter ships with the Buzz desktop app.", + cli_install_hint: "", + adapter_install_hint: "The Codex ACP adapter ships with the Buzz desktop app. Reinstall Buzz to restore it.", skill_dir: Some(".codex/skills"), supports_acp_model_switching: false, model_env_var: None, @@ -187,7 +190,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ max_tokens_env_var: None, context_limit_env_var: None, required_normalized_fields: &[], - login_hint: Some("Run `codex login` to authenticate."), + login_hint: Some("Run `codex login` to authenticate (install the Codex CLI first if needed)."), // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, @@ -838,6 +841,16 @@ pub(crate) fn is_npm_global_install(cmd: &str) -> bool { t.starts_with("npm install -g ") || t.starts_with("npm i -g ") } +/// Resolve the binary for a CLI auth probe (`claude`, `codex`): the pinned +/// CLI vendored inside the bundled bridge wins — it is the exact binary agent +/// sessions run and reads the same credential store — falling back to the +/// user's install for builds without staged bundle resources. Not routed +/// through `resolve_command`: probe binaries must stay out of the resolve +/// cache and off the agent-spawn PATH. +pub(crate) fn resolve_probe_binary(cli: &str) -> Option { + super::acp_tools::bundled_harness_cli(cli).or_else(|| resolve_command(cli)) +} + /// Run a CLI auth probe with a 10-second process-level timeout. /// /// Spawns the probe CLI as a child process. Stdout and stderr are drained on @@ -955,25 +968,21 @@ pub fn missing_command_message(command: &str, role: &str) -> String { ) } +/// A resolving adapter is available, full stop — whether the runtime is +/// usable beyond that is an auth question (`auth_status`), not an install +/// question. The retired `CliMissing` state gated availability on a user +/// CLI install the bundled bridges no longer need. pub(crate) fn classify_runtime( adapter_result: Option<(&str, PathBuf)>, underlying_cli: Option<&str>, underlying_cli_found: bool, ) -> (AcpAvailabilityStatus, Option, Option) { if let Some((cmd, path)) = adapter_result { - if underlying_cli.is_some() && !underlying_cli_found { - ( - AcpAvailabilityStatus::CliMissing, - Some(cmd.to_string()), - Some(path.display().to_string()), - ) - } else { - ( - AcpAvailabilityStatus::Available, - Some(cmd.to_string()), - Some(path.display().to_string()), - ) - } + ( + AcpAvailabilityStatus::Available, + Some(cmd.to_string()), + Some(path.display().to_string()), + ) } else if underlying_cli.is_some() && underlying_cli_found { (AcpAvailabilityStatus::AdapterMissing, None, None) } else { @@ -1031,7 +1040,6 @@ pub fn discover_acp_runtimes() -> Vec { let adapter_hint = runtime.adapter_install_hint; let install_hint = match availability { AcpAvailabilityStatus::Available => cli_hint.to_string(), - AcpAvailabilityStatus::CliMissing => cli_hint.to_string(), AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(), AcpAvailabilityStatus::NotInstalled => { if !cli_hint.is_empty() && !adapter_hint.is_empty() { @@ -1087,8 +1095,8 @@ pub fn discover_acp_runtimes() -> Vec { return None; } let probe_args = partial.runtime.auth_probe_args?; - // Need the resolved binary path for the CLI (e.g. the actual `claude` binary). - let binary_path = resolve_command(probe_args[0])?; + // Probe the bundled CLI when the app ships one, else the user's. + let binary_path = resolve_probe_binary(probe_args[0])?; let probe_args_owned: Vec = probe_args.iter().map(|s| s.to_string()).collect(); let handle = std::thread::spawn(move || { diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 28df53daeb..ceac22fb81 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -176,13 +176,15 @@ fn classifies_not_installed_when_no_underlying_cli() { } #[test] -fn classifies_cli_missing_when_adapter_found_but_cli_absent() { +fn classifies_available_when_adapter_found_even_without_underlying_cli() { + // The retired CliMissing gate must not come back: a resolving adapter is + // available regardless of whether an underlying CLI is on the user PATH. let (status, cmd, path) = classify_runtime( Some(("codex-acp", PathBuf::from("/opt/homebrew/bin/codex-acp"))), Some("codex"), false, ); - assert_eq!(status, AcpAvailabilityStatus::CliMissing); + assert_eq!(status, AcpAvailabilityStatus::Available); assert_eq!(cmd.as_deref(), Some("codex-acp")); assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp")); } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 771f98a515..3dd959fab7 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -46,7 +46,7 @@ use crate::managed_agents::{ agent_env::baked_build_env, config_bridge::read_goose_file_config, discovery::{ - classify_runtime, find_command, known_acp_runtime, resolve_command, KnownAcpRuntime, + classify_runtime, find_command, known_acp_runtime, resolve_probe_binary, KnownAcpRuntime, }, env_vars::merged_user_env, global_config::GlobalAgentConfig, @@ -502,7 +502,10 @@ fn cli_login_requirements( .iter() .find_map(|cmd| find_command(cmd).map(|path| (*cmd, path))); - // Check whether the underlying CLI itself (e.g. "claude", "codex") is on PATH. + // Check whether the underlying CLI is on PATH — only set for runtimes + // whose CLI is a separate install (not the bundled claude/codex bridges, + // which vendor their own); it distinguishes AdapterMissing from + // NotInstalled below. let underlying_cli_found = runtime .underlying_cli .map(|cli| find_command(cli).is_some()) @@ -513,10 +516,12 @@ fn cli_login_requirements( match availability { AcpAvailabilityStatus::Available => { - // Both adapter and CLI are present — probe login status. - // Resolve via the full login-shell PATH so the probe works in a - // packaged macOS DMG where the GUI PATH lacks npm/homebrew. - let Some(binary_path) = resolve_command(probe_args[0]) else { + // Adapter present — probe login status against the bundled CLI + // when the app ships one (the same pinned binary agent sessions + // run), else the user's install resolved via the full login-shell + // PATH so the probe works in a packaged macOS DMG where the GUI + // PATH lacks npm/homebrew. + let Some(binary_path) = resolve_probe_binary(probe_args[0]) else { // Unexpectedly not resolvable (race or PATH edge case). return vec![Requirement::CliLogin { probe_args: probe_args.iter().map(|s| s.to_string()).collect(), @@ -1040,10 +1045,11 @@ mod tests { } #[test] - fn cli_login_requirements_cli_missing_emits_cli_missing() { + fn cli_login_requirements_probe_runs_even_without_underlying_cli() { // Adapter present (use the running test binary as a portable stand-in), - // underlying CLI absent. - // → CliMissing state → no probe run → CliLogin{CliMissing}. + // underlying CLI absent. The retired CliMissing gate would have skipped + // the probe; now the adapter alone means Available, the probe runs + // (here: exit 0 → logged in) and no requirement is emitted. let exe = present_binary_str(); let rt = make_cli_runtime( static_commands(vec![exe]), // adapter found via absolute path @@ -1051,19 +1057,9 @@ mod tests { ); let reqs = cli_login_requirements(&[exe, "--list"], "install the CLI", &rt); assert!( - !reqs.is_empty(), - "CLI missing must produce a CliLogin requirement" + reqs.is_empty(), + "adapter present must probe login regardless of the user CLI; got {reqs:?}" ); - if let Requirement::CliLogin { - ref availability, .. - } = reqs[0] - { - assert_eq!( - *availability, - crate::managed_agents::AcpAvailabilityStatus::CliMissing, - "adapter present, CLI absent → CliMissing" - ); - } } #[test] diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index a56027bf5c..7c177bc4bc 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -525,12 +525,14 @@ pub struct ManagedAgentLogResponse { pub log_path: String, } +/// The retired `CliMissing` variant (adapter present, user CLI absent) is +/// gone: the bundled bridges vendor their own CLI, so a resolving adapter is +/// available regardless of user installs — sign-in state is `AuthStatus`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AcpAvailabilityStatus { Available, AdapterMissing, - CliMissing, NotInstalled, } diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index ff74c1dca4..17c47ff2cd 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -552,9 +552,7 @@ export function AgentDefinitionDialog({

{selectedRuntime.availability === "adapter_missing" ? `${selectedRuntime.label} CLI is installed but the ACP adapter is missing.` - : selectedRuntime.availability === "cli_missing" - ? `${selectedRuntime.label} ACP adapter is installed but the CLI is missing.` - : `${selectedRuntime.label} is not installed.`}{" "} + : `${selectedRuntime.label} is not installed.`}{" "} Visit Settings > Doctor to set it up.

) : null; diff --git a/desktop/src/features/agents/ui/personaDialogPickers.test.mjs b/desktop/src/features/agents/ui/personaDialogPickers.test.mjs index 5cc49a9896..de81a57264 100644 --- a/desktop/src/features/agents/ui/personaDialogPickers.test.mjs +++ b/desktop/src/features/agents/ui/personaDialogPickers.test.mjs @@ -97,7 +97,7 @@ test("getDefaultPersonaRuntime falls back to goose when buzz-agent is unavailabl test("getDefaultPersonaRuntime returns first available when neither buzz-agent nor goose is available", () => { const runtimes = [ makeRuntime("buzz-agent", "adapter_missing"), - makeRuntime("goose", "cli_missing"), + makeRuntime("goose", "not_installed"), makeRuntime("claude"), ]; const result = getDefaultPersonaRuntime(runtimes); @@ -111,7 +111,7 @@ test("getDefaultPersonaRuntime returns null for an empty list", () => { test("getDefaultPersonaRuntime returns null when no runtime is available", () => { const runtimes = [ makeRuntime("buzz-agent", "not_installed"), - makeRuntime("goose", "cli_missing"), + makeRuntime("goose", "adapter_missing"), ]; assert.equal(getDefaultPersonaRuntime(runtimes), null); }); @@ -168,11 +168,7 @@ test("getPersonaModelOptions for buzz-agent with no provider returns default mod // is non-null (so the UI surfaces the reason) for each unavailability reason. test("formatModelDiscoveryErrorStatus returns a non-null status for runtime unavailable errors", () => { - for (const availability of [ - "adapter_missing", - "cli_missing", - "not_installed", - ]) { + for (const availability of ["adapter_missing", "not_installed"]) { const status = formatModelDiscoveryErrorStatus( new Error(`Runtime not available: ${availability}`), "anthropic", diff --git a/desktop/src/features/agents/ui/personaDialogPickers.tsx b/desktop/src/features/agents/ui/personaDialogPickers.tsx index f027408b81..fb9d0baacc 100644 --- a/desktop/src/features/agents/ui/personaDialogPickers.tsx +++ b/desktop/src/features/agents/ui/personaDialogPickers.tsx @@ -386,11 +386,9 @@ export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) { const suffix = runtime.availability === "adapter_missing" ? " (adapter missing)" - : runtime.availability === "cli_missing" - ? " (CLI missing)" - : runtime.availability === "not_installed" - ? " (not installed)" - : ""; + : runtime.availability === "not_installed" + ? " (not installed)" + : ""; return `${runtime.label}${suffix}`; } @@ -400,12 +398,10 @@ function runtimeAvailabilitySortRank( switch (availability) { case "available": return 0; - case "cli_missing": - return 1; case "not_installed": - return 2; + return 1; case "adapter_missing": - return 3; + return 2; } } diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 3559538086..ede6aa2501 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -353,19 +353,6 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { ); } - if (runtime.availability === "cli_missing") { - return ( - <> -

- ACP adapter detected; CLI missing. -

-

- {runtime.installHint} -

- - ); - } - return ( <>

diff --git a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs index cf21b6f6a8..2fea5986c5 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs @@ -108,7 +108,7 @@ test("validateLinkedAgentRuntimeEdit rejects unavailable linked-agent runtime ch input: updateInput({ runtime: "claude" }), managedAgent: agent(), previousPersona: persona({ runtime: "goose" }), - runtimes: [runtime({ availability: "cli_missing", command: null })], + runtimes: [runtime({ availability: "not_installed", command: null })], }), "Claude Code is not available. Install it before saving this linked agent.", ); diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 653e8056e2..0d705d568f 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -38,8 +38,6 @@ function StatusIcon({ return ; case "adapter_missing": return ; - case "cli_missing": - return ; case "not_installed": return ; } @@ -122,8 +120,6 @@ function InstallActions({ /** * Node.js callout when required, or the install actions when it is not. * Used for both `adapter_missing` and `not_installed` availability states. - * The `cli_missing` branch is intentionally excluded — its install path does - * not involve npm, so no Node.js gate applies. */ function NodeRequiredOrInstall({ hasError, @@ -180,8 +176,7 @@ function RuntimeRow({ "flex min-h-16 items-start gap-3 px-4 py-3 text-sm", runtime.availability === "available" ? "bg-background/60" - : runtime.availability === "adapter_missing" || - runtime.availability === "cli_missing" + : runtime.availability === "adapter_missing" ? "bg-amber-500/5" : "bg-muted/20", )} @@ -218,23 +213,12 @@ function RuntimeRow({

) : null} - {runtime.underlyingCliPath && - runtime.underlyingCliPath !== runtime.binaryPath ? ( -
-

- CLI:{" "} - {runtime.underlyingCliPath} -

- {/* The bundled bridge's resource-dir path is noise — the - "ACP bridge bundled with Buzz." line above covers it. */} - {runtime.adapterBundled ? null : ( -

- ACP adapter:{" "} - {runtime.binaryPath} -

- )} -
- ) : runtime.adapterBundled ? null : ( + {/* The bundled bridge's resource-dir path is noise — the + "ACP bridge bundled with Buzz." line above covers it. The + user-CLI path row is retired with the cli_missing gate: the + bundled bridges vendor their own CLI, so no runtime reports a + separate CLI path anymore. */} + {runtime.adapterBundled ? null : ( <>

{runtime.binaryPath} @@ -285,34 +269,6 @@ function RuntimeRow({ runtime={runtime} /> - ) : runtime.availability === "cli_missing" ? ( - <> -

- {runtime.adapterBundled ? ( - <> - ACP bridge bundled with Buzz, but the {runtime.label} CLI is - not installed. - - ) : ( - <> - ACP adapter found at{" "} - - {runtime.binaryPath ?? "unknown path"} - {" "} - but the {runtime.label} CLI is not installed. - - )} -

-

- {runtime.installHint} -

- - ) : ( <>

diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 5f0fa33a83..307c0fa6a3 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -548,10 +548,14 @@ export type GitBashPrerequisite = { installHint: string; }; +/** + * The retired "cli_missing" state (adapter present, user CLI absent) is gone: + * the bundled bridges vendor their own CLI, so a resolving adapter is + * available regardless of user installs — sign-in state lives in AuthStatus. + */ export type AcpAvailabilityStatus = | "available" | "adapter_missing" - | "cli_missing" | "not_installed"; /** Authentication/login status for a CLI-based ACP runtime. */ diff --git a/desktop/src/shared/lib/configNudge.test.mjs b/desktop/src/shared/lib/configNudge.test.mjs index 8e1603e235..c38e95aacc 100644 --- a/desktop/src/shared/lib/configNudge.test.mjs +++ b/desktop/src/shared/lib/configNudge.test.mjs @@ -103,6 +103,30 @@ test("extractConfigNudge rejects retired adapter_outdated availability", () => { ); }); +test("extractConfigNudge rejects retired cli_missing availability", () => { + // The cli_missing gate was retired when auth probes moved to the CLIs + // vendored inside the bundled bridges — a user CLI install no longer gates + // availability. Stale nudge JSON emitted by an older app version must not + // parse into a card the current UI has no rendering for. + const payload = { + agent_name: "Fizz", + agent_pubkey: FIZZ_PUBKEY, + requirements: [ + { + surface: "cli_login", + probe_args: ["claude", "auth", "status"], + setup_copy: "install the Claude CLI", + availability: "cli_missing", + }, + ], + }; + assert.equal( + extractConfigNudge(withSentinel("prose", payload)), + null, + "retired cli_missing availability must be rejected by the validator", + ); +}); + test("extractConfigNudge returns null for cli_login without availability", () => { // availability is required — old-format payloads (no availability field) // must not parse so stale nudge JSON from before the Doctor-CTA update diff --git a/desktop/src/shared/lib/configNudge.ts b/desktop/src/shared/lib/configNudge.ts index 901bb22737..5ceb8314d6 100644 --- a/desktop/src/shared/lib/configNudge.ts +++ b/desktop/src/shared/lib/configNudge.ts @@ -32,8 +32,7 @@ export type ConfigNudgeRequirement = * Determines which message and CTA the nudge card shows: * - "available" → tooling installed, needs login * - "adapter_missing" → CLI installed but ACP adapter missing - * - "cli_missing" → ACP adapter installed but CLI missing - * - "not_installed" → neither adapter nor CLI found + * - "not_installed" → no adapter found */ availability: AcpAvailabilityStatus; } @@ -139,9 +138,11 @@ function isConfigNudgeRequirement(v: unknown): v is ConfigNudgeRequirement { Array.isArray(r.probe_args) && r.probe_args.every((a) => typeof a === "string") && typeof r.setup_copy === "string" && + // Retired literals ("adapter_outdated", "cli_missing") emitted by + // older app versions are rejected here so stale nudge JSON cannot + // render a card the current UI has no branch for. (r.availability === "available" || r.availability === "adapter_missing" || - r.availability === "cli_missing" || r.availability === "not_installed") ); case "git_bash": diff --git a/desktop/src/shared/ui/config-nudge-attachment.tsx b/desktop/src/shared/ui/config-nudge-attachment.tsx index f21e5a3291..b7ebd6ff35 100644 --- a/desktop/src/shared/ui/config-nudge-attachment.tsx +++ b/desktop/src/shared/ui/config-nudge-attachment.tsx @@ -103,8 +103,6 @@ function cliLoginMessage( switch (req.availability) { case "not_installed": return `${harness} isn't installed`; - case "cli_missing": - return `${harness} CLI is missing`; case "adapter_missing": return `${harness} ACP adapter isn't installed`; case "available": diff --git a/desktop/tests/e2e/doctor-cta-screenshots.spec.ts b/desktop/tests/e2e/doctor-cta-screenshots.spec.ts index 9bd2732175..e0ab3ffe43 100644 --- a/desktop/tests/e2e/doctor-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/doctor-cta-screenshots.spec.ts @@ -257,44 +257,7 @@ test.describe("doctor CTA nudge card screenshots", () => { }); }); - /** - * 04 — cli_missing state: ACP adapter present but underlying CLI absent. - * Shows "claude CLI is missing" copy. - */ - test("04-cli-login-cli-missing-state", async ({ page }) => { - await installMockBridge(page, { - managedAgents: [ - { - pubkey: AGENT_PUBKEY, - name: AGENT_NAME, - status: "stopped" as const, - channelNames: ["general"], - }, - ], - }); - - await page.goto("/", { waitUntil: "domcontentloaded" }); - - const content = makeNudgeSentinel(AGENT_NAME, AGENT_PUBKEY, [ - { - surface: "cli_login", - probe_args: ["claude"], - setup_copy: "install the Claude CLI", - availability: "cli_missing", - }, - ]); - - await injectNudgeAndNavigate(page, content); - - const card = page.locator("[data-config-nudge]").last(); - await expect(card).toBeVisible({ timeout: 10_000 }); - await expect(card.getByText(/CLI is missing/)).toBeVisible(); - - await card.scrollIntoViewIfNeeded(); - await settleAnimations(page); - - await card.screenshot({ - path: `${SHOTS}/04-cli-login-cli-missing-state.png`, - }); - }); + // The former 04-cli-login-cli-missing-state test retired with the + // cli_missing gate: the validator now rejects that availability literal, + // so no card renders for it (see configNudge.test.mjs). }); diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index 5bcb893904..20e56977df 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -50,6 +50,9 @@ const BUZZ_AGENT_AVAILABLE = { /** * Claude available and logged in — used as a neutral entry when claude is not * the runtime under test, and as the base for the auth states being tested. + * No `underlying_cli_path` and no auto-install: the bundled bridge vendors + * its own CLI, so the backend reports neither for claude since the + * cli_missing gate was retired. */ const CLAUDE_AVAILABLE_LOGGED_IN = { id: "claude", @@ -63,8 +66,8 @@ const CLAUDE_AVAILABLE_LOGGED_IN = { install_hint: "", install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", - can_auto_install: true, - underlying_cli_path: "/usr/local/bin/claude", + can_auto_install: false, + underlying_cli_path: null, node_required: false, auth_status: { status: "logged_in" }, }; @@ -150,7 +153,6 @@ test.describe("Doctor panel state screenshots", () => { availability: "available", command: "codex-acp", binary_path: "/usr/local/bin/codex-acp", - underlying_cli_path: "/usr/local/bin/codex", auth_status: { status: "logged_out" }, login_hint: "Run `codex login` to authenticate.", }, @@ -450,7 +452,8 @@ test.describe("Doctor panel state screenshots", () => { /** * 09 — available runtime whose adapter is the bridge bundled with the app: * the row says "ACP bridge bundled with Buzz" instead of rendering the - * resource-dir path; the user's CLI path still renders. + * resource-dir path, and no CLI path renders — the bundled bridge vendors + * its own CLI, so the user-CLI row retired with the cli_missing gate. */ test("09-bundled-adapter", async ({ page }) => { const bundledPath = @@ -474,7 +477,7 @@ test.describe("Doctor panel state screenshots", () => { const row = page.getByTestId("doctor-runtime-claude"); await expect(row).toBeVisible({ timeout: 10_000 }); await expect(row).toContainText("ACP bridge bundled with Buzz."); - await expect(row).toContainText("/usr/local/bin/claude"); + await expect(row).not.toContainText("CLI:"); await expect(row).not.toContainText(bundledPath); await expect(row).not.toContainText("installed on PATH"); From 2b94ce2e5fd6a1e66ce8c7d9e5cd8a305831e51a Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 14:03:34 +1000 Subject: [PATCH 11/18] refactor(desktop): shorten bundled-adapter Doctor copy to "Bundled with Buzz." MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Doctor runtime row's status line for a bundled adapter read "ACP bridge bundled with Buzz." — the protocol jargon adds nothing in a row that already names the runtime, and the sentence only needs to say where the tool comes from. Shorten it to "Bundled with Buzz." (keeping the trailing period to match the sibling "Available via ..." line). Updated in step: the DoctorSettingsPanel comment quoting the line, the 09-bundled-adapter e2e assertion and its doc comment, and the AcpRuntimeCatalogEntry::adapter_bundled doc comment that cites the UI copy. No other test or fixture asserts the old string; the node-runtime check's backend-sourced messages keep their ACP wording per the row-detail/page-framing split from the de-jargon pass. Verification: - tsc --noEmit: clean; biome check on both touched TS files: clean - cargo fmt --check (desktop/src-tauri): clean (doc-comment-only change) - playwright doctor-states.spec.ts: 9 passed, including 09-bundled-adapter against the new copy Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/src-tauri/src/managed_agents/types.rs | 4 ++-- desktop/src/features/settings/ui/DoctorSettingsPanel.tsx | 4 ++-- desktop/tests/e2e/doctor-states.spec.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 7c177bc4bc..954ca975f3 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -567,8 +567,8 @@ pub struct AcpRuntimeCatalogEntry { pub command: Option, pub binary_path: Option, /// true when `binary_path` is the pinned bridge bundled with the app - /// rather than a user install. The Doctor UI says "ACP bridge bundled - /// with Buzz" instead of rendering the resource-dir path. + /// rather than a user install. The Doctor UI says "Bundled with Buzz" + /// instead of rendering the resource-dir path. pub adapter_bundled: bool, pub default_args: Vec, pub mcp_command: Option, diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 0d705d568f..2649b4520b 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -202,7 +202,7 @@ function RuntimeRow({ <>

{runtime.adapterBundled - ? "ACP bridge bundled with Buzz." + ? "Bundled with Buzz." : `Available via ${describeResolvedCommand(runtime.command, runtime.binaryPath)}.`}

{runtime.defaultArgs.length > 0 ? ( @@ -214,7 +214,7 @@ function RuntimeRow({

) : null} {/* The bundled bridge's resource-dir path is noise — the - "ACP bridge bundled with Buzz." line above covers it. The + "Bundled with Buzz." line above covers it. The user-CLI path row is retired with the cli_missing gate: the bundled bridges vendor their own CLI, so no runtime reports a separate CLI path anymore. */} diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index 20e56977df..272b844ade 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -451,7 +451,7 @@ test.describe("Doctor panel state screenshots", () => { /** * 09 — available runtime whose adapter is the bridge bundled with the app: - * the row says "ACP bridge bundled with Buzz" instead of rendering the + * the row says "Bundled with Buzz" instead of rendering the * resource-dir path, and no CLI path renders — the bundled bridge vendors * its own CLI, so the user-CLI row retired with the cli_missing gate. */ @@ -476,7 +476,7 @@ test.describe("Doctor panel state screenshots", () => { const row = page.getByTestId("doctor-runtime-claude"); await expect(row).toBeVisible({ timeout: 10_000 }); - await expect(row).toContainText("ACP bridge bundled with Buzz."); + await expect(row).toContainText("Bundled with Buzz."); await expect(row).not.toContainText("CLI:"); await expect(row).not.toContainText(bundledPath); await expect(row).not.toContainText("installed on PATH"); From b52a665a7449db462255a7ac79812cd337d10891 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 14:52:17 +1000 Subject: [PATCH 12/18] refactor(desktop): retire dead install-gate, npm-preflight, and codex drift machinery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the bundling series emptied every runtime's adapter_install_commands, three pieces of machinery in the earlier cleanup plan were dead code. Delete them, and fix the Phase-3 install verification hint that the emptying broke for goose: - node_required install gate: runtime_needs_npm was constant false, so the node_required computation never fired and Doctor's amber "Node.js is required to install this adapter" callout could never render. Deleted the trigger (discovery.rs), the wire field (types.rs, tauri.ts, types.ts), the callout component + the nodeRequired half of the Install-button gate (DoctorSettingsPanel.tsx), and the fixture fields (e2eBridge.ts, agentReadiness.test.mjs, doctor-states.spec.ts). E2e test 04-node-required force-fed the retired state and is deleted; the bundled bridges' real Node.js requirement bites at spawn, not install, and is covered by the node-runtime Doctor section (07-node-runtime-warn). The cleanup plan's per-row spawn-time callout derived from nodeRuntimeCheck remains open as a follow-up. - npm preflight/EACCES machinery: Phase 2 of install_acp_runtime_blocking applied npm handling only to adapter_install_commands — empty everywhere. Deleted is_npm_global_install (+7 tests), npm_eacces_hint/npm_eacces_guidance/ NPM_MISSING_HINT (+5 tests), npm_preflight_check/resolve_npm_prefix/ npm_install_target_is_writable/unix_is_writable (+3 tests), the Phase-2 npm branches, and the stale Phase-1 preflight note. - codex adapter-availability drift stamp: built to catch a manual npm install/downgrade flipping the adapter under a running agent — impossible now that bare-name resolution prefers the bundle. Deleted the cache + availability_drift predicate (+5 tests), the codex-only discovery warm, the spawn-time stamp, the availability_drift half of needs_restart (hash_drift stays), and the ManagedAgentProcess field with its finish_spawn plumbing. Goose verify hint fix: adapter_verification_step received bundled = adapter_install_commands.is_empty(), now true for all four runtimes — a goose curl install that succeeded but left `goose` unresolvable claimed "The Goose ACP adapter ships with the Buzz desktop app… Reinstall Buzz", wrong on both counts. The new runtime_adapter_is_bundled seam requires cli_install_commands AND adapter_install_commands to be empty (goose has a curl CLI installer; claude/codex/buzz-agent have neither), with catalog-driven tests pinning goose to the check-the-step-output hint. That hint also drops its "and your npm global prefix" tail — no npm-installed adapters remain in the catalog. File-size ledger ratcheted down to bank the deletions: agent_discovery.rs 1410 -> 1021, discovery.rs 1147 -> 1046, runtime.rs 2216 -> 2079, tauri.ts 1342 -> 1340, types.ts 1066 -> 1064. Covers sections 1 and 3 of the post-bundling cleanup note; the adapter_missing retirement (section 2) and copy/fixture staleness (section 4) are separate follow-ups. Verification: - cargo test --lib (desktop/src-tauri): 1423 passed, 0 failed (1436 at head - 15 deleted npm/drift tests + 2 new bundled-flag tests) - cargo clippy --lib --tests -D warnings: clean; cargo fmt --check: clean - tsc --noEmit: clean; biome: no new diagnostics - pnpm test (desktop): 2792 passed, 0 failed - playwright doctor-states.spec.ts: 8 passed; doctor-cta-screenshots.spec.ts: 3 passed - node scripts/check-file-sizes.mjs + check-px-text.mjs: pass Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/scripts/check-file-sizes.mjs | 23 +- .../src-tauri/src/commands/agent_discovery.rs | 481 ++---------------- .../src-tauri/src/managed_agents/discovery.rs | 101 ---- .../src/managed_agents/process_lifecycle.rs | 2 - .../src-tauri/src/managed_agents/runtime.rs | 31 +- desktop/src-tauri/src/managed_agents/types.rs | 9 - .../onboarding/ui/agentReadiness.test.mjs | 1 - .../settings/ui/DoctorSettingsPanel.tsx | 46 +- desktop/src/shared/api/tauri.ts | 2 - desktop/src/shared/api/types.ts | 2 - desktop/src/testing/e2eBridge.ts | 4 - desktop/tests/e2e/doctor-states.spec.ts | 50 +- 12 files changed, 76 insertions(+), 676 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index f748c12acc..946aa771df 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -120,7 +120,10 @@ const overrides = new Map([ // record_provider param + applies persona_field_with_record_fallback. +5 lines. // global-agent-config: spawn_agent_child loads global config and merges as // lowest env layer (+8 lines). Queued to split. - ["src-tauri/src/managed_agents/runtime.rs", 2216], + // acp-dead-machinery retirement: codex adapter-availability spawn stamp + + // the availability_drift half of needs_restart deleted (the bundled bridge + // can't drift out-of-band); ratcheted 2216 -> 2079 to bank the deletions. + ["src-tauri/src/managed_agents/runtime.rs", 2079], // config-bridge setup-payload env-boundary fix adds readiness wiring in // spawn_agent_child; load-bearing security fix, queued to split. ["src-tauri/src/managed_agents/config_bridge/reader.rs", 1016], @@ -199,7 +202,9 @@ const overrides = new Map([ // split with the rest of this file. // bundled-adapter-doctor-copy: adapter_bundled field on // RawAcpRuntimeCatalogEntry + mapper passthrough (+3 lines). - ["src/shared/api/tauri.ts", 1342], + // acp-dead-machinery retirement: node_required wire field deleted; + // ratcheted 1342 -> 1340. + ["src/shared/api/tauri.ts", 1340], // doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line). // doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/ // loginHint fields on AcpRuntimeCatalogEntry (+14 lines). Load-bearing new feature. @@ -216,7 +221,9 @@ const overrides = new Map([ // AcpRuntimeCatalogEntry (+2 lines). // bundled-cli-probes: "cli_missing" availability retired; +4 doc lines on // AcpAvailabilityStatus explaining why the state no longer exists. - ["src/shared/api/types.ts", 1066], + // acp-dead-machinery retirement: nodeRequired field deleted; + // ratcheted 1066 -> 1064. + ["src/shared/api/types.ts", 1064], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog // shows required markers and credential amber rows (parity with @@ -276,7 +283,10 @@ const overrides = new Map([ // bundled-cli-probes: resolve_probe_binary (bundled-CLI-first probe // resolution) + classify_runtime/underlying_cli doc rewrites for the // CliMissing retirement (+8 lines). - ["src-tauri/src/managed_agents/discovery.rs", 1147], + // acp-dead-machinery retirement: adapter-availability cache + + // availability_drift, runtime_needs_npm/is_npm_global_install, and the + // node_required computation deleted; ratcheted 1147 -> 1046. + ["src-tauri/src/managed_agents/discovery.rs", 1046], // rebase over codex-acp-package-swap: its version-probe tests union with the // doctor-install-reliability nvm/login-shell/semver tests — each side alone // stayed under the 1000 default; the union exceeds it. @@ -419,7 +429,10 @@ const overrides = new Map([ // bundle-acps: adapter_verification_step post-install gate + its test // quartet (+120 lines, partly offset by the version-gate retirement's // deletions in this file). Queued to split. - ["src-tauri/src/commands/agent_discovery.rs", 1410], + // acp-dead-machinery retirement: npm EACCES preflight (resolve_npm_prefix, + // npm_preflight_check, npm_eacces_hint) + its Phase-2 branches and test + // groups deleted; ratcheted 1410 -> 1021 to bank the deletions. + ["src-tauri/src/commands/agent_discovery.rs", 1021], // draft-persistence predicate: submit-time `loadDraft` check + inline comment // + deps-array entry in submitMessage closes the never-persisted-boundary // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index a9e355462b..be52b07329 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -4,9 +4,9 @@ use tauri::State; use crate::{ app_state::AppState, managed_agents::{ - command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, - DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, InstallStepResult, - ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, + command_availability, AcpRuntimeCatalogEntry, DiscoverManagedAgentPrereqsRequest, + InstallRuntimeResult, InstallStepResult, ManagedAgentPrereqsInfo, RelayAgentInfo, + DEFAULT_ACP_COMMAND, }, nostr_convert, relay::query_relay, @@ -55,8 +55,8 @@ pub(crate) fn plan_adapter_install<'c>( /// Returns `None` when there is nothing to verify (`commands` is empty) or any /// adapter command resolves. Otherwise returns a failed synthetic "verify" /// step whose hint points at reinstalling Buzz when the adapter is bundled -/// (`bundled`, i.e. the catalog carries no install commands) or at the install -/// step output otherwise. +/// (`bundled`, see [`runtime_adapter_is_bundled`]) or at the install step +/// output otherwise. fn adapter_verification_step( commands: &[&str], label: &str, @@ -74,7 +74,7 @@ fn adapter_verification_step( } else { format!( "The {label} ACP adapter still could not be found after the install steps completed. \ - Check the step output above and your npm global prefix." + Check the step output above." ) }; Some(InstallStepResult { @@ -88,6 +88,14 @@ fn adapter_verification_step( }) } +/// A runtime's adapter ships with the Buzz desktop app when its catalog entry +/// carries no install commands at all — neither CLI nor adapter. Goose has a +/// curl CLI installer (and its CLI *is* its adapter), so a failed goose verify +/// must not claim the adapter is bundled and point at reinstalling Buzz. +fn runtime_adapter_is_bundled(runtime: &crate::managed_agents::KnownAcpRuntime) -> bool { + runtime.cli_install_commands.is_empty() && runtime.adapter_install_commands.is_empty() +} + #[tauri::command] pub async fn discover_acp_providers() -> Result, String> { tokio::task::spawn_blocking(|| { @@ -184,11 +192,6 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result Result Result std::process::Command { let shell = if std::path::Path::new("/bin/zsh").exists() { "/bin/zsh" @@ -789,230 +776,6 @@ fn floor_char_boundary(s: &str, mut index: usize) -> usize { index } -// ── npm EACCES preflight ────────────────────────────────────────────────────── - -/// Guidance text for the EACCES / unwritable-prefix case. -fn npm_eacces_guidance(command: &str) -> String { - format!( - "npm's global install directory isn't writable by your user.\n\ -\n\ -Fix (no sudo):\n\ - 1. Run: npm config set prefix ~/.npm-global\n\ - 2. Add to ~/.zprofile: export PATH=\"$HOME/.npm-global/bin:$PATH\"\n\ - 3. Restart Buzz, then click Install again.\n\ -\n\ -Or install manually, then click Refresh:\n\ - sudo {command}" - ) -} - -/// Guidance text shown when npm / Node.js is not found in the login-shell PATH. -const NPM_MISSING_HINT: &str = "Node.js / npm was not found. Install Node.js \ -(https://nodejs.org or your version manager), restart Buzz, then click Install again.\n\ -If npm works in your terminal, make sure your Node version manager is initialized in \ -~/.zprofile (not only ~/.zshrc) — Buzz resolves tools via non-interactive login shells."; - -/// Result of probing `npm prefix -g` in the hermit-stripped login shell. -#[cfg(unix)] -enum NpmPrefix { - /// npm responded with a parseable prefix path. - Found(std::path::PathBuf), - /// npm was not found, the spawn failed, the command returned a non-zero - /// exit, or the output could not be parsed. - Unavailable, - /// The probe exceeded the 30-second deadline (e.g. a version-manager init - /// that blocks on `/dev/tty`). The install should proceed so the stderr - /// classifier remains the backstop. - TimedOut, -} - -/// Spawn the same login shell used by `run_install_command` and run -/// `npm prefix -g` to discover where npm would install global packages. -#[cfg(unix)] -fn resolve_npm_prefix() -> NpmPrefix { - let mut cmd = install_shell_command("npm prefix -g"); - let mut child = match cmd - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - { - Ok(c) => c, - Err(_) => return NpmPrefix::Unavailable, - }; - - // Drain stdout/stderr on background threads to prevent pipe-buffer deadlock. - let stdout_pipe = child.stdout.take(); - let stderr_pipe = child.stderr.take(); - let stdout_thread = std::thread::spawn(move || { - let mut buf = Vec::new(); - if let Some(mut pipe) = stdout_pipe { - let _ = pipe.read_to_end(&mut buf); - } - buf - }); - let stderr_thread = std::thread::spawn(move || { - // Drain stderr so the child doesn't block on a full pipe. - if let Some(mut pipe) = stderr_pipe { - let _ = std::io::copy(&mut pipe, &mut std::io::sink()); - } - }); - - let child_pid = child.id(); - let (tx, rx) = std::sync::mpsc::channel(); - let wait_thread = std::thread::spawn(move || { - let status = child.wait(); - let _ = tx.send(status); - }); - - // 30-second timeout — plenty for `npm prefix -g`; intentionally shorter - // than the 5-minute install budget in `run_install_command`. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); - let raw_bytes: Option> = loop { - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - // Timed out: send SIGTERM, clean up threads, signal the caller to - // fall through to the install path rather than abort. - unsafe { libc::kill(child_pid as i32, libc::SIGTERM) }; - drop(rx); - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - eprintln!( - "buzz: npm prefix probe timed out after 30s; \ - proceeding to install (stderr classifier is the backstop)" - ); - return NpmPrefix::TimedOut; - } - match rx.recv_timeout(std::time::Duration::from_millis(200).min(remaining)) { - Ok(Ok(status)) => { - let _ = wait_thread.join(); - let stdout = stdout_thread.join().unwrap_or_default(); - let _ = stderr_thread.join(); - break if status.success() { Some(stdout) } else { None }; - } - Ok(Err(_)) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - break None; - } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, - } - }; - - let bytes = match raw_bytes { - Some(b) => b, - None => return NpmPrefix::Unavailable, - }; - let raw = String::from_utf8_lossy(&bytes).into_owned(); - // Version managers can print banner lines before the real prefix — take the - // last non-empty line to skip any preamble. - let prefix = match raw.lines().rfind(|l| !l.trim().is_empty()) { - Some(l) => l.trim().to_string(), - None => return NpmPrefix::Unavailable, - }; - if prefix.is_empty() { - return NpmPrefix::Unavailable; - } - NpmPrefix::Found(std::path::PathBuf::from(prefix)) -} - -/// Check write access to a file-system path using the POSIX `access(2)` syscall. -#[cfg(unix)] -fn unix_is_writable(path: &std::path::Path) -> bool { - use std::os::unix::ffi::OsStrExt; - let bytes = path.as_os_str().as_bytes(); - let Ok(c_path) = std::ffi::CString::new(bytes) else { - return false; - }; - // SAFETY: `access` is a pure read-only syscall; we pass a valid NUL-terminated - // path and a standard flag constant. This mirrors the existing `setsid`/`kill` - // usage in this file. - unsafe { libc::access(c_path.as_ptr(), libc::W_OK) == 0 } -} - -/// Returns true when the directory where npm would write global packages is -/// writable by the current process user. -/// -/// On non-unix platforms always returns `true` — the EACCES preflight is a -/// no-op there; the stderr classifier still applies. -fn npm_install_target_is_writable(prefix: &std::path::Path) -> bool { - #[cfg(unix)] - { - // Probe the most specific candidate that exists; fall back up the tree. - for candidate in &[ - prefix.join("lib/node_modules"), - prefix.join("lib"), - prefix.to_path_buf(), - ] { - if candidate.exists() { - return unix_is_writable(candidate); - } - } - // Nothing exists — npm couldn't create it either. - unix_is_writable(prefix) - } - #[cfg(not(unix))] - { - let _ = prefix; - true - } -} - -/// Inspect `stderr` for known npm EACCES patterns and return actionable -/// guidance if matched, or `None` when the error is unrelated. -fn npm_eacces_hint(stderr: &str, command: &str) -> Option { - if stderr.contains("EACCES: permission denied") || stderr.contains("npm error EACCES") { - Some(npm_eacces_guidance(command)) - } else { - None - } -} - -/// Run the npm preflight before executing an npm global install command. -/// Returns `Some(failed InstallStepResult)` to abort, or `None` to proceed. -fn npm_preflight_check(step: &str, command: &str) -> Option { - #[cfg(unix)] - { - match resolve_npm_prefix() { - NpmPrefix::Unavailable => Some(InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: String::new(), - exit_code: None, - hint: Some(NPM_MISSING_HINT.to_string()), - }), - NpmPrefix::Found(prefix) if !npm_install_target_is_writable(&prefix) => { - Some(InstallStepResult { - step: step.to_string(), - command: command.to_string(), - success: false, - stdout: String::new(), - stderr: format!( - "npm global prefix '{}' is not writable by the current user.", - prefix.display() - ), - exit_code: None, - hint: Some(npm_eacces_guidance(command)), - }) - } - // `Found` + writable, or `TimedOut` — proceed; let the install run and - // the stderr classifier serve as the backstop. - _ => None, - } - } - #[cfg(not(unix))] - { - let _ = (step, command); - None - } -} - -// ── end npm preflight ───────────────────────────────────────────────────────── - #[tauri::command] pub async fn discover_managed_agent_prereqs( input: DiscoverManagedAgentPrereqsRequest, @@ -1065,124 +828,6 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result Option { pub fn clear_resolve_cache() { let mut guard = resolve_cache().lock().unwrap_or_else(|e| e.into_inner()); guard.clear(); - // Also invalidate the adapter-availability cache so a freshly-installed - // adapter is reflected the next time the summary builder checks the badge. - clear_adapter_availability_cache(); -} - -// ── Adapter availability cache (Phase-2 badge fallback) ───────────────────── -// -// `build_managed_agent_summary` needs to compare the spawn-time adapter -// availability against the *current* availability without re-running command -// resolution on every poll cycle. This cache stores the last availability -// status of the codex-acp binary at its resolved path. It is warmed by -// `discover_acp_runtimes` (which already resolves), so the badge path reads -// warm data, and is invalidated by `clear_resolve_cache` -// (called on every Doctor install and every `discover_acp_providers` call). - -fn adapter_availability_cache() -> &'static std::sync::Mutex> { - use std::sync::{Mutex, OnceLock}; - static CACHE: OnceLock>> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(None)) -} - -fn clear_adapter_availability_cache() { - if let Ok(mut guard) = adapter_availability_cache().lock() { - *guard = None; - } -} - -/// Cache the current codex-acp adapter availability status. -/// -/// Called by `discover_acp_runtimes` after it probes the codex adapter so the -/// badge path has a warm value without re-probing. -pub(crate) fn cache_adapter_availability(status: AcpAvailabilityStatus) { - if let Ok(mut guard) = adapter_availability_cache().lock() { - *guard = Some(status); - } -} - -/// Return the most recently cached codex-acp adapter availability, or -/// `None` if no discovery has run yet. -/// -/// This is a **read from cache only** — it never spawns a subprocess. The -/// value is populated by `discover_acp_runtimes` and invalidated by -/// `clear_resolve_cache`. When the cache is cold, returning `None` defers -/// the drift check until discovery has produced a real value, preventing -/// a fabricated `AdapterMissing` stamp from triggering a false restart badge -/// on a newly restarted process. -pub(crate) fn adapter_availability_cached() -> Option { - adapter_availability_cache() - .lock() - .ok() - .and_then(|g| g.clone()) -} - -/// Pure predicate: does the stamped adapter availability differ from the -/// current cached availability? -/// -/// Returns `false` whenever either side is `None` (unknown) — "no data" is -/// not evidence of drift. This is extracted for unit testing without global -/// state and used by `build_managed_agent_summary`. -pub(crate) fn availability_drift( - stamped: Option<&AcpAvailabilityStatus>, - current: Option, -) -> bool { - match (stamped, current) { - (Some(s), Some(c)) => *s != c, - _ => false, - } } fn resolve_command_uncached(command: &str) -> Option { @@ -823,24 +756,6 @@ pub(crate) fn find_command(command: &str) -> Option { resolve_command(command) } -/// Returns true when the runtime has at least one adapter install step that -/// is an npm global install. Used to determine whether Node.js is required. -fn runtime_needs_npm(runtime: &KnownAcpRuntime) -> bool { - runtime - .adapter_install_commands - .iter() - .any(|cmd| is_npm_global_install(cmd)) -} - -/// Returns `true` when `cmd` is an `npm install -g` invocation. -/// -/// Used by Doctor to determine whether Node.js is required before running an -/// install step, and by the npm EACCES preflight in the install command path. -pub(crate) fn is_npm_global_install(cmd: &str) -> bool { - let t = cmd.trim_start(); - t.starts_with("npm install -g ") || t.starts_with("npm i -g ") -} - /// Resolve the binary for a CLI auth probe (`claude`, `codex`): the pinned /// CLI vendored inside the bundled bridge wins — it is the exact binary agent /// sessions run and reads the same credential store — falling back to the @@ -1016,13 +931,6 @@ pub fn discover_acp_runtimes() -> Vec { let (availability, command, binary_path) = classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); - // Warm the adapter-availability cache for the badge fallback. - // The cache is scoped to the codex runtime; other runtimes leave it - // unchanged. Invalidated by `clear_resolve_cache`. - if runtime.id == "codex" { - cache_adapter_availability(availability.clone()); - } - let underlying_cli_path = runtime .underlying_cli .and_then(find_command) @@ -1052,14 +960,6 @@ pub fn discover_acp_runtimes() -> Vec { } }; - // node_required: an npm adapter step is pending AND node/npm are absent. - let node_required = matches!( - availability, - AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled - ) && runtime_needs_npm(runtime) - && resolve_command("npm").is_none() - && resolve_command("node").is_none(); - PartialEntry { runtime, entry: AcpRuntimeCatalogEntry { @@ -1076,7 +976,6 @@ pub fn discover_acp_runtimes() -> Vec { install_instructions_url: runtime.install_instructions_url.to_string(), can_auto_install, underlying_cli_path, - node_required, // Filled in by the probe phase below. auth_status: AuthStatus::Unknown, login_hint: None, diff --git a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs index 1ef3eae010..473861ba6a 100644 --- a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs +++ b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs @@ -135,7 +135,6 @@ pub fn finish_spawn( log_path: std::path::PathBuf, spawn_config_hash: u64, setup_mode: bool, - adapter_availability: Option, agent_name: &str, ) -> super::ManagedAgentProcess { let job = create_job_for_child(child.id()); @@ -150,7 +149,6 @@ pub fn finish_spawn( log_path, spawn_config_hash, setup_mode, - adapter_availability, job, } } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b7f633b56f..9d7663c0df 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1321,29 +1321,18 @@ pub fn build_managed_agent_summary( // at launch; recompute from current disk state and flag drift. Only a // tracked live process can drift — stopped agents spawn fresh, and // adopted (runtime_pid-only) processes have no stamped hash to compare. - // - // Additionally, for runtimes with an adapter version gate (codex only), - // check whether the cached adapter availability has drifted from the value - // stamped at spawn. This catches out-of-band adapter changes (manual - // npm install/downgrade) that Phase-1 auto-restart doesn't cover. The - // cache is read-only here — no subprocess is spawned. let needs_restart = runtimes.get(&record.pubkey).is_some_and(|runtime| { use tauri::Manager; let state = app.state::(); let global_for_hash = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); - let hash_drift = runtime.spawn_config_hash + runtime.spawn_config_hash != crate::managed_agents::spawn_hash::spawn_config_hash( record, personas, &crate::relay::relay_ws_url_with_override(&state), &global_for_hash, - ); - let availability_drift = super::availability_drift( - runtime.adapter_availability.as_ref(), - super::adapter_availability_cached(), - ); - hash_drift || availability_drift + ) }); // Resolve the effective harness the same way, then derive args/mcp from it, @@ -1883,20 +1872,6 @@ pub fn spawn_agent_child( let spawn_config_hash = super::spawn_hash::spawn_config_hash(record, &personas, &effective_relay_url, &global); - // Stamp the adapter availability for runtimes with a version gate (codex - // only). The summary builder compares this against the current cached value - // to detect out-of-band adapter changes after spawn (Phase-2 badge fallback). - // Non-codex runtimes get `None` — nothing changes for them. - // When the cache is cold (e.g. Doctor just installed and cleared the cache), - // `adapter_availability_cached()` returns `None`, so the stamp is `None` and - // the drift check is skipped until discovery warms the cache — preventing a - // false restart badge immediately after auto-restart. - let spawned_adapter_availability = if runtime_meta.is_some_and(|r| r.id == "codex") { - super::adapter_availability_cached() - } else { - None - }; - let _ = super::write_agent_pid_file(app, &record.pubkey, child.id()); // Windows: assign the harness to a Job Object so its whole tree dies with @@ -1907,7 +1882,6 @@ pub fn spawn_agent_child( log_path, spawn_config_hash, spawned_setup_mode, - spawned_adapter_availability, &record.name, )); #[cfg(not(windows))] @@ -1916,7 +1890,6 @@ pub fn spawn_agent_child( log_path, spawn_config_hash, setup_mode: spawned_setup_mode, - adapter_availability: spawned_adapter_availability, }) } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 954ca975f3..ea3cb11e02 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -431,12 +431,6 @@ pub struct ManagedAgentProcess { /// `install_acp_runtime` to target only stuck agents for auto-restart, /// excluding healthy in-pool agents. pub setup_mode: bool, - /// Adapter availability status stamped at spawn time for runtimes with a - /// version gate (currently codex only; `None` for all others). Runtime-only - /// — never persisted. The summary builder compares this against the current - /// cached availability and sets `needs_restart` on drift, catching out-of- - /// band adapter changes that Phase-1 auto-restart doesn't cover. - pub adapter_availability: Option, /// Win32 Job Object owning the harness + its entire process tree. Closing /// the handle (via `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`) kills the whole /// tree — the Windows mirror of the Unix process-group teardown. `None` @@ -577,9 +571,6 @@ pub struct AcpRuntimeCatalogEntry { /// true when at least one automated install step is available pub can_auto_install: bool, pub underlying_cli_path: Option, - /// true when an npm adapter step is pending but Node.js / npm is absent. - /// The UI hides the Install button and shows a Node.js install callout. - pub node_required: bool, /// Login/authentication status for CLI-based runtimes. pub auth_status: AuthStatus, /// Hint for completing authentication, shown when `auth_status` is not `logged_in`. diff --git a/desktop/src/features/onboarding/ui/agentReadiness.test.mjs b/desktop/src/features/onboarding/ui/agentReadiness.test.mjs index 66b11b6be8..33eb195ddb 100644 --- a/desktop/src/features/onboarding/ui/agentReadiness.test.mjs +++ b/desktop/src/features/onboarding/ui/agentReadiness.test.mjs @@ -19,7 +19,6 @@ function makeRuntime(overrides = {}) { installInstructionsUrl: "https://example.com", canAutoInstall: false, underlyingCliPath: null, - nodeRequired: false, loginHint: null, ...overrides, }; diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 2649b4520b..7e78409b77 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -83,7 +83,7 @@ function InstallActions({ onInstall: () => void; runtime: AcpRuntimeCatalogEntry; }) { - const showInstall = runtime.canAutoInstall && !runtime.nodeRequired; + const showInstall = runtime.canAutoInstall; return (
@@ -117,46 +117,6 @@ function InstallActions({ ); } -/** - * Node.js callout when required, or the install actions when it is not. - * Used for both `adapter_missing` and `not_installed` availability states. - */ -function NodeRequiredOrInstall({ - hasError, - isInstalling, - onInstall, - runtime, -}: { - hasError: boolean; - isInstalling: boolean; - onInstall: () => void; - runtime: AcpRuntimeCatalogEntry; -}) { - if (runtime.nodeRequired) { - return ( -

- Node.js is required to install this adapter.{" "} - - , then click Re-run. -

- ); - } - return ( - - ); -} - function RuntimeRow({ installError, installSuccess, @@ -262,7 +222,7 @@ function RuntimeRow({

{runtime.installHint}

- {runtime.installHint}

- { await row.screenshot({ path: `${SHOTS}/03-auth-config-error.png` }); }); - /** - * 04 — adapter_missing runtime with node_required: true: the amber "Node.js - * is required…" callout replaces the Install button so the user cannot - * inadvertently trigger a doomed npm install. + /* + * 04-node-required retired with the per-row `node_required` install gate: + * no runtime carries npm adapter install commands anymore, so the amber + * "Node.js is required…" callout has no trigger. The Node.js requirement + * for the bundled bridges is covered by 07-node-runtime-warn below. */ - test("04-node-required", async ({ page }) => { - await installMockBridge(page, { - acpRuntimesCatalog: [ - GOOSE_AVAILABLE, - CLAUDE_AVAILABLE_LOGGED_IN, - { - ...CODEX_NOT_INSTALLED, - availability: "adapter_missing", - underlying_cli_path: "/usr/local/bin/codex", - node_required: true, - install_hint: - "Install the Codex ACP adapter: npm install -g @zed-industries/codex-acp", - }, - BUZZ_AGENT_AVAILABLE, - ], - }); - - await page.goto("/", { waitUntil: "domcontentloaded" }); - await openSettings(page, "doctor"); - - const row = page.getByTestId("doctor-runtime-codex"); - await expect(row).toBeVisible({ timeout: 10_000 }); - await expect(row).toContainText("Node.js is required"); - // Exact-name match so "Install Node.js" (inside the callout) is not counted. - await expect( - row.getByRole("button", { name: "Install", exact: true }), - ).toHaveCount(0); - - await row.scrollIntoViewIfNeeded(); - await waitForAnimations(page); - await row.screenshot({ path: `${SHOTS}/04-node-required.png` }); - }); /** * 05 — a failed install renders a "Retry" button; clicking Retry succeeds. @@ -261,7 +226,6 @@ test.describe("Doctor panel state screenshots", () => { { ...CODEX_NOT_INSTALLED, can_auto_install: true, - node_required: false, }, BUZZ_AGENT_AVAILABLE, ], From 5d3b6122831939282432aa7c470b14ae6904cee9 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 15:27:56 +1000 Subject: [PATCH 13/18] fix(desktop): preserve unselected targets in partial ACP lock bumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A partial run like `just bump-acp-tools --target aarch64-apple-darwin` rewrote the entire acp-tools.lock.json with only the selected targets, silently dropping the other three targets' entries. Because ensure/ prepare treat a target with zero lock entries as success (stage nothing, exit 0), a later desktop-release-build for a dropped target would ship an empty resources/acp and silently fall back to unpinned user installs — exactly the drift the bundling series exists to prevent. Make --target runs merge instead of replace: before resolving, read the existing lock and carry over every entry whose target is not selected, then write the union. The guard rails match the script's fail-loudly ethos: - A missing lock preserves nothing (there is nothing to drop) and the run proceeds — first-time generation with --target still works. - An unreadable, unparseable, or tools-array-less lock aborts the partial run before any registry work, refusing to overwrite pins it cannot see. - Full runs (no --target, or all four targets) never read the lock, so `just bump-acp-tools` can still regenerate a corrupt lock from scratch. Preserved entries are logged ("Preserved 6 existing lock entries for unselected targets") so a partial bump's diff is explainable from its output, and the usage text documents the merge behaviour. Addresses the partial-run finding from the bundling-series code review (review 2ed3d00d on b52a665a). Verification (against the Block registry, temp lock files): - --target aarch64-apple-darwin on a copy of the committed lock: 6 unselected entries preserved byte-identically; merged lock byte-identical to the committed lock (registry pins unchanged); 8 entries across all 4 targets. - --target with no existing lock: writes only the selected target's 2 entries, exit 0. - --target with corrupt JSON / {"tools":"nope"}: exits 1 in <1s with a refusing-to-overwrite message; lock file left untouched. - pnpm exec biome check + node scripts/check-file-sizes.mjs: clean. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/scripts/update-acp-tools-lock.mjs | 54 ++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/desktop/scripts/update-acp-tools-lock.mjs b/desktop/scripts/update-acp-tools-lock.mjs index bffcf0c320..dcadaf9b75 100755 --- a/desktop/scripts/update-acp-tools-lock.mjs +++ b/desktop/scripts/update-acp-tools-lock.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { execFile } from "node:child_process"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { promisify } from "node:util"; @@ -102,6 +102,10 @@ writes acp-tools.lock.json. Fails loudly when a package or one of its per-target native dependencies cannot be resolved — never silently pins an older version. +A --target run regenerates only the selected targets; the existing lock's +entries for every other target are preserved verbatim, so a partial bump +never drops another target's pins. + Supported targets: ${SUPPORTED_TARGETS.join("\n ")} @@ -341,14 +345,62 @@ async function lockToolForTarget(tool, target) { }; } +// Entries preserved from the existing lock when --target selects a subset. +// A missing lock preserves nothing (there is nothing to drop); an unreadable +// or malformed one aborts the run rather than clobbering pins we cannot see. +async function preservedLockEntries(lockFile, selectedTargets) { + let raw; + try { + raw = await readFile(lockFile, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw new Error( + `Cannot read existing lock ${lockFile} to preserve unselected targets: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error( + `Existing lock ${lockFile} is not valid JSON — refusing to overwrite it with a partial --target run: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (!Array.isArray(parsed?.tools)) { + throw new Error( + `Existing lock ${lockFile} has no tools array — refusing to overwrite it with a partial --target run`, + ); + } + return parsed.tools.filter((entry) => !selectedTargets.has(entry?.target)); +} + async function main() { const { targets, lockFile } = parseArgs(process.argv.slice(2)); + const selectedTargets = new Set(targets); + const fullRun = SUPPORTED_TARGETS.every((target) => + selectedTargets.has(target), + ); + const preserved = fullRun + ? [] + : await preservedLockEntries(lockFile, selectedTargets); const tools = []; for (const tool of TOOL_SPECS) { for (const target of targets) { tools.push(await lockToolForTarget(tool, target)); } } + if (preserved.length) { + console.log( + `Preserved ${preserved.length} existing lock entr${ + preserved.length === 1 ? "y" : "ies" + } for unselected targets`, + ); + } + tools.push(...preserved); tools.sort((left, right) => `${left.id}:${left.target}`.localeCompare(`${right.id}:${right.target}`), ); From f619669ac945e985aad21ce4520b19233c4764c7 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 15:44:51 +1000 Subject: [PATCH 14/18] fix(desktop): surface empty-target ACP staging notice in --print-bin-dir runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensure-acp-tools.sh printed "No ACP tools locked for target ..." only in the non---print-bin-dir branch, so the exact invocation path release builds take (prepare-acp-tools-resource.sh capturing --print-bin-dir output) suppressed the notice entirely. An empty-target staging — the failure mode the lock-merge fix guards against — left no trace in build logs and was only discoverable later via Doctor showing the bridges as not installed. Emit the notice to stderr unconditionally before the --print-bin-dir branch: stderr keeps the --print-bin-dir stdout contract (a single bin dir path) intact while making the empty staging visible in release build logs, matching the script's other diagnostics ("Installing ACP tool ..." already goes to stderr). Addresses the suppressed-notice finding from the bundling-series code review (review 2ed3d00d on b52a665a). Verification: - --target fake-unknown-target --print-bin-dir: notice on stderr, stdout is exactly the bin dir path, exit 0 - --target fake-unknown-target (no flag): notice on stderr, empty stdout, exit 0 - real host target --print-bin-dir: no-op stage, prints only the bin dir path, exit 0 Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/scripts/ensure-acp-tools.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/desktop/scripts/ensure-acp-tools.sh b/desktop/scripts/ensure-acp-tools.sh index ddee056ca4..da1b91c153 100755 --- a/desktop/scripts/ensure-acp-tools.sh +++ b/desktop/scripts/ensure-acp-tools.sh @@ -132,10 +132,11 @@ entry_count="$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]). mkdir -p "$bin_dir" if [[ "$entry_count" == "0" ]]; then find "$bin_dir" -type f -delete + # stderr so the notice shows up in release build logs even when stdout is + # reserved for --print-bin-dir consumers (prepare-acp-tools-resource.sh). + echo "No ACP tools locked for target $target." >&2 if [[ "$print_bin_dir" == "1" ]]; then printf '%s\n' "$bin_dir" - else - echo "No ACP tools locked for target $target." fi exit 0 fi From 1b044de72cd5ff330151b9e7b100d7628bc00c36 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 15:50:38 +1000 Subject: [PATCH 15/18] fix(desktop): warn instead of swallowing ad-hoc codesign failures in ACP staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codesign_if_darwin ran `codesign ... >/dev/null 2>&1 || true`, discarding both the exit status and the error output. An unsigned nested Mach-O therefore never surfaced at stage time — it surfaced much later as Gatekeeper killing a subprocess mid-session, the exact failure mode the nested-Mach-O signing scan exists to prevent, with nothing in the build output to connect the two. Keep the failure non-fatal (an unsignable Mach-O fragment that never executes should not sink the stage, and release builds re-sign with the real identity anyway) but make it visible: capture codesign's combined output and, on non-zero exit, print a stderr warning naming the file plus codesign's own diagnostics. Addresses the swallowed-codesign-failure finding from the bundling-series code review (review 2ed3d00d on b52a665a). Verification: - Forced failure (Mach-O in a read-only directory): warning with file path and codesign's "internal error in Code Signing subsystem" on stderr; script continues under set -e, exit 0. - Notable non-failure probed while testing: codesign xattr-signs non-Mach-O and even corrupt-header files successfully, so the realistic trigger is filesystem/permission trouble, not file(1) false positives. - Full prepare-acp-tools-resource.sh run: no warnings, both manifests written, all 5 staged Mach-Os pass codesign --verify. - bash -n: syntax OK. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/scripts/prepare-acp-tools-resource.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/desktop/scripts/prepare-acp-tools-resource.sh b/desktop/scripts/prepare-acp-tools-resource.sh index 4e90265646..4bc67126fc 100755 --- a/desktop/scripts/prepare-acp-tools-resource.sh +++ b/desktop/scripts/prepare-acp-tools-resource.sh @@ -69,10 +69,19 @@ harness_cli_manifest="$resource_root/harness-clis.json" rm -f "$harness_cli_manifest" harness_cli_entries=() +# Ad-hoc signing failure is a warning, not a hard stop: an unsignable Mach-O +# fragment that never executes should not sink the stage, and release builds +# re-sign everything with the real identity anyway. But it must be visible — +# a silently unsigned binary surfaces much later as Gatekeeper killing a +# subprocess mid-session, which is undiagnosable from build output. codesign_if_darwin() { local file="$1" + local output if [[ "$(uname -s)" == "Darwin" ]] && command -v codesign >/dev/null 2>&1; then - codesign --force --sign - "$file" >/dev/null 2>&1 || true + if ! output="$(codesign --force --sign - "$file" 2>&1)"; then + echo "Warning: ad-hoc codesign failed for $file — Gatekeeper may kill it at spawn time:" >&2 + echo "$output" >&2 + fi fi } From e81f1c2471ebd31ac49347d886e80b5c116a2ad1 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 15:54:40 +1000 Subject: [PATCH 16/18] fix(desktop): normalize ACP lock tarball URLs to the public npm registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit acp-tools.lock.json pinned Block's internal Artifactory host in all 24 tarball URL fields — internal infrastructure leaked into an OSS repo, and anyone regenerating the lock against a different registry churned every URL in the diff even when no pin changed. The URLs are informational only: ensure-acp-tools.sh installs via the developer's ambient npm registry and validates the registry-agnostic sha512 integrity from npm's package-lock, so no install path dereferences them. Normalize at write time instead: update-acp-tools-lock.mjs rebuilds every tarball URL on https://registry.npmjs.org from the resolved package name plus the registry-reported tarball basename (the basename is not derivable from name@version alone — @openai/codex native tarballs carry a platform suffix like codex-0.144.1-darwin-arm64.tgz). A dist.tarball with no /-/ segment fails loudly rather than writing an unnormalizable URL. The usage text documents the normalization, and the committed lock is regenerated: the diff is exactly the 24 tarball fields; every version and integrity hash is byte-identical. Addresses the internal-Artifactory-URL finding from the bundling-series code review (review 2ed3d00d on b52a665a). Verification (ambient registry = Block Artifactory, proving normalization against an internal mirror): - Full regeneration: versions/integrity byte-identical to the previous lock; only tarball hosts changed. - Partial --target aarch64-apple-darwin rerun on the normalized lock: byte-identical output (preserved + fresh entries agree). - All normalized URL shapes probe live on registry.npmjs.org (303 CDN redirect), including the platform-suffixed codex native tarball. - ensure-acp-tools.sh with the new lock: validates and no-op stages, exit 0 (the freshness stamp does not compare tarball fields, so no spurious re-install). - pnpm exec biome check on the script: clean. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/acp-tools.lock.json | 48 +++++++++++------------ desktop/scripts/update-acp-tools-lock.mjs | 30 +++++++++++++- 2 files changed, 53 insertions(+), 25 deletions(-) diff --git a/desktop/acp-tools.lock.json b/desktop/acp-tools.lock.json index d2361a3b6b..6ef4947bf6 100644 --- a/desktop/acp-tools.lock.json +++ b/desktop/acp-tools.lock.json @@ -7,7 +7,7 @@ "package": "@agentclientprotocol/claude-agent-acp", "version": "0.58.1", "integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz", + "tarball": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz", "target": "aarch64-apple-darwin", "npmOs": "darwin", "npmCpu": "arm64", @@ -15,13 +15,13 @@ "dependencyPackage": "@anthropic-ai/claude-agent-sdk", "dependencyVersion": "0.3.205", "dependencyIntegrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz", + "dependencyTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz", "claudeCodeVersion": "2.1.205", "nativePackage": "@anthropic-ai/claude-agent-sdk-darwin-arm64", "nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-arm64", "nativeVersion": "0.3.205", "nativeIntegrity": "sha512-lrfJ4eVtzfPkCpbSkBOGSMQCBbvmW6nbPzgHE4IwMN3scZlpuFMUFqh2aaJa/X2SAcWD9H2S0t2WWvSRgM7BjA==", - "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.205.tgz", + "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.205.tgz", "nativeExecutable": "claude" }, { @@ -31,7 +31,7 @@ "package": "@agentclientprotocol/claude-agent-acp", "version": "0.58.1", "integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz", + "tarball": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz", "target": "aarch64-unknown-linux-gnu", "npmOs": "linux", "npmCpu": "arm64", @@ -40,13 +40,13 @@ "dependencyPackage": "@anthropic-ai/claude-agent-sdk", "dependencyVersion": "0.3.205", "dependencyIntegrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz", + "dependencyTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz", "claudeCodeVersion": "2.1.205", "nativePackage": "@anthropic-ai/claude-agent-sdk-linux-arm64", "nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-arm64", "nativeVersion": "0.3.205", "nativeIntegrity": "sha512-CXzySK3PV3EizCRPXnxPqeaAtgrBFDnMFOVpMe36oC3U16yDb1b1tAJGqZi/7uFrVvAiaXvnSFxhUWnDDSaO+A==", - "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.205.tgz", + "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.205.tgz", "nativeExecutable": "claude" }, { @@ -56,7 +56,7 @@ "package": "@agentclientprotocol/claude-agent-acp", "version": "0.58.1", "integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz", + "tarball": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz", "target": "x86_64-apple-darwin", "npmOs": "darwin", "npmCpu": "x64", @@ -64,13 +64,13 @@ "dependencyPackage": "@anthropic-ai/claude-agent-sdk", "dependencyVersion": "0.3.205", "dependencyIntegrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz", + "dependencyTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz", "claudeCodeVersion": "2.1.205", "nativePackage": "@anthropic-ai/claude-agent-sdk-darwin-x64", "nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-x64", "nativeVersion": "0.3.205", "nativeIntegrity": "sha512-G6ETPmL5mNzJ2DFsWxG3jmsmrXgZX1N2ZCJvxaGUUpjTsKZJ4Tup1cWYvcd/m7o5fYZmx9REmgzTwsAIc1fdPQ==", - "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.205.tgz", + "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.205.tgz", "nativeExecutable": "claude" }, { @@ -80,7 +80,7 @@ "package": "@agentclientprotocol/claude-agent-acp", "version": "0.58.1", "integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz", + "tarball": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz", "target": "x86_64-unknown-linux-gnu", "npmOs": "linux", "npmCpu": "x64", @@ -89,13 +89,13 @@ "dependencyPackage": "@anthropic-ai/claude-agent-sdk", "dependencyVersion": "0.3.205", "dependencyIntegrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz", + "dependencyTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz", "claudeCodeVersion": "2.1.205", "nativePackage": "@anthropic-ai/claude-agent-sdk-linux-x64", "nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-x64", "nativeVersion": "0.3.205", "nativeIntegrity": "sha512-siS+1iNqBSlGFZZvJY6+mhzZ/6/ec/TbX9GMuwmTF0E6fxGhIIp797jJxR1q8r6FAq7d39mEoRNhC0Ffo60uNQ==", - "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.205.tgz", + "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.205.tgz", "nativeExecutable": "claude" }, { @@ -105,7 +105,7 @@ "package": "@agentclientprotocol/codex-acp", "version": "1.1.2", "integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz", + "tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz", "target": "aarch64-apple-darwin", "npmOs": "darwin", "npmCpu": "arm64", @@ -113,12 +113,12 @@ "dependencyPackage": "@openai/codex", "dependencyVersion": "0.144.1", "dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1.tgz", + "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz", "nativePackage": "@openai/codex-darwin-arm64", "nativePackageName": "@openai/codex", "nativeVersion": "0.144.1-darwin-arm64", "nativeIntegrity": "sha512-dABeDK+ATqMG54MGBd3VjpKfh5EOoqx9PKVQB2QYDaEXx3F6CdUCXue5QIMfr4OxziUj8pUcLAQyd+KFqiTUFw==", - "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1-darwin-arm64.tgz", + "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-darwin-arm64.tgz", "nativeExecutable": "vendor/aarch64-apple-darwin/bin/codex" }, { @@ -128,7 +128,7 @@ "package": "@agentclientprotocol/codex-acp", "version": "1.1.2", "integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz", + "tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz", "target": "aarch64-unknown-linux-gnu", "npmOs": "linux", "npmCpu": "arm64", @@ -137,12 +137,12 @@ "dependencyPackage": "@openai/codex", "dependencyVersion": "0.144.1", "dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1.tgz", + "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz", "nativePackage": "@openai/codex-linux-arm64", "nativePackageName": "@openai/codex", "nativeVersion": "0.144.1-linux-arm64", "nativeIntegrity": "sha512-451o15+XtaXCCb35t/KCyyPqXHnTPxPxtdqEYOnE3e4sH5AfnI/uVJwfdjOksMG6vRLy6R+fLvSDOMguRFLmQw==", - "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1-linux-arm64.tgz", + "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-linux-arm64.tgz", "nativeExecutable": "vendor/aarch64-unknown-linux-musl/bin/codex" }, { @@ -152,7 +152,7 @@ "package": "@agentclientprotocol/codex-acp", "version": "1.1.2", "integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz", + "tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz", "target": "x86_64-apple-darwin", "npmOs": "darwin", "npmCpu": "x64", @@ -160,12 +160,12 @@ "dependencyPackage": "@openai/codex", "dependencyVersion": "0.144.1", "dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1.tgz", + "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz", "nativePackage": "@openai/codex-darwin-x64", "nativePackageName": "@openai/codex", "nativeVersion": "0.144.1-darwin-x64", "nativeIntegrity": "sha512-K2g3Q3tNxzFhV0SuzO6HcsYK7EQrp/o4HyeReyhkwVrwwUPoYwyIbB0IRjHIiDzRhbKriDccid2iyF5aPqdTcg==", - "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1-darwin-x64.tgz", + "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-darwin-x64.tgz", "nativeExecutable": "vendor/x86_64-apple-darwin/bin/codex" }, { @@ -175,7 +175,7 @@ "package": "@agentclientprotocol/codex-acp", "version": "1.1.2", "integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz", + "tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz", "target": "x86_64-unknown-linux-gnu", "npmOs": "linux", "npmCpu": "x64", @@ -184,12 +184,12 @@ "dependencyPackage": "@openai/codex", "dependencyVersion": "0.144.1", "dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1.tgz", + "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz", "nativePackage": "@openai/codex-linux-x64", "nativePackageName": "@openai/codex", "nativeVersion": "0.144.1-linux-x64", "nativeIntegrity": "sha512-HNGVI+BulrOaC/0IzBvd6EL62j7LrlbFKibrhw6hZjjCjAeUYzRB2jB4qDzXN1NfqDi6Xrvniof3kwbwab24lg==", - "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1-linux-x64.tgz", + "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-linux-x64.tgz", "nativeExecutable": "vendor/x86_64-unknown-linux-musl/bin/codex" } ] diff --git a/desktop/scripts/update-acp-tools-lock.mjs b/desktop/scripts/update-acp-tools-lock.mjs index dcadaf9b75..f80091c21b 100755 --- a/desktop/scripts/update-acp-tools-lock.mjs +++ b/desktop/scripts/update-acp-tools-lock.mjs @@ -91,6 +91,13 @@ const NPM_TARGET_CONFIG = { }, }; +// Tarball URLs in the lock are informational — ensure-acp-tools.sh installs +// via the ambient npm registry and validates the registry-agnostic sha512 +// integrity. Normalize them to the public registry so regenerating against an +// internal mirror (e.g. Artifactory) neither leaks internal hosts into the +// committed lock nor churns every URL in the diff. +const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org"; + const npmViewCache = new Map(); const execFileAsync = promisify(execFile); @@ -106,6 +113,10 @@ A --target run regenerates only the selected targets; the existing lock's entries for every other target are preserved verbatim, so a partial bump never drops another target's pins. +Tarball URLs are normalized to ${PUBLIC_NPM_REGISTRY} regardless of the +registry that resolved the metadata, so regeneration is deterministic across +environments and internal mirror hosts never land in the committed lock. + Supported targets: ${SUPPORTED_TARGETS.join("\n ")} @@ -178,10 +189,27 @@ function requireString(value, label) { return value; } +// Rebuild the tarball URL on PUBLIC_NPM_REGISTRY, keeping the registry's +// `/-/.tgz` path (the basename is not always derivable +// from name@version — @openai/codex native tarballs carry a platform suffix). +function normalizeTarballUrl(tarball, packageName, label) { + const separator = "/-/"; + const separatorIndex = tarball.lastIndexOf(separator); + if (separatorIndex === -1) { + throw new Error(`Cannot normalize ${label} tarball URL: ${tarball}`); + } + const basename = tarball.slice(separatorIndex + separator.length); + return `${PUBLIC_NPM_REGISTRY}/${packageName}/-/${basename}`; +} + function packageDist(metadata, label) { const dist = metadata?.dist; return { - tarball: requireString(dist?.tarball, `${label} dist.tarball`), + tarball: normalizeTarballUrl( + requireString(dist?.tarball, `${label} dist.tarball`), + requireString(metadata?.name, `${label} name`), + label, + ), integrity: requireString(dist?.integrity, `${label} dist.integrity`), }; } From 7b52a4111df2efe9a859b54f8a6dac763a596a64 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 16:18:26 +1000 Subject: [PATCH 17/18] fix(desktop): stage bundled ACP tools in OSS release builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundling series wired ACP tool staging into `just dev`, `just staging`, and `just desktop-release-build`, and flagged the internal sprout-releases pipeline as follow-up — but the OSS release workflow was missed entirely. None of release.yml's four platform jobs ran prepare-acp-tools-resource.sh before `pnpm tauri build`, so every OSS release artifact (DMGs, deb/AppImage, NSIS installer) shipped an empty resources/acp: the app silently fell back to unpinned user-installed bridges, which is exactly the drift the bundling series exists to prevent, while the catalog's "ships with the Buzz desktop app… Reinstall Buzz" copy pointed users at a reinstall that could never fix it. Add a "Stage bundled ACP tools" step to all four jobs, after the sidecar build and before the Tauri build (tauri.conf.json already bundles resources/acp): - macOS arm64 / Intel and Linux stage their target's pinned npm trees and bash wrapper shims; node + npm come from the already-activated hermit toolchain. - Windows runs the same script under Git Bash (the job already runs every step with `shell: bash`; node comes from setup-node). The lock currently pins no windows-msvc entries, so the step is a visible no-op — ensure-acp-tools.sh prints its empty-target notice to the build log and stages nothing — until the Windows bundling lands. Ensuring the staging scripts run before every `tauri build` invocation path was the first work item of the Windows-bundling plan, independent of the Windows decision itself. Verification: - prepare-acp-tools-resource.sh aarch64-apple-darwin: staged wrappers report the pinned 0.58.1 / 1.1.2 versions (same invocation the new steps run, on the same runner OS as the arm64 job). - ensure-acp-tools.sh --target x86_64-pc-windows-msvc (pre-Windows- bundling lock state): "No ACP tools locked" notice on stderr, empty staging, exit 0 — the Windows job stays green before the lock gains windows entries. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .github/workflows/release.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cbe9fb1ea6..1ccb9b77aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -132,6 +132,12 @@ jobs: cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh + # Stage the pinned ACP bridge tools into src-tauri/resources/acp before + # `tauri build` bundles resources — without this the app ships an empty + # resources/acp and silently falls back to unpinned user installs. + - name: Stage bundled ACP tools + run: ./desktop/scripts/prepare-acp-tools-resource.sh aarch64-apple-darwin + # Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it. - name: Resolve mesh-llm rev id: mesh_rev @@ -341,6 +347,9 @@ jobs: 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: Stage bundled ACP tools + run: ./desktop/scripts/prepare-acp-tools-resource.sh "$TARGET" + - name: Build unsigned Tauri app run: cd desktop && pnpm tauri build --verbose --no-sign --target "$TARGET" --config src-tauri/tauri.release.conf.json env: @@ -588,6 +597,9 @@ jobs: 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: Stage bundled ACP tools + run: ./desktop/scripts/prepare-acp-tools-resource.sh x86_64-unknown-linux-gnu + - name: Generate release config run: cd desktop && node scripts/build-release-config.mjs env: @@ -745,6 +757,10 @@ jobs: 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: Stage bundled ACP tools + shell: bash + run: ./desktop/scripts/prepare-acp-tools-resource.sh "$TARGET" + - name: Build Windows NSIS installer (unsigned) shell: bash run: cd desktop && pnpm tauri build --verbose --target "$TARGET" --bundles nsis --config src-tauri/tauri.release.conf.json From 4fd4d1d76b618579fdb9243e123e7e59e820bed9 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 16:19:27 +1000 Subject: [PATCH 18/18] feat(desktop): bundle the ACP bridge tools on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The windows-x86_64 build was the one supported target left out of the ACP bundling series: no lock entries, no staged resources, and — since the series made the claude/codex catalog entries platform-unconditional (empty install commands, "ships with the Buzz desktop app" hint) — a Windows user without their own claude-agent-acp hit a dead end that reinstalling could never fix. Everything upstream already exists (both bridges publish win32-x64 native packages; the release job already runs under Git Bash; the desktop's resolution/spawn layer is Windows-aware), so extend the bundle to Windows. The one genuinely Windows-shaped problem is the runtime shim: the staged bridges are bash wrappers that Windows cannot execute — and the resolver only looks for .exe anyway. Solve it with a tiny compiled launcher instead of .cmd shims, which would have rippled a two-candidate list (.exe, .cmd) through the shared resolution code and left an intermediate cmd.exe in the spawn chain that kill_on_drop cannot reap through. - New root-workspace crate buzz-acp-node-launcher: reads the sibling .shim.json ({entrypoint, nodeEngine, requiredNodeMajor}) written by the staging scripts, resolves node from PATH, enforces the lock's Node major with the exact wrapper-shim error message and exit codes (127 missing / 1 too old), then runs node on the vendored entrypoint, proxying stdio and the exit code. On Windows the child joins a Job Object with KILL_ON_JOB_CLOSE (mirroring buzz-dev-mcp's KillGroup) so terminating the launcher — as buzz-acp's kill_on_drop does — takes the node tree with it; on Unix it execs node like the bash shim, existing there so workspace clippy/tests pass everywhere. Unsafe is confined to the Win32 FFI, cfg-forbidden elsewhere, per the buzz-dev-mcp precedent. - update-acp-tools-lock.mjs gains x86_64-pc-windows-msvc (npmOs win32, npmCpu x64, no libc; native executables claude.exe and vendor/x86_64-pc-windows-msvc/bin/codex.exe). The committed lock grows 8 -> 10 entries via a partial --target run: the two new Windows pins match the other targets' versions exactly (0.58.1 / 1.1.2, sdk 0.3.205, codex 0.144.1-win32-x64) and the existing 8 entries are preserved byte-identically. - The staging scripts branch per target family through the shared wrapper lib: Unix targets keep the bash wrapper; Windows targets stage the launcher as .exe next to .shim.json (write_windows_node_launcher). The launcher builds via cargo on first use (ACP_NODE_LAUNCHER_EXE overrides; target dir resolved via cargo metadata, never ./target), a target with no locked tools still stages nothing without needing cargo, and dev-cache shims embed bin-dir-relative entrypoints because Git Bash absolute paths (/c/Users/...) are unresolvable to a native exe. The freshness path re-copies the launcher when the built binary changes (cmp-gated: a running agent's open .exe is never rewritten), the prune reduces [.exe][.stamp] and .shim.json to the lock's bare binary name, and harness-clis.json drops the .exe suffix from its cli keys so the app's bare-name auth probes ("claude", "codex") resolve the vendored CLIs on Windows too. - release.yml's Windows job needs no extra wiring: the staging step added with the previous commit now finds lock entries and builds the launcher with the job's already-installed MSVC toolchain. The windows-rust CI job gains a cargo test step for the launcher so its spawn path gates on a real Windows runner. Per the plan's risk note, codex-on-Windows maturity is a validate- before-release concern: the lock's per-tool-per-target shape allows dropping the codex-acp windows entry if a real session shakes out badly. win32-arm64 packages exist but there is no arm64 Windows release job; deferred until one exists. Node.js stays a user prerequisite, surfaced by the existing node-runtime Doctor section. Verification (macOS host): - cargo test -p buzz-acp-node-launcher: 9 passed — manifest parsing, version parsing, path resolution, plus end-to-end launcher runs (arg/stdio/exit-code proxying via real node, missing-manifest, missing-entrypoint, and too-old-Node failures with the wrapper-shim message). cargo clippy --all-targets -D warnings and cargo fmt --all --check: clean. - Cross-staged the Windows target end-to-end with ACP_NODE_LAUNCHER_EXE standing in for the MSVC launcher: both win32 npm trees install and validate against the lock (integrity + X_OK on the vendored claude.exe/codex.exe, confirming the locked vendor paths), bin dir stages .exe + .exe.stamp + .shim.json with relative entrypoints, re-run performs zero installs, stray .exe/.shim.json artifacts are pruned, and prepare writes harness-clis.json with bare "claude"/"codex" keys mapping to the vendored .exe paths. - Empty-target Windows staging (aarch64-pc-windows-msvc) exits 0 with the notice, without cargo on PATH. - Darwin re-stage after the cross-stage: bash wrappers report 0.58.1 / 1.1.2, manifests back to darwin shape — Unix staging unregressed. - biome check on update-acp-tools-lock.mjs: clean. Windows-runner validation (NSIS install, Doctor states, live claude + codex sessions, auth probes against the vendored CLIs) needs a real Windows machine and rides the first release train with these entries. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .github/workflows/ci.yml | 6 + .github/workflows/release.yml | 3 + Cargo.lock | 10 + Cargo.toml | 1 + crates/buzz-acp-node-launcher/Cargo.toml | 21 ++ crates/buzz-acp-node-launcher/src/main.rs | 278 ++++++++++++++++++ .../buzz-acp-node-launcher/tests/launcher.rs | 115 ++++++++ desktop/acp-tools.lock.json | 47 +++ desktop/scripts/ensure-acp-tools.sh | 39 ++- desktop/scripts/lib/acp-node-wrapper.sh | 87 ++++++ desktop/scripts/prepare-acp-tools-resource.sh | 28 +- desktop/scripts/update-acp-tools-lock.mjs | 13 + 12 files changed, 640 insertions(+), 8 deletions(-) create mode 100644 crates/buzz-acp-node-launcher/Cargo.toml create mode 100644 crates/buzz-acp-node-launcher/src/main.rs create mode 100644 crates/buzz-acp-node-launcher/tests/launcher.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d8b5d378d..735fd85c38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -856,6 +856,12 @@ jobs: # Serial: windows_resolver_tests mutate process-global env # (BUZZ_SHELL/GIT_BASH/SystemRoot) that SharedState::new reads. run: cargo test -p buzz-dev-mcp --target $env:TARGET -- --test-threads=1 + - name: Test (buzz-acp-node-launcher) + # The compiled launcher shim staged as the bundled ACP bridges' + # .exe on Windows; its spawn path (Job Object, exit-code + # proxying) only gates if tested ON Windows. The end-to-end tests use + # the node preinstalled on windows-latest runners. + run: cargo test -p buzz-acp-node-launcher --target $env:TARGET # Smoke-test the new host-prereq contract: Git for Windows (which provides # bash) is available on the runner, a shell command round-trips, and bash # does NOT resolve from System32 (so WSL's launcher is never picked up). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ccb9b77aa..909f7e91dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -757,6 +757,9 @@ jobs: 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" + # Also builds the buzz-acp-node-launcher shim for the target with the + # toolchain installed above (Windows targets stage it as the bridges' + # .exe). - name: Stage bundled ACP tools shell: bash run: ./desktop/scripts/prepare-acp-tools-resource.sh "$TARGET" diff --git a/Cargo.lock b/Cargo.lock index 6ba6f6d5d0..95ace47120 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -747,6 +747,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-acp-node-launcher" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "buzz-admin" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 17267207a7..fef04b5025 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/buzz-search", "crates/buzz-audit", "crates/buzz-acp", + "crates/buzz-acp-node-launcher", "crates/buzz-agent", "crates/sprig", "crates/buzz-test-client", diff --git a/crates/buzz-acp-node-launcher/Cargo.toml b/crates/buzz-acp-node-launcher/Cargo.toml new file mode 100644 index 0000000000..f6c00e08a9 --- /dev/null +++ b/crates/buzz-acp-node-launcher/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "buzz-acp-node-launcher" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Compiled launcher shim for the bundled ACP bridge tools on Windows" + +[[bin]] +name = "buzz-acp-node-launcher" +path = "src/main.rs" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_JobObjects"] } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/buzz-acp-node-launcher/src/main.rs b/crates/buzz-acp-node-launcher/src/main.rs new file mode 100644 index 0000000000..e77adf0cde --- /dev/null +++ b/crates/buzz-acp-node-launcher/src/main.rs @@ -0,0 +1,278 @@ +#![cfg_attr(not(windows), forbid(unsafe_code))] +//! Compiled launcher shim for the bundled ACP bridge tools. +//! +//! Unix targets stage the bundled bridges (`claude-agent-acp`, `codex-acp`) +//! as bash wrapper shims (`desktop/scripts/lib/acp-node-wrapper.sh`), which +//! Windows cannot execute — and the desktop's command resolution only looks +//! for `.exe` there anyway. Windows targets stage this launcher as +//! `.exe` next to a `.shim.json` manifest instead. The +//! launcher reproduces the wrapper contract exactly: verify a Node.js +//! runtime satisfying the locked engine range is on PATH, then run node on +//! the vendored entrypoint, forwarding arguments, stdio, and the exit code. +//! +//! On Windows the node child is additionally assigned to a Job Object with +//! `KILL_ON_JOB_CLOSE`, so terminating the launcher (as the buzz-acp +//! harness's `kill_on_drop` does when a session ends) takes the node process +//! tree with it instead of orphaning it. On Unix the launcher execs node, +//! matching the bash shim; it exists there only so the crate builds and +//! tests on every workspace platform. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// On-disk shape of the `.shim.json` manifest written by +/// `desktop/scripts/lib/acp-node-wrapper.sh` next to the staged launcher. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ShimManifest { + /// JS entrypoint to run; a relative path resolves against the launcher's + /// own directory, mirroring the bash wrapper. + entrypoint: String, + /// The lock's Node engine range (e.g. ">=22"), used verbatim in error + /// messages so they match the bash wrapper's. + node_engine: String, + /// Minimum Node.js major version enforced before spawning. + required_node_major: u32, +} + +/// The manifest sits next to the launcher under the same staged binary name: +/// `claude-agent-acp.exe` reads `claude-agent-acp.shim.json`. +fn shim_manifest_path(exe: &Path) -> PathBuf { + exe.with_extension("shim.json") +} + +fn resolve_entrypoint(exe_dir: &Path, entrypoint: &str) -> PathBuf { + let entrypoint = Path::new(entrypoint); + if entrypoint.is_absolute() { + entrypoint.to_path_buf() + } else { + exe_dir.join(entrypoint) + } +} + +fn parse_node_major(version: &str) -> Option { + version.trim().split('.').next()?.parse().ok() +} + +/// `Ok(None)` means node ran but produced no parsable version; `Err` is a +/// spawn failure (`NotFound` when node is not on PATH at all). +fn node_major_version() -> std::io::Result> { + let output = Command::new("node") + .args(["-p", "process.versions.node"]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output()?; + if !output.status.success() { + return Ok(None); + } + Ok(parse_node_major(&String::from_utf8_lossy(&output.stdout))) +} + +fn fail(message: std::fmt::Arguments<'_>, code: i32) -> ! { + eprintln!("{message}"); + std::process::exit(code); +} + +/// Run node on the entrypoint, forwarding stdio and the exit code. Diverges: +/// on Unix this execs (the launcher *becomes* node, like the bash shim); on +/// Windows it waits on a Job-Object-managed child. +fn run_node(name: &str, entrypoint: &Path, args: std::env::ArgsOs) -> ! { + let mut command = Command::new("node"); + command.arg(entrypoint).args(args.skip(1)); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let error = command.exec(); + fail(format_args!("{name}: failed to exec node: {error}"), 1); + } + + #[cfg(windows)] + { + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => fail(format_args!("{name}: failed to spawn node: {error}"), 1), + }; + // Held for the launcher's lifetime: the OS closes the handle at + // process exit, which is exactly when KILL_ON_JOB_CLOSE should fire. + // After a normal wait() the child is already gone and the close is a + // no-op; on TerminateProcess it reaps the whole node tree. + let _job = job::KillOnCloseJob::assign(&child); + match child.wait() { + Ok(status) => std::process::exit(status.code().unwrap_or(1)), + Err(error) => fail(format_args!("{name}: failed to wait on node: {error}"), 1), + } + } +} + +fn main() { + let exe = match std::env::current_exe() { + Ok(exe) => exe, + Err(error) => fail( + format_args!("acp-node-launcher: cannot determine own path: {error}"), + 1, + ), + }; + let name = exe + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| "acp-node-launcher".to_string()); + + let manifest_path = shim_manifest_path(&exe); + let manifest_raw = match std::fs::read_to_string(&manifest_path) { + Ok(raw) => raw, + Err(error) => fail( + format_args!( + "{name}: cannot read shim manifest {}: {error}", + manifest_path.display() + ), + 1, + ), + }; + let manifest: ShimManifest = match serde_json::from_str(&manifest_raw) { + Ok(manifest) => manifest, + Err(error) => fail( + format_args!( + "{name}: invalid shim manifest {}: {error}", + manifest_path.display() + ), + 1, + ), + }; + + let exe_dir = exe.parent().unwrap_or_else(|| Path::new(".")); + let entrypoint = resolve_entrypoint(exe_dir, &manifest.entrypoint); + if !entrypoint.is_file() { + fail( + format_args!( + "{name}: bundled entrypoint missing: {}", + entrypoint.display() + ), + 1, + ); + } + + // Same message and exit codes as the bash wrapper: 127 when node is not + // on PATH at all, 1 when it is too old (or unidentifiable). + match node_major_version() { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => fail( + format_args!("{name} requires Node.js {} on PATH.", manifest.node_engine), + 127, + ), + Ok(Some(major)) if major >= manifest.required_node_major => {} + Ok(_) | Err(_) => fail( + format_args!("{name} requires Node.js {} on PATH.", manifest.node_engine), + 1, + ), + } + + run_node(&name, &entrypoint, std::env::args_os()); +} + +#[cfg(windows)] +mod job { + //! Job Object holding the node child, mirroring buzz-dev-mcp's + //! `KillGroup` (crates/buzz-dev-mcp/src/shell.rs): `KILL_ON_JOB_CLOSE` + //! kills every process still in the job when the last handle closes. + + use std::os::windows::io::AsRawHandle; + + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }; + + pub struct KillOnCloseJob { + _job: HANDLE, + } + + impl KillOnCloseJob { + /// Best-effort: a creation or assignment failure leaves the child + /// unmanaged (the outer harness Job Object still reaps it at app + /// shutdown) rather than failing the launch. + pub fn assign(child: &std::process::Child) -> Self { + // SAFETY: each call is a documented Win32 FFI call with arguments + // that satisfy its contract — a null SECURITY_ATTRIBUTES/name for + // an anonymous job, a zeroed #[repr(C)] info struct sized by + // size_of, and the live process handle owned by `child`. A null + // job HANDLE on failure makes the later calls harmless no-ops. + let job = unsafe { + let job: HANDLE = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + if !job.is_null() { + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + std::ptr::addr_of!(info).cast(), + std::mem::size_of::() as u32, + ); + AssignProcessToJobObject(job, child.as_raw_handle() as HANDLE); + } + job + }; + Self { _job: job } + } + } +} + +#[cfg(test)] +mod tests { + use super::{parse_node_major, resolve_entrypoint, shim_manifest_path, ShimManifest}; + use std::path::Path; + + #[test] + fn manifest_parses_the_staging_script_shape() { + let manifest: ShimManifest = serde_json::from_str( + r#"{ + "entrypoint": "../node/claude-acp/node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js", + "nodeEngine": ">=22", + "requiredNodeMajor": 22 + }"#, + ) + .expect("parse manifest"); + assert!(manifest.entrypoint.ends_with("dist/index.js")); + assert_eq!(manifest.node_engine, ">=22"); + assert_eq!(manifest.required_node_major, 22); + } + + #[test] + fn manifest_rejects_missing_fields() { + assert!(serde_json::from_str::(r#"{"entrypoint": "index.js"}"#).is_err()); + } + + #[test] + fn shim_manifest_sits_next_to_the_launcher_under_the_staged_name() { + assert_eq!( + shim_manifest_path(Path::new("/acp/bin/claude-agent-acp.exe")), + Path::new("/acp/bin/claude-agent-acp.shim.json"), + ); + // Unix-style staging without an extension gains the suffix whole. + assert_eq!( + shim_manifest_path(Path::new("/acp/bin/codex-acp")), + Path::new("/acp/bin/codex-acp.shim.json"), + ); + } + + #[test] + fn relative_entrypoints_resolve_against_the_launcher_dir() { + assert_eq!( + resolve_entrypoint(Path::new("/acp/bin"), "../node/x/dist/index.js"), + Path::new("/acp/bin/../node/x/dist/index.js"), + ); + assert_eq!( + resolve_entrypoint(Path::new("/acp/bin"), "/abs/dist/index.js"), + Path::new("/abs/dist/index.js"), + ); + } + + #[test] + fn node_major_parses_plain_and_noisy_versions() { + assert_eq!(parse_node_major("22.14.0\n"), Some(22)); + assert_eq!(parse_node_major("24"), Some(24)); + assert_eq!(parse_node_major(""), None); + assert_eq!(parse_node_major("not-a-version"), None); + } +} diff --git a/crates/buzz-acp-node-launcher/tests/launcher.rs b/crates/buzz-acp-node-launcher/tests/launcher.rs new file mode 100644 index 0000000000..e8bd17f585 --- /dev/null +++ b/crates/buzz-acp-node-launcher/tests/launcher.rs @@ -0,0 +1,115 @@ +//! End-to-end tests for the launcher shim: copy the built binary under a +//! bridge name next to a `.shim.json` manifest — exactly how the +//! staging scripts lay it out — and run it against the real node on PATH. +//! Tests that need node skip (loudly) when it is absent, so the suite still +//! passes on stripped-down environments. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn node_available() -> bool { + Command::new("node") + .arg("--version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +/// Stage the launcher under `name` in `dir`, as the staging scripts do. +fn stage_launcher(dir: &Path, name: &str) -> PathBuf { + let staged = dir.join(format!("{name}{}", std::env::consts::EXE_SUFFIX)); + fs::copy(env!("CARGO_BIN_EXE_buzz-acp-node-launcher"), &staged).expect("stage launcher"); + staged +} + +fn write_shim_manifest(dir: &Path, name: &str, entrypoint: &str, required_node_major: u32) { + fs::write( + dir.join(format!("{name}.shim.json")), + format!( + r#"{{"entrypoint":{},"nodeEngine":">={required_node_major}","requiredNodeMajor":{required_node_major}}}"#, + serde_json::to_string(entrypoint).expect("encode entrypoint"), + ), + ) + .expect("write shim manifest"); +} + +#[test] +fn runs_entrypoint_forwarding_args_stdio_and_exit_code() { + if !node_available() { + eprintln!("skipping: node not on PATH"); + return; + } + let temp = tempfile::tempdir().expect("temp dir"); + fs::write( + temp.path().join("entry.js"), + "console.log(['ok', ...process.argv.slice(2)].join(' '));\nprocess.exit(7);\n", + ) + .expect("write entrypoint"); + // Relative entrypoint: must resolve against the launcher's directory. + write_shim_manifest(temp.path(), "claude-agent-acp", "entry.js", 0); + let staged = stage_launcher(temp.path(), "claude-agent-acp"); + + let output = Command::new(&staged) + .args(["--flag", "value"]) + .output() + .expect("run staged launcher"); + + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "ok --flag value" + ); + assert_eq!(output.status.code(), Some(7)); +} + +#[test] +fn missing_manifest_fails_loudly() { + let temp = tempfile::tempdir().expect("temp dir"); + let staged = stage_launcher(temp.path(), "codex-acp"); + + let output = Command::new(&staged).output().expect("run staged launcher"); + + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("shim manifest"), + "stderr should name the missing manifest: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn missing_entrypoint_fails_loudly() { + let temp = tempfile::tempdir().expect("temp dir"); + write_shim_manifest(temp.path(), "codex-acp", "no-such/dist/index.js", 0); + let staged = stage_launcher(temp.path(), "codex-acp"); + + let output = Command::new(&staged).output().expect("run staged launcher"); + + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("entrypoint missing"), + "stderr should name the missing entrypoint: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn node_major_below_requirement_is_rejected() { + if !node_available() { + eprintln!("skipping: node not on PATH"); + return; + } + let temp = tempfile::tempdir().expect("temp dir"); + fs::write(temp.path().join("entry.js"), "process.exit(0);\n").expect("write entrypoint"); + write_shim_manifest(temp.path(), "claude-agent-acp", "entry.js", 999); + let staged = stage_launcher(temp.path(), "claude-agent-acp"); + + let output = Command::new(&staged).output().expect("run staged launcher"); + + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("requires Node.js >=999 on PATH."), + "stderr should carry the wrapper-shim message: {}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/desktop/acp-tools.lock.json b/desktop/acp-tools.lock.json index 6ef4947bf6..d9430ca33d 100644 --- a/desktop/acp-tools.lock.json +++ b/desktop/acp-tools.lock.json @@ -73,6 +73,30 @@ "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.205.tgz", "nativeExecutable": "claude" }, + { + "id": "claude-acp", + "binary": "claude-agent-acp", + "source": "npm", + "package": "@agentclientprotocol/claude-agent-acp", + "version": "0.58.1", + "integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==", + "tarball": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz", + "target": "x86_64-pc-windows-msvc", + "npmOs": "win32", + "npmCpu": "x64", + "nodeEngine": ">=22", + "dependencyPackage": "@anthropic-ai/claude-agent-sdk", + "dependencyVersion": "0.3.205", + "dependencyIntegrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==", + "dependencyTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz", + "claudeCodeVersion": "2.1.205", + "nativePackage": "@anthropic-ai/claude-agent-sdk-win32-x64", + "nativePackageName": "@anthropic-ai/claude-agent-sdk-win32-x64", + "nativeVersion": "0.3.205", + "nativeIntegrity": "sha512-kg2kkXyeSoFLruO3Ic2IruLxzBR0xCUtmlJHdWi3SYW7JhAKNJg4fcrdJsWcardmEw23Y2UDGDJbRyxqSVx6wg==", + "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.205.tgz", + "nativeExecutable": "claude.exe" + }, { "id": "claude-acp", "binary": "claude-agent-acp", @@ -168,6 +192,29 @@ "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-darwin-x64.tgz", "nativeExecutable": "vendor/x86_64-apple-darwin/bin/codex" }, + { + "id": "codex-acp", + "binary": "codex-acp", + "source": "npm", + "package": "@agentclientprotocol/codex-acp", + "version": "1.1.2", + "integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==", + "tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz", + "target": "x86_64-pc-windows-msvc", + "npmOs": "win32", + "npmCpu": "x64", + "nodeEngine": ">=22", + "dependencyPackage": "@openai/codex", + "dependencyVersion": "0.144.1", + "dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==", + "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz", + "nativePackage": "@openai/codex-win32-x64", + "nativePackageName": "@openai/codex", + "nativeVersion": "0.144.1-win32-x64", + "nativeIntegrity": "sha512-qv2HOp6v/nVP31p5I5GxYyL0wa79PMzim1+W9CKSV0UldjFV9AMbualA8PeXcYhbvvh9Y1UASXxwjuQdlyfAvw==", + "nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-win32-x64.tgz", + "nativeExecutable": "vendor/x86_64-pc-windows-msvc/bin/codex.exe" + }, { "id": "codex-acp", "binary": "codex-acp", diff --git a/desktop/scripts/ensure-acp-tools.sh b/desktop/scripts/ensure-acp-tools.sh index da1b91c153..8477acdc8d 100755 --- a/desktop/scripts/ensure-acp-tools.sh +++ b/desktop/scripts/ensure-acp-tools.sh @@ -16,7 +16,10 @@ Installs the ACP bridge tools pinned in acp-tools.lock.json into the shared Buzz dev cache. The lockfile is target-specific; only entries matching the requested target are prepared. Each tool is installed as a vendored npm package tree with a small executable wrapper, validated against the locked -versions and integrity hashes. +versions and integrity hashes. Unix targets get a bash wrapper shim; Windows +targets get the compiled buzz-acp-node-launcher staged as .exe next +to a .shim.json manifest (built with cargo, override with +ACP_NODE_LAUNCHER_EXE). Environment variables: ACP_TOOLS_LOCK_FILE lockfile path (default: desktop/acp-tools.lock.json) @@ -141,6 +144,15 @@ if [[ "$entry_count" == "0" ]]; then exit 0 fi +# Windows targets stage the compiled launcher shim instead of a bash +# wrapper; it needs the repo's Rust toolchain. Built once up front — the +# per-tool loop below runs in a pipeline subshell. +launcher_exe="" +if acp_target_is_windows "$target"; then + require_tool cargo + launcher_exe="$(acp_node_launcher_exe "$target")" +fi + validate_npm_install() { local install_dir="$1" local package="$2" @@ -285,7 +297,11 @@ for (const entry of entries) { package_dir="$install_dir/node_modules/$package" entrypoint="$package_dir/dist/index.js" native_binary="$install_dir/node_modules/$native_package/$native_executable" - staged_bin="$bin_dir/$binary" + staged_bin="$bin_dir/$(acp_staged_binary_name "$binary" "$target")" + # Windows shims embed a bin-dir-relative entrypoint: under Git Bash the + # absolute cache path is POSIX-style (/c/Users/...), which the native + # launcher cannot resolve. + entrypoint_from_bin_dir="../../$target/$id/$version/npm/node_modules/$package/dist/index.js" # The staged output is shared across lock versions, so its freshness stamp # must live next to it, not in the per-version tool_dir: a per-version stamp # stays self-consistent after a lock revert and would skip re-staging. @@ -294,6 +310,12 @@ for (const entry of entries) { # shellcheck disable=SC1090 source "$stamp" if [[ "${STAMP_PACKAGE:-}" == "$package" && "${STAMP_VERSION:-}" == "$version" && "${STAMP_INTEGRITY:-}" == "$integrity" && "${STAMP_DEPENDENCY_PACKAGE:-}" == "$dependency_package" && "${STAMP_DEPENDENCY_VERSION:-}" == "$dependency_version" && "${STAMP_DEPENDENCY_INTEGRITY:-}" == "$dependency_integrity" && "${STAMP_NATIVE_PACKAGE:-}" == "$native_package" && "${STAMP_NATIVE_PACKAGE_NAME:-}" == "$native_package_name" && "${STAMP_NATIVE_VERSION:-}" == "$native_version" && "${STAMP_NATIVE_INTEGRITY:-}" == "$native_integrity" && "${STAMP_NATIVE_EXECUTABLE:-}" == "$native_executable" ]]; then + # The npm tree is fresh, but the compiled launcher tracks the crate, + # not the lock, so the stamp cannot see it change — refresh it every + # run (the copy no-ops when already identical). + if acp_target_is_windows "$target"; then + write_windows_node_launcher "$staged_bin" "$launcher_exe" "$entrypoint_from_bin_dir" "$node_engine" + fi continue fi fi @@ -319,7 +341,11 @@ for (const entry of entries) { npm "${npm_args[@]}" >&2 validate_npm_install "$install_dir" "$package" "$version" "$integrity" "$dependency_package" "$dependency_version" "$dependency_integrity" "$native_package" "$native_package_name" "$native_version" "$native_integrity" "$native_executable" "$claude_code_version" - write_node_wrapper "$staged_bin" "$entrypoint" "$node_engine" + if acp_target_is_windows "$target"; then + write_windows_node_launcher "$staged_bin" "$launcher_exe" "$entrypoint_from_bin_dir" "$node_engine" + else + write_node_wrapper "$staged_bin" "$entrypoint" "$node_engine" + fi { printf 'STAMP_TARGET=%q\n' "$target" printf 'STAMP_PACKAGE=%q\n' "$package" @@ -354,7 +380,12 @@ for (const entry of entries) console.log(entry.binary); ' "$lock_entries" | sort -u)" find "$bin_dir" -type f -print0 | while IFS= read -r -d '' staged_file; do name="$(basename "$staged_file")" - if ! printf '%s\n' "$locked_binaries" | grep -Fxq -- "${name%.stamp}"; then + # Reduce every staged artifact shape to the lock's bare binary name: + # [.exe][.stamp] and the Windows launcher's .shim.json. + base="${name%.stamp}" + base="${base%.shim.json}" + base="${base%.exe}" + if ! printf '%s\n' "$locked_binaries" | grep -Fxq -- "$base"; then rm -f -- "$staged_file" fi done diff --git a/desktop/scripts/lib/acp-node-wrapper.sh b/desktop/scripts/lib/acp-node-wrapper.sh index 7d99839ee1..8689d22a18 100644 --- a/desktop/scripts/lib/acp-node-wrapper.sh +++ b/desktop/scripts/lib/acp-node-wrapper.sh @@ -4,6 +4,30 @@ # resources cannot drift (a drift would make dev and bundled installs fail # differently on the same missing/old Node runtime). # +# Unix targets stage a bash wrapper shim (write_node_wrapper); Windows +# targets stage the compiled buzz-acp-node-launcher as `.exe` next +# to a `.shim.json` manifest (write_windows_node_launcher) — Windows +# cannot execute bash shims, and the desktop's command resolution only looks +# for `.exe`. Both shims enforce the same lock-derived Node engine +# requirement. +# +# acp_target_is_windows +# Whether the Rust target triple names a Windows target. +acp_target_is_windows() { + [[ "$1" == *-windows-* ]] +} + +# acp_staged_binary_name +# The filename a tool's shim is staged under for : the lock's bare +# binary name on Unix, `.exe` on Windows. +acp_staged_binary_name() { + if acp_target_is_windows "$2"; then + printf '%s.exe\n' "$1" + else + printf '%s\n' "$1" + fi +} +# # acp_required_node_major # Prints the minimum Node.js major version implied by a ">=N..." engine # range, defaulting to 22 when the range is not in that form. Shared by @@ -59,3 +83,66 @@ write_node_wrapper() { } > "$wrapper" chmod +x "$wrapper" } + +# acp_node_launcher_exe +# Prints the path to the compiled Windows launcher shim for , +# building it with cargo when needed (a no-op rebuild when up to date). +# ACP_NODE_LAUNCHER_EXE overrides the build entirely — for callers that +# already built the crate, and for cross-target staging tests on hosts +# without the Windows toolchain. +acp_node_launcher_exe() { + local target="$1" + if [[ -n "${ACP_NODE_LAUNCHER_EXE:-}" ]]; then + printf '%s\n' "$ACP_NODE_LAUNCHER_EXE" + return + fi + # The lib lives at desktop/scripts/lib; the launcher crate is in the repo + # root workspace so the Windows release job's warm target dir is reused. + local repo_root manifest target_dir + repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" + manifest="$repo_root/Cargo.toml" + echo "Building ACP node launcher shim for $target..." >&2 + cargo build --release --manifest-path "$manifest" -p buzz-acp-node-launcher --target "$target" >&2 + # Resolve the target dir instead of assuming ./target — CARGO_TARGET_DIR + # and .cargo/config redirects are common. + target_dir="$(cargo metadata --format-version 1 --no-deps --manifest-path "$manifest" \ + | node -e 'process.stdout.write(JSON.parse(require("node:fs").readFileSync(0, "utf8")).target_directory)')" + printf '%s/%s/release/buzz-acp-node-launcher.exe\n' "$target_dir" "$target" +} + +# write_windows_node_launcher [node-engine] +# Stages the compiled launcher shim at and writes the sibling +# `.shim.json` manifest the launcher reads at spawn time. Mirrors +# write_node_wrapper's contract: a relative resolves against +# the launcher's directory at run time. Idempotent — the copy is skipped +# when the staged launcher is already identical, so a re-stage never +# rewrites an .exe a running agent may hold open. +write_windows_node_launcher() { + local dest_exe="$1" + local launcher_exe="$2" + local entrypoint="$3" + local node_engine="${4:->=22}" + local required_node_major + required_node_major="$(acp_required_node_major "$node_engine")" + + if [[ ! -f "$launcher_exe" ]]; then + echo "ACP node launcher shim not found: $launcher_exe" >&2 + return 1 + fi + mkdir -p "$(dirname "$dest_exe")" + if ! cmp -s "$launcher_exe" "$dest_exe"; then + cp -f "$launcher_exe" "$dest_exe" + fi + chmod +x "$dest_exe" + ACP_SHIM_ENTRYPOINT="$entrypoint" \ + ACP_SHIM_NODE_ENGINE="$node_engine" \ + ACP_SHIM_REQUIRED_NODE_MAJOR="$required_node_major" \ + node -e ' +const fs = require("node:fs"); +fs.writeFileSync(process.argv[1], `${JSON.stringify({ + entrypoint: process.env.ACP_SHIM_ENTRYPOINT, + nodeEngine: process.env.ACP_SHIM_NODE_ENGINE, + requiredNodeMajor: Number(process.env.ACP_SHIM_REQUIRED_NODE_MAJOR), +}, null, 2)}\n`); +' "${dest_exe%.exe}.shim.json" +} diff --git a/desktop/scripts/prepare-acp-tools-resource.sh b/desktop/scripts/prepare-acp-tools-resource.sh index 4bc67126fc..0b8ea5e14d 100755 --- a/desktop/scripts/prepare-acp-tools-resource.sh +++ b/desktop/scripts/prepare-acp-tools-resource.sh @@ -14,8 +14,10 @@ Usage: desktop/scripts/prepare-acp-tools-resource.sh [target-triple] Stages the locked ACP bridge tools into src-tauri/resources/acp so Tauri can bundle them as application resources: vendored npm package trees under -resources/acp/node and executable wrappers under resources/acp/bin. The -optional target triple defaults to the Rust host target. +resources/acp/node and executable wrappers under resources/acp/bin (bash +shims on Unix targets; the compiled buzz-acp-node-launcher plus +.shim.json manifests on Windows targets). The optional target triple +defaults to the Rust host target. Note: resources/acp/bin holds a single target at a time, so staging must stay tied to the build target. @@ -41,6 +43,12 @@ fi cache_bin_dir="$("$script_dir/ensure-acp-tools.sh" ${ensure_args[@]+"${ensure_args[@]}"} --print-bin-dir)" cache_root="$(dirname "$(dirname "$cache_bin_dir")")" + +# Windows targets stage the compiled launcher shim instead of a bash +# wrapper. Resolved lazily at first use so a target with no locked tools +# stays a no-op stage; ensure-acp-tools.sh above already built the launcher +# for any target that has them, so resolution hits a warm target dir. +launcher_exe="" resource_root="$app_root/src-tauri/resources/acp" resource_bin_dir="$resource_root/bin" resource_node_dir="$resource_root/node" @@ -101,7 +109,14 @@ while IFS=$'\t' read -r id binary package version node_engine native_package nat echo "Failed to stage npm ACP tool: $package@$version" >&2 exit 1 fi - write_node_wrapper "$resource_bin_dir/$binary" "../node/$id/node_modules/$package/dist/index.js" "$node_engine" + if acp_target_is_windows "$target"; then + if [[ -z "$launcher_exe" ]]; then + launcher_exe="$(acp_node_launcher_exe "$target")" + fi + write_windows_node_launcher "$resource_bin_dir/$(acp_staged_binary_name "$binary" "$target")" "$launcher_exe" "../node/$id/node_modules/$package/dist/index.js" "$node_engine" + else + write_node_wrapper "$resource_bin_dir/$binary" "../node/$id/node_modules/$package/dist/index.js" "$node_engine" + fi node_runtime_entries+=("$id"$'\t'"$binary"$'\t'"$node_engine"$'\t'"$(acp_required_node_major "$node_engine")") # Record the vendored native harness CLI (relative to the acp resource # root) for the auth-probe manifest. Fail loudly if the lock names one @@ -115,7 +130,12 @@ while IFS=$'\t' read -r id binary package version node_engine native_package nat exit 1 fi chmod +x "$cli_abspath" - harness_cli_entries+=("$id"$'\t'"$(basename "$native_executable")"$'\t'"$cli_relpath") + # The manifest keys CLIs by the bare probe name the app resolves + # ("claude", "codex"), so the Windows vendored executables drop their + # .exe suffix here. + cli_name="$(basename "$native_executable")" + cli_name="${cli_name%.exe}" + harness_cli_entries+=("$id"$'\t'"$cli_name"$'\t'"$cli_relpath") fi # Ad-hoc sign every Mach-O in the staged package, not just the main CLIs: # the codex native package also vendors executables like rg and zsh, and diff --git a/desktop/scripts/update-acp-tools-lock.mjs b/desktop/scripts/update-acp-tools-lock.mjs index f80091c21b..53403896b4 100755 --- a/desktop/scripts/update-acp-tools-lock.mjs +++ b/desktop/scripts/update-acp-tools-lock.mjs @@ -13,6 +13,7 @@ const SUPPORTED_TARGETS = [ "x86_64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", ]; // The Codex ACP executable stays `codex-acp`, but bundled installs must come @@ -89,6 +90,18 @@ const NPM_TARGET_CONFIG = { openaiCodex: "vendor/x86_64-unknown-linux-musl/bin/codex", }, }, + "x86_64-pc-windows-msvc": { + npmOs: "win32", + npmCpu: "x64", + nativePackages: { + claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-win32-x64", + openaiCodex: "@openai/codex-win32-x64", + }, + nativeExecutables: { + claudeAgentSdk: "claude.exe", + openaiCodex: "vendor/x86_64-pc-windows-msvc/bin/codex.exe", + }, + }, }; // Tarball URLs in the lock are informational — ensure-acp-tools.sh installs