From a29a3da6fea966b0debeb6e9cbc4e90c36615f2a Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 13 Jul 2026 13:08:27 +1000 Subject: [PATCH 01/20] 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 ed2d1a941..bbf535883 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 d49369b2f..ea50e8a2d 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}} @@ -330,6 +335,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 @@ -358,6 +368,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 000000000..d2361a3b6 --- /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 000000000..ddee056ca --- /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 000000000..7d99839ee --- /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 000000000..df7f84047 --- /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 000000000..bffcf0c32 --- /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 000000000..e69de29bb diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index a8600f3bc..fbda81d65 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 f32d436daa8212c3b028485e629bc991ca315759 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 13 Jul 2026 13:22:02 +1000 Subject: [PATCH 02/20] 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 | 6 +- 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, 291 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 d34611613..0b8dc2ee2 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -279,7 +279,11 @@ const overrides = new Map([ // +13: fetch_login_shell_path_inner Windows guard (POSIX PATH → None). // resolve_git_bash made pub(crate) for Windows test access. // +1: login_shell_candidates doc comment expanded for resolve_bash_path. - ["src-tauri/src/managed_agents/discovery.rs", 1366], + // bundle-acps: bundled ACP bridge check at the top of the resolution sweep + // (+4 lines over the Windows baseline). 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", 1371], // 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 a9d37c5dc..adb7d691a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -446,6 +446,12 @@ pub fn run() { return Ok(()); } + // 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. if reset_outcome.completed { migration::run_boot_migrations_after_reset(&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 000000000..e7b4e444b --- /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 5ba0500aa..2f64a6a3b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -268,12 +268,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() @@ -469,7 +469,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; }; @@ -629,6 +629,13 @@ fn command_basenames(command: &str) -> Vec { } 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 d99d38f0f..e5ed4a4b1 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 8e2f866b7..703f0d00f 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 3745fe571..a61987392 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1542,6 +1542,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/) @@ -1550,6 +1551,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 af2d2e96d..4db6e62db 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 f88d90353400b5e5faf63ee8f6a16c1017d3b376 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 13 Jul 2026 13:35:59 +1000 Subject: [PATCH 03/20] 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 0b8dc2ee2..c0c6e9989 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -202,7 +202,11 @@ const overrides = new Map([ // mapper. This is the existing API boundary; split remains queued. // team-instructions-first-class: createManagedAgent Tauri bridge threads the // new teamId input through to the backend (+1 line). - ["src/shared/api/tauri.ts", 1305], + // bundle-acps: RawNodeRuntimeCheck type + fromRawNodeRuntimeCheck mapper + + // checkAcpNodeRuntime wrapper for the bundled-bridge Node.js doctor check + // (+35 lines on rebase union with main's Doctor/team growth). Queued to + // split with the rest of this file. + ["src/shared/api/tauri.ts", 1340], // 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/ @@ -217,7 +221,10 @@ const overrides = new Map([ // doc comment) and AgentTeam/CreateTeamInput/UpdateTeamInput.instructions // (+3) — the new team-id spawn link and the runtime-layered instructions // field. - ["src/shared/api/types.ts", 1047], + // bundle-acps: NodeRuntimeCheck + NodeRuntimeRequirement types for the + // bundled-bridge Node.js doctor check (+23 lines on rebase union with + // main's Git Bash / signout-wipe / team-instructions type growth). + ["src/shared/api/types.ts", 1070], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog // shows required markers and credential amber rows (parity with @@ -447,7 +454,9 @@ const overrides = new Map([ // +53: pass 2 — three cfg(windows) install shell tests (resolve succeeds with // Git, error hint content, install_shell_command succeeds). // +8: install_shell_from pure seam extracted for deterministic testing. - ["src-tauri/src/commands/agent_discovery.rs", 1523], + // bundle-acps: node runtime check wiring for the bundled-bridge Doctor + // requirement (+9 lines on rebase union with the Windows Doctor install fix). + ["src-tauri/src/commands/agent_discovery.rs", 1532], // 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 f939c95e4..b10f16dcc 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 bf4ba7315..052bc44ff 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 adb7d691a..083a89f2b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -736,6 +736,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 e7b4e444b..ca15f4ce2 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 e5ed4a4b1..2a010dde4 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; pub(crate) mod git_bash; pub(crate) mod global_config; mod nest; +pub(crate) mod node_runtime; mod persona_avatars; pub(crate) mod persona_events; mod personas; 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 000000000..59bf77653 --- /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 7672b74b1..3c0cc9731 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -14,6 +14,7 @@ import { } from "@/features/channels/hooks"; import { evictUsersBatchEntries } from "@/features/profile/hooks"; import { + checkAcpNodeRuntime, createManagedAgent, deleteManagedAgent, discoverAcpRuntimes, @@ -101,6 +102,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; @@ -184,6 +186,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 75c10e183..5350a52a2 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 9dc9e69a5..b9ebf7ff2 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 { @@ -1093,6 +1121,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 8d125d56e..997b70933 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -597,6 +597,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 d8a53e08f..c334cb1eb 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"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -112,6 +113,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. @@ -8927,6 +8931,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 a1aed0490..80043393f 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 4b9b0639b2e992cdc30b6891c474a582c76ab423 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 13 Jul 2026 13:54:22 +1000 Subject: [PATCH 04/20] 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 | 41 ++-- .../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 | 170 +------------- 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, 85 insertions(+), 779 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 c0c6e9989..59428ac19 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -152,12 +152,6 @@ 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). @@ -167,7 +161,10 @@ const overrides = new Map([ // Windows Doctor install fix: cli_install_commands_windows field added to test stubs. // team-instructions-first-class: ManagedAgentRecord fixture gains the new // team_id field (+1 line). - ["src-tauri/src/managed_agents/readiness.rs", 1765], + // 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 1765 -> 1599 to bank the headroom. + ["src-tauri/src/managed_agents/readiness.rs", 1599], // 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 @@ -208,7 +205,6 @@ const overrides = new Map([ // split with the rest of this file. ["src/shared/api/tauri.ts", 1340], // 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 @@ -223,8 +219,9 @@ const overrides = new Map([ // field. // bundle-acps: NodeRuntimeCheck + NodeRuntimeRequirement types for the // bundled-bridge Node.js doctor check (+23 lines on rebase union with - // main's Git Bash / signout-wipe / team-instructions type growth). - ["src/shared/api/types.ts", 1070], + // main's Git Bash / signout-wipe / team-instructions type growth); + // "adapter_outdated" availability retired with the codex version gate (-1 line). + ["src/shared/api/types.ts", 1069], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog // shows required markers and credential amber rows (parity with @@ -258,14 +255,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, @@ -286,11 +275,12 @@ const overrides = new Map([ // +13: fetch_login_shell_path_inner Windows guard (POSIX PATH → None). // resolve_git_bash made pub(crate) for Windows test access. // +1: login_shell_candidates doc comment expanded for resolve_bash_path. - // bundle-acps: bundled ACP bridge check at the top of the resolution sweep - // (+4 lines over the Windows baseline). 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", 1371], + // 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; ratcheting 1371 -> 1250 to bank the + // deletions (main's Windows Doctor install growth stays). + ["src-tauri/src/managed_agents/discovery.rs", 1250], // 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. @@ -302,7 +292,10 @@ const overrides = new Map([ // +32: deterministic .cmd resolver + no-registry + install_shell_from tests. // team-instructions-first-class: record_with test fixture gained the new // ManagedAgentRecord.team_id field (+1 line) alongside persona_team_dir. - ["src-tauri/src/managed_agents/discovery/tests.rs", 1271], + // bundle-acps: version-gate retirement deletes the probe/availability test + // sections; ratcheting 1271 -> 1067 to bank the deletions (main's Windows + // Doctor test growth keeps this above the 1000 default). + ["src-tauri/src/managed_agents/discovery/tests.rs", 1067], // 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 052bc44ff..c6c31c09c 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> { @@ -1096,114 +1096,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, @@ -1224,21 +1116,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`. @@ -1265,7 +1145,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 608b511fa..7ce9fa593 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; @@ -607,208 +605,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 5886a4399..000000000 --- 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 6a5af8925..05f001e24 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. @@ -1132,153 +1113,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: &[], - cli_install_commands_windows: &[], - 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 9cb235476..36131fde6 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -534,8 +534,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 28b51b1ef..25cfe9af1 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -562,11 +562,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 07cc6ae2f..72a96ca99 100644 --- a/desktop/src/features/agents/ui/personaDialogPickers.tsx +++ b/desktop/src/features/agents/ui/personaDialogPickers.tsx @@ -383,13 +383,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}`; } @@ -405,8 +403,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 bd59f0124..355953808 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 community 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 5350a52a2..2c70a453c 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 community 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 997b70933..a959c558a 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -557,7 +557,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 f616d7829..8e1603e23 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 7893236c2..901bb2273 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 4be3e6398..f21e5a329 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 751284a02d15269e23b259d0b4b28acdd6295160 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 13 Jul 2026 13:59:12 +1000 Subject: [PATCH 05/20] 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 59428ac19..906f5613d 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -449,7 +449,10 @@ const overrides = new Map([ // +8: install_shell_from pure seam extracted for deterministic testing. // bundle-acps: node runtime check wiring for the bundled-bridge Doctor // requirement (+9 lines on rebase union with the Windows Doctor install fix). - ["src-tauri/src/commands/agent_discovery.rs", 1532], + // 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", 1576], // 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 c6c31c09c..32710dad9 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/20] 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 906f5613d..3f55b7465 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -203,7 +203,9 @@ const overrides = new Map([ // checkAcpNodeRuntime wrapper for the bundled-bridge Node.js doctor check // (+35 lines on rebase union with main's Doctor/team growth). Queued to // split with the rest of this file. - ["src/shared/api/tauri.ts", 1340], + // bundled-adapter-doctor-copy: adapter_bundled field on + // RawAcpRuntimeCatalogEntry + mapper passthrough (+3 lines). + ["src/shared/api/tauri.ts", 1343], // 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. @@ -221,7 +223,9 @@ const overrides = new Map([ // bundled-bridge Node.js doctor check (+23 lines on rebase union with // main's Git Bash / signout-wipe / team-instructions type growth); // "adapter_outdated" availability retired with the codex version gate (-1 line). - ["src/shared/api/types.ts", 1069], + // bundled-adapter-doctor-copy: adapterBundled field on + // AcpRuntimeCatalogEntry (+2 lines). + ["src/shared/api/types.ts", 1071], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog // shows required markers and credential amber rows (parity with @@ -280,7 +284,9 @@ const overrides = new Map([ // codex_adapter_availability/is_outdated, AdapterOutdated arm) made // obsolete by pinned bundling; ratcheting 1371 -> 1250 to bank the // deletions (main's Windows Doctor install growth stays). - ["src-tauri/src/managed_agents/discovery.rs", 1250], + // 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", 1254], // 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 67546ce834a7270b5367339518fe01f21e717700 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 13 Jul 2026 17:15:22 +1000 Subject: [PATCH 07/20] 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 b9ebf7ff2..4b6873a1f 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 a959c558a..bbb6666bc 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -575,6 +575,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 80043393f..5bcb89390 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 70ee9a03446a9642670bf81896f774ad9754e685 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 13 Jul 2026 17:23:18 +1000 Subject: [PATCH 08/20] 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 3f55b7465..f23c14ed1 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -286,7 +286,10 @@ const overrides = new Map([ // deletions (main's Windows Doctor install 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", 1254], + // 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", 1259], // 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 5ced5ac3d..667b8cdf6 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. @@ -166,8 +169,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 5140bb2cd..6ce9a2361 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 7ce9fa593..a5aa2e606 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -364,10 +364,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")), @@ -446,9 +446,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), @@ -509,7 +509,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 f6c23394eea21fced261917516f9e6856b98d9d1 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 11:47:41 +1000 Subject: [PATCH 09/20] 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 be4941e31..653e8056e 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 a4e592ed3aaf316a3c424dd0b513e15b2b010a0b Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 12:38:57 +1000 Subject: [PATCH 10/20] 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 | 15 ++- desktop/scripts/prepare-acp-tools-resource.sh | 52 +++++++- .../src-tauri/src/managed_agents/acp_tools.rs | 124 +++++++++++++++++- .../src-tauri/src/managed_agents/discovery.rs | 66 ++++++---- .../src/managed_agents/discovery/tests.rs | 59 ++++----- .../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, 332 insertions(+), 227 deletions(-) diff --git a/.gitignore b/.gitignore index bbf535883..f23cb358b 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 16b89fce7..45f943509 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 f23c14ed1..babbaffe2 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -225,7 +225,9 @@ const overrides = new Map([ // "adapter_outdated" availability retired with the codex version gate (-1 line). // bundled-adapter-doctor-copy: adapterBundled field on // AcpRuntimeCatalogEntry (+2 lines). - ["src/shared/api/types.ts", 1071], + // bundled-cli-probes: "cli_missing" availability retired; +4 doc lines on + // AcpAvailabilityStatus explaining why the state no longer exists. + ["src/shared/api/types.ts", 1075], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog // shows required markers and credential amber rows (parity with @@ -289,7 +291,10 @@ 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", 1259], + // 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", 1267], // 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. @@ -304,7 +309,11 @@ const overrides = new Map([ // bundle-acps: version-gate retirement deletes the probe/availability test // sections; ratcheting 1271 -> 1067 to bank the deletions (main's Windows // Doctor test growth keeps this above the 1000 default). - ["src-tauri/src/managed_agents/discovery/tests.rs", 1067], + // bundled-cli-probes: CliMissing test reworked to assert Available when the + // adapter resolves without an underlying CLI (+2 comment lines); main's + // Windows install-command tests inverted to assert the bundled runtimes + // expose no install commands (-13 lines). + ["src-tauri/src/managed_agents/discovery/tests.rs", 1056], // 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 df7f84047..4e9026564 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 472e650fa..a328ecb3c 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 667b8cdf6..dca22ed63 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], @@ -176,13 +179,13 @@ 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"], - cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""], + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], 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, @@ -196,7 +199,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 { @@ -207,13 +210,13 @@ 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"], - cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://chatgpt.com/codex/install.ps1 | iex\""], + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], 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, @@ -227,7 +230,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"]), }, @@ -958,6 +961,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 @@ -1075,25 +1088,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 { @@ -1151,7 +1160,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() { @@ -1207,8 +1215,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 a5aa2e606..f32be9a99 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")); } @@ -868,64 +870,51 @@ fn test_command_basenames_dotted_name_no_extra_candidates() { // ── Phase B: cli_install_commands_for_os ──────────────────────────────────── -/// Claude and Codex have non-empty default cli_install_commands (install.sh). +/// Claude and Codex vendor their CLIs inside the bundled ACP packages — +/// the curl-pipe install commands are retired with the cli_missing gate. #[test] -fn test_claude_and_codex_have_cli_install_commands() { +fn test_claude_and_codex_have_no_cli_install_commands() { let claude = super::known_acp_runtime_exact("claude").unwrap(); let codex = super::known_acp_runtime_exact("codex").unwrap(); assert!( - !claude.cli_install_commands.is_empty(), - "claude must have cli install commands" + claude.cli_install_commands.is_empty(), + "claude CLI ships inside the bundled adapter — must not have cli install commands" ); assert!( - !codex.cli_install_commands.is_empty(), - "codex must have cli install commands" + codex.cli_install_commands.is_empty(), + "codex CLI ships inside the bundled adapter — must not have cli install commands" ); } -/// cli_install_commands_for_os returns a non-empty slice for claude and codex. +/// cli_install_commands_for_os is empty for claude and codex on every platform. #[test] -fn test_cli_install_commands_for_os_non_empty_for_claude_codex() { +fn test_cli_install_commands_for_os_empty_for_claude_codex() { let claude = super::known_acp_runtime_exact("claude").unwrap(); let codex = super::known_acp_runtime_exact("codex").unwrap(); assert!( - !claude.cli_install_commands_for_os().is_empty(), - "claude must have install commands on every platform" + claude.cli_install_commands_for_os().is_empty(), + "claude must not have install commands on any platform" ); assert!( - !codex.cli_install_commands_for_os().is_empty(), - "codex must have install commands on every platform" + codex.cli_install_commands_for_os().is_empty(), + "codex must not have install commands on any platform" ); } -/// On Windows, Claude and Codex select the PowerShell install commands. +/// On Windows, the bundled runtimes still expose no install commands, and +/// goose keeps its platform-neutral commands. #[cfg(windows)] #[test] -fn test_cli_install_commands_for_os_selects_powershell_on_windows() { +fn test_cli_install_commands_for_os_on_windows() { let claude = super::known_acp_runtime_exact("claude").unwrap(); let codex = super::known_acp_runtime_exact("codex").unwrap(); - - // Windows must select the PowerShell commands, not the curl|bash ones. - let claude_cmds = claude.cli_install_commands_for_os(); - let codex_cmds = codex.cli_install_commands_for_os(); - - assert_ne!( - claude_cmds, claude.cli_install_commands, - "Windows must NOT use the default curl|bash commands for claude" - ); - assert_ne!( - codex_cmds, codex.cli_install_commands, - "Windows must NOT use the default curl|bash commands for codex" - ); - - // Verify they are the PowerShell installers. assert!( - claude_cmds.iter().any(|c| c.contains("powershell")), - "claude Windows install must use powershell; got: {claude_cmds:?}" + claude.cli_install_commands_for_os().is_empty(), + "claude ships bundled — no Windows install commands" ); assert!( - codex_cmds.iter().any(|c| c.contains("powershell")), - "codex Windows install must use powershell; got: {codex_cmds:?}" + codex.cli_install_commands_for_os().is_empty(), + "codex ships bundled — no Windows install commands" ); // Goose and buzz-agent must NOT use Windows-specific commands. diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 05f001e24..d8695624d 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(), @@ -1041,10 +1046,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 @@ -1052,19 +1058,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 68f9603ac..29a24f0ca 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -529,12 +529,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 25cfe9af1..b8666728c 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -562,9 +562,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 5cc49a989..de81a5726 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 72a96ca99..c2c9c9e49 100644 --- a/desktop/src/features/agents/ui/personaDialogPickers.tsx +++ b/desktop/src/features/agents/ui/personaDialogPickers.tsx @@ -383,11 +383,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}`; } @@ -397,12 +395,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 355953808..ede6aa250 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 cf21b6f6a..2fea5986c 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 653e8056e..0d705d568 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 bbb6666bc..c8b65ad8b 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -554,10 +554,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 8e1603e23..c38e95aac 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 901bb2273..5ceb8314d 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 f21e5a329..b7ebd6ff3 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 9bd273217..e0ab3ffe4 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 5bcb89390..20e56977d 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 b3aed32a8882c3b8acc680f0ea50ccdd1c649fd9 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 14:03:34 +1000 Subject: [PATCH 11/20] 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 29a24f0ca..45333a2a8 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -571,8 +571,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 0d705d568..2649b4520 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 20e56977d..272b844ad 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 f60138463f02c66a403f93dafe58d64676723241 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 14:52:17 +1000 Subject: [PATCH 12/20] 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 | 26 +- .../src-tauri/src/commands/agent_discovery.rs | 484 ++---------------- .../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, 79 insertions(+), 679 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index babbaffe2..c8d219a82 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -121,7 +121,11 @@ 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 to bank the deletions (main's + // team-instructions spawn-hash growth stays). + ["src-tauri/src/managed_agents/runtime.rs", 2087], // 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], @@ -205,7 +209,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", 1343], + // acp-dead-machinery retirement: node_required wire field deleted; + // ratcheted 1343 -> 1341. + ["src/shared/api/tauri.ts", 1341], // 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. @@ -227,7 +233,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", 1075], + // acp-dead-machinery retirement: nodeRequired field deleted; + // ratcheted 1075 -> 1073. + ["src/shared/api/types.ts", 1073], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog // shows required markers and credential amber rows (parity with @@ -294,7 +302,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", 1267], + // acp-dead-machinery retirement: adapter-availability cache + + // availability_drift, runtime_needs_npm/is_npm_global_install, and the + // node_required computation deleted; ratcheted 1267 -> 1166. + ["src-tauri/src/managed_agents/discovery.rs", 1166], // 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. @@ -470,7 +481,12 @@ 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", 1576], + // acp-dead-machinery retirement: npm EACCES preflight (resolve_npm_prefix, + // npm_preflight_check, npm_eacces_hint) + its Phase-2 branches, test + // groups, and the availability_drift tests deleted; ratcheted + // 1576 -> 1184 to bank the deletions (main's Windows install-shell + // machinery and tests stay). + ["src-tauri/src/commands/agent_discovery.rs", 1184], // 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 32710dad9..814e08ae7 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 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 = match install_shell_command("npm prefix -g") { - Ok(cmd) => cmd, - Err(_) => return NpmPrefix::Unavailable, - }; - 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, @@ -1123,124 +883,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, - } } /// Return all candidate basenames for `command` on the current platform. @@ -943,24 +876,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 @@ -1136,13 +1051,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) @@ -1172,14 +1080,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 { @@ -1196,7 +1096,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 1ef3eae01..473861ba6 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 a61987392..bc014889b 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1321,31 +1321,20 @@ 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 teams_for_hash = crate::managed_agents::load_teams(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, &teams_for_hash, &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, @@ -1891,20 +1880,6 @@ pub fn spawn_agent_child( &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 @@ -1915,7 +1890,6 @@ pub fn spawn_agent_child( log_path, spawn_config_hash, spawned_setup_mode, - spawned_adapter_availability, &record.name, )); #[cfg(not(windows))] @@ -1924,7 +1898,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 45333a2a8..e6c42823f 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -435,12 +435,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` @@ -581,9 +575,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 66b11b6be..33eb195dd 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 2649b4520..7e78409b7 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 8d962a4062f645de9e9d7f32b051a18e00fe5900 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 15:27:56 +1000 Subject: [PATCH 13/20] 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 bffcf0c32..dcadaf9b7 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 ff2d3c2241bb7bca69518334c20d587fcebbcfc3 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 15:44:51 +1000 Subject: [PATCH 14/20] 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 ddee056ca..da1b91c15 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 fbb9f1b94eefdf1b7f1a3a25c54019bd9f8deb67 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 15:50:38 +1000 Subject: [PATCH 15/20] 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 4e9026564..4bc67126f 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 b991b35f33f15a6b5c1d5bb2ce25d0f34047ddfa Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 15:54:40 +1000 Subject: [PATCH 16/20] 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 d2361a3b6..6ef4947bf 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 dcadaf9b7..f80091c21 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 056788026df8b8a02491310b493cb837181e3add Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 16:18:26 +1000 Subject: [PATCH 17/20] 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 cbe9fb1ea..1ccb9b77a 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 64eae442e34ff924a0db2d2cc0eb9d9fae0f5064 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 14 Jul 2026 16:19:27 +1000 Subject: [PATCH 18/20] 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 d1906a7cf..de9ada44f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -859,6 +859,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 1ccb9b77a..909f7e91d 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 9876dfe33..72ab1becf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -762,6 +762,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 3499285f9..c470e2f12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,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 000000000..f6c00e08a --- /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 000000000..e77adf0cd --- /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 000000000..e8bd17f58 --- /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 6ef4947bf..d9430ca33 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 da1b91c15..8477acdc8 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 7d99839ee..8689d22a1 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 4bc67126f..0b8ea5e14 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 f80091c21..53403896b 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 From b2ce7a4a010ba78792f1ddfd0e3e2e515165653a Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 15 Jul 2026 12:21:31 +1000 Subject: [PATCH 19/20] feat(desktop): bundle only the ACP bridges, not the harness CLIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the bundled ACP tooling from full-CLI bundling to bridge-only bundling. The staging scripts now install the bridge JS trees with `npm --omit=optional`, which skips the SDK/codex platform packages that vendor the native claude/codex CLIs — the bundled resource drops from ~586MB to ~56MB of pure JS, and ad-hoc codesigning of vendored Mach-O binaries is no longer needed. The bridges instead run the user's own harness CLI: at spawn time the desktop resolves `claude`/`codex` from PATH and exports CLAUDE_CODE_EXECUTABLE / CODEX_PATH (neither bridge falls back to PATH itself), driven by a new `bridge_cli_env_var` catalog field. A value already present in the desktop's environment wins, and per-agent env overrides still apply afterwards. Because the app once again depends on a user-installed CLI, this reinstates the machinery that 55a80e5c retired: the CliMissing availability gate, PATH-based auth probes, curl CLI install commands, and the Doctor's CLI-missing copy and CLI-path row. The Doctor's "bundled" badge now reads an explicit `adapter_ships_with_app` catalog field, since inferring it from empty install-command lists breaks once claude/codex regain CLI install commands. The lock drops the native*/npmOs/npmCpu/npmLibc fields (trees are platform-independent, but stay per-target so pins can be bumped independently), install validation asserts no platform package slipped into the tree, and freshness stamps gain STAMP_INSTALL_MODE=bridge-only so stale full-CLI caches are reinstalled rather than reused. The harness-clis.json manifest and its resolution path are removed; the prepare script deletes the stale file from previously staged resource dirs. Verified: desktop cargo tests (1421), buzz-acp tests, clippy + fmt on both, tsc, biome, desktop unit tests (2791), doctor-states + doctor-cta-screenshots Playwright specs, file-size/px guards, and end-to-end staging on aarch64-apple-darwin (bridge wrappers report 0.58.1/1.1.2, no Mach-O in the tree, idempotent re-run). Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .gitignore | 1 - crates/buzz-acp/src/setup_mode.rs | 2 - desktop/acp-tools.lock.json | 104 ++---------- desktop/scripts/check-file-sizes.mjs | 44 ++--- desktop/scripts/ensure-acp-tools.sh | 129 ++++++--------- desktop/scripts/prepare-acp-tools-resource.sh | 69 ++------ desktop/scripts/update-acp-tools-lock.mjs | 150 ++---------------- .../src-tauri/src/commands/agent_config.rs | 2 + .../src-tauri/src/commands/agent_discovery.rs | 9 +- .../src-tauri/src/managed_agents/acp_tools.rs | 124 +-------------- .../config_bridge/reader_tests.rs | 4 + .../src-tauri/src/managed_agents/discovery.rs | 85 ++++++---- .../src/managed_agents/discovery/tests.rs | 85 +++++++--- .../src-tauri/src/managed_agents/readiness.rs | 40 +++-- .../src-tauri/src/managed_agents/runtime.rs | 25 +++ 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 | 56 ++++++- 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 | 14 +- 28 files changed, 416 insertions(+), 656 deletions(-) diff --git a/.gitignore b/.gitignore index f23cb358b..bbf535883 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,6 @@ 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 45f943509..f39ac3f00 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -67,8 +67,6 @@ pub(crate) enum AcpAvailabilityStatus { /// 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/acp-tools.lock.json b/desktop/acp-tools.lock.json index d9430ca33..3eae0934c 100644 --- a/desktop/acp-tools.lock.json +++ b/desktop/acp-tools.lock.json @@ -9,20 +9,12 @@ "integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==", "tarball": "https://registry.npmjs.org/@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://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://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.205.tgz", - "nativeExecutable": "claude" + "claudeCodeVersion": "2.1.205" }, { "id": "claude-acp", @@ -33,21 +25,12 @@ "integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==", "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", - "npmLibc": "glibc", "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-linux-arm64", - "nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-arm64", - "nativeVersion": "0.3.205", - "nativeIntegrity": "sha512-CXzySK3PV3EizCRPXnxPqeaAtgrBFDnMFOVpMe36oC3U16yDb1b1tAJGqZi/7uFrVvAiaXvnSFxhUWnDDSaO+A==", - "nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.205.tgz", - "nativeExecutable": "claude" + "claudeCodeVersion": "2.1.205" }, { "id": "claude-acp", @@ -58,20 +41,12 @@ "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-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://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://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.205.tgz", - "nativeExecutable": "claude" + "claudeCodeVersion": "2.1.205" }, { "id": "claude-acp", @@ -82,20 +57,12 @@ "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" + "claudeCodeVersion": "2.1.205" }, { "id": "claude-acp", @@ -106,21 +73,12 @@ "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-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://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://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.205.tgz", - "nativeExecutable": "claude" + "claudeCodeVersion": "2.1.205" }, { "id": "codex-acp", @@ -131,19 +89,11 @@ "integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==", "tarball": "https://registry.npmjs.org/@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://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://registry.npmjs.org/@openai/codex/-/codex-0.144.1-darwin-arm64.tgz", - "nativeExecutable": "vendor/aarch64-apple-darwin/bin/codex" + "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz" }, { "id": "codex-acp", @@ -154,20 +104,11 @@ "integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==", "tarball": "https://registry.npmjs.org/@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://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://registry.npmjs.org/@openai/codex/-/codex-0.144.1-linux-arm64.tgz", - "nativeExecutable": "vendor/aarch64-unknown-linux-musl/bin/codex" + "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz" }, { "id": "codex-acp", @@ -178,19 +119,11 @@ "integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==", "tarball": "https://registry.npmjs.org/@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://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://registry.npmjs.org/@openai/codex/-/codex-0.144.1-darwin-x64.tgz", - "nativeExecutable": "vendor/x86_64-apple-darwin/bin/codex" + "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz" }, { "id": "codex-acp", @@ -201,19 +134,11 @@ "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" + "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz" }, { "id": "codex-acp", @@ -224,20 +149,11 @@ "integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==", "tarball": "https://registry.npmjs.org/@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://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://registry.npmjs.org/@openai/codex/-/codex-0.144.1-linux-x64.tgz", - "nativeExecutable": "vendor/x86_64-unknown-linux-musl/bin/codex" + "dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz" } ] } diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index c8d219a82..f5782b0a2 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -125,7 +125,9 @@ const overrides = new Map([ // the availability_drift half of needs_restart deleted (the bundled bridge // can't drift out-of-band); ratcheted to bank the deletions (main's // team-instructions spawn-hash growth stays). - ["src-tauri/src/managed_agents/runtime.rs", 2087], + // bridge-only bundling: spawn-time CLAUDE_CODE_EXECUTABLE/CODEX_PATH export + // pointing the bundled bridge at the user's CLI (+25 lines incl. rationale). + ["src-tauri/src/managed_agents/runtime.rs", 2112], // 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], @@ -168,7 +170,9 @@ const overrides = new Map([ // 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 1765 -> 1599 to bank the headroom. - ["src-tauri/src/managed_agents/readiness.rs", 1599], + // bridge-only bundling: bridge_cli_env_var + adapter_ships_with_app fields + // in the make_cli_runtime stub (+2 lines). + ["src-tauri/src/managed_agents/readiness.rs", 1601], // 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 @@ -231,11 +235,11 @@ const overrides = new Map([ // "adapter_outdated" availability retired with the codex version gate (-1 line). // bundled-adapter-doctor-copy: adapterBundled field on // AcpRuntimeCatalogEntry (+2 lines). - // bundled-cli-probes: "cli_missing" availability retired; +4 doc lines on - // AcpAvailabilityStatus explaining why the state no longer exists. - // acp-dead-machinery retirement: nodeRequired field deleted; - // ratcheted 1075 -> 1073. - ["src/shared/api/types.ts", 1073], + // acp-dead-machinery retirement: nodeRequired field deleted. + // bridge-only bundling: the "cli_missing" availability literal returns — + // the bundled bridges run the user's claude/codex CLI again; ratcheted + // to bank the node_required-era headroom. + ["src/shared/api/types.ts", 1069], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog // shows required markers and credential amber rows (parity with @@ -299,13 +303,14 @@ 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. - // bundled-cli-probes: resolve_probe_binary (bundled-CLI-first probe - // resolution) + classify_runtime/underlying_cli doc rewrites for the - // CliMissing retirement (+8 lines). // acp-dead-machinery retirement: adapter-availability cache + // availability_drift, runtime_needs_npm/is_npm_global_install, and the // node_required computation deleted; ratcheted 1267 -> 1166. - ["src-tauri/src/managed_agents/discovery.rs", 1166], + // bridge-only bundling: CliMissing classification and user-CLI probe + // resolution return with the cli_missing gate; bridge_cli_env_var + + // adapter_ships_with_app catalog fields (docs + 4 initializers) and the + // restored curl/PowerShell CLI install commands (+15 lines). + ["src-tauri/src/managed_agents/discovery.rs", 1181], // 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. @@ -320,11 +325,10 @@ const overrides = new Map([ // bundle-acps: version-gate retirement deletes the probe/availability test // sections; ratcheting 1271 -> 1067 to bank the deletions (main's Windows // Doctor test growth keeps this above the 1000 default). - // bundled-cli-probes: CliMissing test reworked to assert Available when the - // adapter resolves without an underlying CLI (+2 comment lines); main's - // Windows install-command tests inverted to assert the bundled runtimes - // expose no install commands (-13 lines). - ["src-tauri/src/managed_agents/discovery/tests.rs", 1056], + // bridge-only bundling: classifies_cli_missing returns with the CliMissing + // gate + bridge_cli_env_vars catalog mapping test (+24 lines), and main's + // Windows install-command tests return with the restored CLI installers. + ["src-tauri/src/managed_agents/discovery/tests.rs", 1093], // 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 @@ -460,7 +464,9 @@ const overrides = new Map([ // is_safe_to_reveal allowlist + baked_env_thinking_effort_is_unmasked test. // +1: doctor-install-reliability: login_hint: None added to goose_runtime test stub. // +1: doctor-install-reliability review fixes: auth_probe_args: None added to stub. - ["src-tauri/src/commands/agent_config.rs", 1021], + // +2: bridge-only bundling: bridge_cli_env_var + adapter_ships_with_app + // added to the runtime test stub (union with main's Windows stub field). + ["src-tauri/src/commands/agent_config.rs", 1022], // codex-install-auto-restart review-fixes: should_restart_after_install // takes pid_alive:bool (pure predicate, no OS-dependent call); 3 racy // cache tests replaced with 6 pure availability_drift predicate tests; @@ -486,7 +492,9 @@ const overrides = new Map([ // groups, and the availability_drift tests deleted; ratcheted // 1576 -> 1184 to bank the deletions (main's Windows install-shell // machinery and tests stay). - ["src-tauri/src/commands/agent_discovery.rs", 1184], + // bridge-only bundling: runtime_adapter_is_bundled reads the explicit + // adapter_ships_with_app catalog field (+2 doc lines). + ["src-tauri/src/commands/agent_discovery.rs", 1187], // 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/scripts/ensure-acp-tools.sh b/desktop/scripts/ensure-acp-tools.sh index 8477acdc8..59b8be896 100755 --- a/desktop/scripts/ensure-acp-tools.sh +++ b/desktop/scripts/ensure-acp-tools.sh @@ -16,10 +16,13 @@ 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. 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). +versions and integrity hashes. Installs are bridge-only: --omit=optional +skips the SDK/codex platform packages that vendor native CLIs, so the trees +stay pure JS and the desktop app points each bridge at the user's own +claude/codex CLI at spawn time. 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) @@ -111,18 +114,10 @@ for (const entry of entries) { "version", "integrity", "tarball", - "npmOs", - "npmCpu", "dependencyPackage", "dependencyVersion", "dependencyIntegrity", "dependencyTarball", - "nativePackage", - "nativePackageName", - "nativeVersion", - "nativeIntegrity", - "nativeTarball", - "nativeExecutable", ]) { requireString(entry, field); } @@ -161,14 +156,9 @@ validate_npm_install() { 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' + local claude_code_version="$8" + + node - "$install_dir" "$package" "$version" "$integrity" "$dependency_package" "$dependency_version" "$dependency_integrity" "$claude_code_version" <<'NODE' const fs = require("node:fs"); const path = require("node:path"); @@ -180,11 +170,6 @@ const [ dependencyPackageName, expectedDependencyVersion, expectedDependencyIntegrity, - nativePackageName, - expectedNativePackageName, - expectedNativeVersion, - expectedNativeIntegrity, - nativeExecutable, expectedClaudeCodeVersion, ] = process.argv.slice(2); @@ -242,23 +227,25 @@ assertEqual( `${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`, +// Bridge-only invariant: --omit=optional must have skipped the platform +// packages that vendor a native CLI. They install as scope siblings of the +// dependency package (`-`), so any such +// directory means a native CLI slipped into the bundle. +const dependencySegments = dependencyPackageName.split("/"); +const dependencyBasename = dependencySegments.at(-1); +const scopeDir = path.join( + installDir, + "node_modules", + ...dependencySegments.slice(0, -1), ); +const vendoredPlatformPackages = fs + .readdirSync(scopeDir) + .filter((name) => name.startsWith(`${dependencyBasename}-`)); +if (vendoredPlatformPackages.length > 0) { + throw new Error( + `bridge-only install unexpectedly vendored native CLI package(s): ${vendoredPlatformPackages.join(", ")}`, + ); +} NODE } @@ -272,31 +259,21 @@ for (const entry of entries) { 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 +' "$lock_entries" | while IFS=$'\x1f' read -r id binary package version integrity tarball node_engine dependency_package dependency_version dependency_integrity dependency_tarball 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/$(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 @@ -306,10 +283,13 @@ for (const entry of entries) { # 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 + if [[ -x "$staged_bin" && -f "$stamp" && -f "$entrypoint" ]]; 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 + # STAMP_INSTALL_MODE distinguishes bridge-only trees from caches staged by + # the retired full-CLI bundling (whose stamps lack the marker): those trees + # still vendor the native CLIs and must be reinstalled, not reused. + if [[ "${STAMP_INSTALL_MODE:-}" == "bridge-only" && "${STAMP_PACKAGE:-}" == "$package" && "${STAMP_VERSION:-}" == "$version" && "${STAMP_INTEGRITY:-}" == "$integrity" && "${STAMP_DEPENDENCY_PACKAGE:-}" == "$dependency_package" && "${STAMP_DEPENDENCY_VERSION:-}" == "$dependency_version" && "${STAMP_DEPENDENCY_INTEGRITY:-}" == "$dependency_integrity" ]]; 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). @@ -323,50 +303,37 @@ for (const entry of entries) { 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" + # --omit=optional is what makes the install bridge-only: the native + # claude/codex CLIs ship as optional platform packages of the pinned + # dependency, and skipping them keeps the tree pure JS. + npm install \ + --prefix "$install_dir" \ + --omit=dev \ + --omit=optional \ + --ignore-scripts \ + --no-audit \ + --no-fund \ + "$package@$version" >&2 + + validate_npm_install "$install_dir" "$package" "$version" "$integrity" "$dependency_package" "$dependency_version" "$dependency_integrity" "$claude_code_version" 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_INSTALL_MODE=bridge-only\n' 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 diff --git a/desktop/scripts/prepare-acp-tools-resource.sh b/desktop/scripts/prepare-acp-tools-resource.sh index 0b8ea5e14..2e731c545 100755 --- a/desktop/scripts/prepare-acp-tools-resource.sh +++ b/desktop/scripts/prepare-acp-tools-resource.sh @@ -67,15 +67,11 @@ 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=() +# Stale artifact of the retired full-CLI bundling: the harness CLI manifest +# pointed auth probes at CLIs vendored inside the bundles. Bridge-only +# bundles carry no CLIs, so remove any leftover manifest from an earlier +# stage — it is unread, but it would otherwise ride into the app bundle. +rm -f "$resource_root/harness-clis.json" # 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 @@ -93,7 +89,7 @@ codesign_if_darwin() { fi } -while IFS=$'\t' read -r id binary package version node_engine native_package native_executable; do +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" @@ -118,29 +114,10 @@ while IFS=$'\t' read -r id binary package version node_engine native_package nat 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 - # 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" - # 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 - # unsigned nested Mach-Os are killed by Gatekeeper. Darwin only, so Linux - # staging skips the file(1) scan. + # Ad-hoc sign every Mach-O in the staged package. Bridge-only trees are + # pure JS, so this scan normally finds nothing — kept because unsigned + # nested Mach-Os are killed by Gatekeeper, and a future dependency could + # reintroduce one. 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 @@ -157,15 +134,7 @@ 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", - entry.nativePackage ?? "", - entry.nativeExecutable ?? "", - ].join("\t")); + console.log([entry.id, entry.binary, entry.package, entry.version, entry.nodeEngine ?? ">=22"].join("\t")); } NODE ) @@ -186,20 +155,4 @@ 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/scripts/update-acp-tools-lock.mjs b/desktop/scripts/update-acp-tools-lock.mjs index 53403896b..5f6a5ee99 100755 --- a/desktop/scripts/update-acp-tools-lock.mjs +++ b/desktop/scripts/update-acp-tools-lock.mjs @@ -21,13 +21,18 @@ const SUPPORTED_TARGETS = [ // Zed package. const CODEX_ACP_PACKAGE = "@agentclientprotocol/codex-acp"; +// Bridge-only bundling: only the bridge JS trees are pinned and installed +// (`npm install --omit=optional` skips the SDK/codex platform packages that +// vendor the native claude/codex CLIs). The desktop app points each bridge at +// the user's own CLI via CLAUDE_CODE_EXECUTABLE / CODEX_PATH at spawn time. +// The trees are therefore platform-independent, but the lock stays per-target +// so a single target's pins can still be bumped or dropped independently. 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, }, { @@ -35,75 +40,9 @@ const TOOL_SPECS = [ 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", - }, - }, - "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 // via the ambient npm registry and validates the registry-agnostic sha512 // integrity. Normalize them to the public registry so regenerating against an @@ -118,9 +57,11 @@ 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. +writes acp-tools.lock.json. Only the bridge JS packages are pinned — the +SDK/codex platform packages that vendor native CLIs are optional dependencies +the install omits; the app runs the user's own claude/codex CLI. Fails loudly +when a package or its pinned dependency 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 @@ -203,8 +144,8 @@ function requireString(value, label) { } // 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). +// `/-/.tgz` path rather than deriving the basename from +// name@version — registries own that naming, not us. function normalizeTarballUrl(tarball, packageName, label) { const separator = "/-/"; const separatorIndex = tarball.lastIndexOf(separator); @@ -257,27 +198,7 @@ function pickLatestMatch(metadata, label) { ); } -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", @@ -308,9 +229,6 @@ async function lockToolForTarget(tool, target) { integrity: packageInfo.integrity, tarball: packageInfo.tarball, target, - npmOs: npmTarget.npmOs, - npmCpu: npmTarget.npmCpu, - ...(npmTarget.npmLibc ? { npmLibc: npmTarget.npmLibc } : {}), nodeEngine: packageMetadata.engines?.node ?? ">=22", }; @@ -323,7 +241,6 @@ async function lockToolForTarget(tool, target) { "name", "version", "dist", - "optionalDependencies", "claudeCodeVersion", ]), `${tool.dependencyPackage}@${dependencyRange}`, @@ -344,46 +261,7 @@ async function lockToolForTarget(tool, target) { 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, - }; + return entry; } // Entries preserved from the existing lock when --target selects a subset. diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index f6141a53e..6ad2025dc 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -581,9 +581,11 @@ mod tests { mcp_command: None, mcp_hooks: false, underlying_cli: None, + bridge_cli_env_var: None, cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], + adapter_ships_with_app: false, install_instructions_url: "", cli_install_hint: "", adapter_install_hint: "", diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 814e08ae7..13958423c 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -89,11 +89,13 @@ 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 +/// says so (`adapter_ships_with_app`). Inferring this from empty install +/// commands broke once the bridge-only bundles brought back CLI install +/// commands for claude/codex — their bridges are still bundled. 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() + runtime.adapter_ships_with_app } #[tauri::command] @@ -1166,7 +1168,8 @@ mod tests { .unwrap_or_else(|| panic!("{id} must be in the catalog")); assert!( runtime_adapter_is_bundled(runtime), - "{id} carries no install commands — its adapter ships with Buzz" + "{id}'s adapter ships with Buzz (bundled bridge or sidecar), \ + even though its user CLI may have install commands" ); } } diff --git a/desktop/src-tauri/src/managed_agents/acp_tools.rs b/desktop/src-tauri/src/managed_agents/acp_tools.rs index a328ecb3c..472e650fa 100644 --- a/desktop/src-tauri/src/managed_agents/acp_tools.rs +++ b/desktop/src-tauri/src/managed_agents/acp_tools.rs @@ -26,11 +26,6 @@ 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(); @@ -84,52 +79,6 @@ 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; @@ -152,10 +101,7 @@ fn bundled_acp_tools_dir_from_parts( #[cfg(test)] mod tests { - use super::{ - bundled_acp_tools_dir_from_parts, bundled_harness_cli_in_root, command_in_dir, - path_is_in_dir, - }; + use super::{bundled_acp_tools_dir_from_parts, command_in_dir, path_is_in_dir}; use std::ffi::OsStr; use std::path::Path; @@ -255,74 +201,6 @@ 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/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index f5fc7c389..57d5620f1 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -37,9 +37,11 @@ fn test_runtime() -> &'static KnownAcpRuntime { mcp_command: None, mcp_hooks: false, underlying_cli: None, + bridge_cli_env_var: None, cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], + adapter_ships_with_app: false, install_instructions_url: "", cli_install_hint: "", adapter_install_hint: "", @@ -612,9 +614,11 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { mcp_command: None, mcp_hooks: false, underlying_cli: None, + bridge_cli_env_var: None, cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], + adapter_ships_with_app: false, install_instructions_url: "", cli_install_hint: "", adapter_install_hint: "", diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index b8057fee0..ca605f411 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -21,11 +21,16 @@ 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 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. + /// CLI binary that indicates partial install (e.g. `"claude"` when `claude-agent-acp` is missing). pub underlying_cli: Option<&'static str>, + /// Env var through which the bundled ACP bridge finds the user's + /// `underlying_cli` binary. The bridge-only bundles vendor no CLI: the + /// claude bridge has no PATH fallback (`CLAUDE_CODE_EXECUTABLE` or the + /// omitted SDK native package), and the codex bridge's `CODEX_PATH` + /// fallback hard-requires the omitted platform package — so spawn exports + /// the resolved user CLI here. `None` for runtimes whose adapter needs no + /// separate CLI handle. + pub bridge_cli_env_var: Option<&'static str>, /// Shell commands to install the runtime CLI itself (run sequentially). pub cli_install_commands: &'static [&'static str], /// Windows-specific CLI install commands (e.g. PowerShell installers). @@ -34,6 +39,13 @@ pub(crate) struct KnownAcpRuntime { pub cli_install_commands_windows: &'static [&'static str], /// Shell commands to install the ACP adapter (run sequentially, after CLI). pub adapter_install_commands: &'static [&'static str], + /// Whether the ACP adapter binary ships inside the Buzz desktop app (the + /// bundled bridges and the buzz-agent sidecar) rather than being + /// user-installed. Drives the post-install verification hint: a bundled + /// adapter that fails to resolve means a broken Buzz installation + /// ("Reinstall Buzz"), not a failed install step. False for goose, whose + /// adapter is the user-installed goose CLI itself. + pub adapter_ships_with_app: bool, /// Link to docs/repo for manual instructions. pub install_instructions_url: &'static str, /// Human-readable hint about installing the CLI binary. @@ -147,9 +159,11 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ mcp_command: None, mcp_hooks: false, underlying_cli: Some("goose"), + bridge_cli_env_var: None, cli_install_commands: &["curl -fsSL https://github.com/block-open-source/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], cli_install_commands_windows: &[], // goose install script is already Windows-aware adapter_install_commands: &[], + adapter_ships_with_app: false, install_instructions_url: "https://block.github.io/goose/", cli_install_hint: "Install Goose via the official install script.", adapter_install_hint: "", @@ -179,12 +193,14 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ avatar_url: CLAUDE_CODE_AVATAR_URL, mcp_command: None, mcp_hooks: false, - underlying_cli: None, - cli_install_commands: &[], - cli_install_commands_windows: &[], + underlying_cli: Some("claude"), + bridge_cli_env_var: Some("CLAUDE_CODE_EXECUTABLE"), + cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], + cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""], adapter_install_commands: &[], + adapter_ships_with_app: true, install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", - cli_install_hint: "", + 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. Reinstall Buzz to restore it.", skill_dir: Some(".claude/skills"), supports_acp_model_switching: false, @@ -199,7 +215,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 (install it first if needed)."), + login_hint: Some("Run the Claude CLI to complete authentication."), auth_probe_args: Some(&["claude", "auth", "status"]), }, KnownAcpRuntime { @@ -210,12 +226,14 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ avatar_url: CODEX_AVATAR_URL, mcp_command: Some("buzz-dev-mcp"), mcp_hooks: false, - underlying_cli: None, - cli_install_commands: &[], - cli_install_commands_windows: &[], + underlying_cli: Some("codex"), + bridge_cli_env_var: Some("CODEX_PATH"), + cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], + cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://chatgpt.com/codex/install.ps1 | iex\""], adapter_install_commands: &[], + adapter_ships_with_app: true, install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", - cli_install_hint: "", + 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. Reinstall Buzz to restore it.", skill_dir: Some(".codex/skills"), supports_acp_model_switching: false, @@ -230,7 +248,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 (install the Codex CLI first if needed)."), + login_hint: Some("Run `codex login` to authenticate."), // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, @@ -243,9 +261,11 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ mcp_command: Some("buzz-dev-mcp"), mcp_hooks: true, underlying_cli: None, + bridge_cli_env_var: None, cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], + adapter_ships_with_app: true, install_instructions_url: "https://github.com/block/buzz", cli_install_hint: "Ships with the Buzz desktop app.", adapter_install_hint: "", @@ -876,16 +896,6 @@ pub(crate) fn find_command(command: &str) -> Option { resolve_command(command) } -/// 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 @@ -1003,21 +1013,25 @@ 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 { - ( - AcpAvailabilityStatus::Available, - Some(cmd.to_string()), - Some(path.display().to_string()), - ) + 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()), + ) + } } else if underlying_cli.is_some() && underlying_cli_found { (AcpAvailabilityStatus::AdapterMissing, None, None) } else { @@ -1068,6 +1082,7 @@ 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() { @@ -1114,8 +1129,8 @@ pub fn discover_acp_runtimes() -> Vec { return None; } let probe_args = partial.runtime.auth_probe_args?; - // Probe the bundled CLI when the app ships one, else the user's. - let binary_path = resolve_probe_binary(probe_args[0])?; + // Need the resolved binary path for the CLI (e.g. the actual `claude` binary). + let binary_path = resolve_command(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 f32be9a99..2ac245449 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -176,19 +176,42 @@ fn classifies_not_installed_when_no_underlying_cli() { } #[test] -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. +fn classifies_cli_missing_when_adapter_found_but_cli_absent() { let (status, cmd, path) = classify_runtime( Some(("codex-acp", PathBuf::from("/opt/homebrew/bin/codex-acp"))), Some("codex"), false, ); - assert_eq!(status, AcpAvailabilityStatus::Available); + assert_eq!(status, AcpAvailabilityStatus::CliMissing); assert_eq!(cmd.as_deref(), Some("codex-acp")); assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp")); } +#[test] +fn bridge_cli_env_vars_map_bundled_bridges_to_the_user_cli() { + // The bridge-only bundles vendor no CLI, so spawn must export exactly + // these env vars for the bridges to find the user's install. Pinned here + // because a drifted mapping fails silently — sessions die at bridge + // startup, not at build time. + let expectations = [ + ("claude", Some("CLAUDE_CODE_EXECUTABLE")), + ("codex", Some("CODEX_PATH")), + ("goose", None), + ("buzz-agent", None), + ]; + for (id, expected) in expectations { + let runtime = crate::managed_agents::known_acp_runtime_exact(id) + .unwrap_or_else(|| panic!("{id} must be in the catalog")); + assert_eq!(runtime.bridge_cli_env_var, expected, "runtime {id}"); + if runtime.bridge_cli_env_var.is_some() { + assert!( + runtime.underlying_cli.is_some(), + "{id}: a bridge CLI env var is meaningless without an underlying CLI to resolve" + ); + } + } +} + fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agents::AgentDefinition { crate::managed_agents::AgentDefinition { id: id.to_string(), @@ -870,51 +893,65 @@ fn test_command_basenames_dotted_name_no_extra_candidates() { // ── Phase B: cli_install_commands_for_os ──────────────────────────────────── -/// Claude and Codex vendor their CLIs inside the bundled ACP packages — -/// the curl-pipe install commands are retired with the cli_missing gate. +/// Claude and Codex have non-empty default cli_install_commands (install.sh) — +/// bridge-only bundling means the user's own CLI must be installable again. #[test] -fn test_claude_and_codex_have_no_cli_install_commands() { +fn test_claude_and_codex_have_cli_install_commands() { let claude = super::known_acp_runtime_exact("claude").unwrap(); let codex = super::known_acp_runtime_exact("codex").unwrap(); assert!( - claude.cli_install_commands.is_empty(), - "claude CLI ships inside the bundled adapter — must not have cli install commands" + !claude.cli_install_commands.is_empty(), + "claude must have cli install commands" ); assert!( - codex.cli_install_commands.is_empty(), - "codex CLI ships inside the bundled adapter — must not have cli install commands" + !codex.cli_install_commands.is_empty(), + "codex must have cli install commands" ); } -/// cli_install_commands_for_os is empty for claude and codex on every platform. +/// cli_install_commands_for_os returns a non-empty slice for claude and codex. #[test] -fn test_cli_install_commands_for_os_empty_for_claude_codex() { +fn test_cli_install_commands_for_os_non_empty_for_claude_codex() { let claude = super::known_acp_runtime_exact("claude").unwrap(); let codex = super::known_acp_runtime_exact("codex").unwrap(); assert!( - claude.cli_install_commands_for_os().is_empty(), - "claude must not have install commands on any platform" + !claude.cli_install_commands_for_os().is_empty(), + "claude must have install commands on every platform" ); assert!( - codex.cli_install_commands_for_os().is_empty(), - "codex must not have install commands on any platform" + !codex.cli_install_commands_for_os().is_empty(), + "codex must have install commands on every platform" ); } -/// On Windows, the bundled runtimes still expose no install commands, and -/// goose keeps its platform-neutral commands. +/// On Windows, Claude and Codex select the PowerShell install commands. #[cfg(windows)] #[test] -fn test_cli_install_commands_for_os_on_windows() { +fn test_cli_install_commands_for_os_selects_powershell_on_windows() { let claude = super::known_acp_runtime_exact("claude").unwrap(); let codex = super::known_acp_runtime_exact("codex").unwrap(); + + // Windows must select the PowerShell commands, not the curl|bash ones. + let claude_cmds = claude.cli_install_commands_for_os(); + let codex_cmds = codex.cli_install_commands_for_os(); + + assert_ne!( + claude_cmds, claude.cli_install_commands, + "Windows must NOT use the default curl|bash commands for claude" + ); + assert_ne!( + codex_cmds, codex.cli_install_commands, + "Windows must NOT use the default curl|bash commands for codex" + ); + + // Verify they are the PowerShell installers. assert!( - claude.cli_install_commands_for_os().is_empty(), - "claude ships bundled — no Windows install commands" + claude_cmds.iter().any(|c| c.contains("powershell")), + "claude Windows install must use powershell; got: {claude_cmds:?}" ); assert!( - codex.cli_install_commands_for_os().is_empty(), - "codex ships bundled — no Windows install commands" + codex_cmds.iter().any(|c| c.contains("powershell")), + "codex Windows install must use powershell; got: {codex_cmds:?}" ); // Goose and buzz-agent must NOT use Windows-specific commands. diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index d8695624d..dd47973aa 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_probe_binary, KnownAcpRuntime, + classify_runtime, find_command, known_acp_runtime, resolve_command, KnownAcpRuntime, }, env_vars::merged_user_env, global_config::GlobalAgentConfig, @@ -502,10 +502,7 @@ fn cli_login_requirements( .iter() .find_map(|cmd| find_command(cmd).map(|path| (*cmd, 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. + // Check whether the underlying CLI itself (e.g. "claude", "codex") is on PATH. let underlying_cli_found = runtime .underlying_cli .map(|cli| find_command(cli).is_some()) @@ -516,12 +513,10 @@ fn cli_login_requirements( match availability { AcpAvailabilityStatus::Available => { - // 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 { + // 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 { // Unexpectedly not resolvable (race or PATH edge case). return vec![Requirement::CliLogin { probe_args: probe_args.iter().map(|s| s.to_string()).collect(), @@ -945,9 +940,11 @@ mod tests { mcp_command: None, mcp_hooks: false, underlying_cli, + bridge_cli_env_var: None, cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], + adapter_ships_with_app: false, install_instructions_url: "", cli_install_hint: "", adapter_install_hint: "", @@ -1046,11 +1043,10 @@ mod tests { } #[test] - fn cli_login_requirements_probe_runs_even_without_underlying_cli() { + fn cli_login_requirements_cli_missing_emits_cli_missing() { // Adapter present (use the running test binary as a portable stand-in), - // 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. + // underlying CLI absent. + // → CliMissing state → no probe run → CliLogin{CliMissing}. let exe = present_binary_str(); let rt = make_cli_runtime( static_commands(vec![exe]), // adapter found via absolute path @@ -1058,9 +1054,19 @@ mod tests { ); let reqs = cli_login_requirements(&[exe, "--list"], "install the CLI", &rt); assert!( - reqs.is_empty(), - "adapter present must probe login regardless of the user CLI; got {reqs:?}" + !reqs.is_empty(), + "CLI missing must produce a CliLogin requirement" ); + 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/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index bc014889b..f915311cc 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1579,6 +1579,31 @@ pub fn spawn_agent_child( command.env("MCP_HOOK_SERVERS", "*"); } + // Point the bundled bridge at the user's harness CLI. The bridge-only + // bundles vendor no CLI: the claude bridge only looks at + // CLAUDE_CODE_EXECUTABLE (or the omitted SDK native package) and the + // codex bridge's CODEX_PATH fallback hard-requires the omitted platform + // package — neither falls back to PATH. A value already present in the + // desktop's own environment is inherited untouched (it names a binary the + // user picked), and a per-agent env var applied below overrides this one. + // Resolution failure is non-fatal here: readiness classifies the runtime + // CliMissing and the agent enters setup mode instead of the pool. + if let Some((cli, env_var)) = + runtime_meta.and_then(|r| r.underlying_cli.zip(r.bridge_cli_env_var)) + { + if std::env::var_os(env_var).is_none() { + match resolve_command(cli) { + Some(path) => { + command.env(env_var, &path); + } + None => eprintln!( + "buzz-desktop: {cli} not found for {env_var}; \ + bridge sessions will fail until it is installed" + ), + } + } + } + // ── Readiness check: set setup-payload if agent is not ready ───────────── // // Build the effective env the agent would have at start-time, run the diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e6c42823f..21f1c014c 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -523,14 +523,12 @@ 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 b8666728c..25cfe9af1 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -562,7 +562,9 @@ export function AgentDefinitionDialog({

{selectedRuntime.availability === "adapter_missing" ? `${selectedRuntime.label} CLI is installed but the ACP adapter 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.test.mjs b/desktop/src/features/agents/ui/personaDialogPickers.test.mjs index de81a5726..5cc49a989 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", "not_installed"), + makeRuntime("goose", "cli_missing"), 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", "adapter_missing"), + makeRuntime("goose", "cli_missing"), ]; assert.equal(getDefaultPersonaRuntime(runtimes), null); }); @@ -168,7 +168,11 @@ 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", "not_installed"]) { + for (const availability of [ + "adapter_missing", + "cli_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 c2c9c9e49..72a96ca99 100644 --- a/desktop/src/features/agents/ui/personaDialogPickers.tsx +++ b/desktop/src/features/agents/ui/personaDialogPickers.tsx @@ -383,9 +383,11 @@ export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) { const suffix = runtime.availability === "adapter_missing" ? " (adapter missing)" - : runtime.availability === "not_installed" - ? " (not installed)" - : ""; + : runtime.availability === "cli_missing" + ? " (CLI missing)" + : runtime.availability === "not_installed" + ? " (not installed)" + : ""; return `${runtime.label}${suffix}`; } @@ -395,10 +397,12 @@ function runtimeAvailabilitySortRank( switch (availability) { case "available": return 0; - case "not_installed": + case "cli_missing": return 1; - case "adapter_missing": + case "not_installed": return 2; + case "adapter_missing": + return 3; } } diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index ede6aa250..355953808 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -353,6 +353,19 @@ 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 2fea5986c..cf21b6f6a 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: "not_installed", command: null })], + runtimes: [runtime({ availability: "cli_missing", 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 7e78409b7..ce665157b 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -38,6 +38,8 @@ function StatusIcon({ return ; case "adapter_missing": return ; + case "cli_missing": + return ; case "not_installed": return ; } @@ -136,7 +138,8 @@ 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 === "adapter_missing" || + runtime.availability === "cli_missing" ? "bg-amber-500/5" : "bg-muted/20", )} @@ -173,12 +176,23 @@ function RuntimeRow({

) : null} - {/* The bundled bridge's resource-dir path is noise — 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. */} - {runtime.adapterBundled ? null : ( + {runtime.underlyingCliPath && + runtime.underlyingCliPath !== runtime.binaryPath ? ( +
+

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

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

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

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

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

+ {runtime.adapterBundled ? ( + <> + 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 470b37cc2..9b07fc911 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -554,14 +554,10 @@ 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 c38e95aac..8e1603e23 100644 --- a/desktop/src/shared/lib/configNudge.test.mjs +++ b/desktop/src/shared/lib/configNudge.test.mjs @@ -103,30 +103,6 @@ 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 5ceb8314d..901bb2273 100644 --- a/desktop/src/shared/lib/configNudge.ts +++ b/desktop/src/shared/lib/configNudge.ts @@ -32,7 +32,8 @@ 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 - * - "not_installed" → no adapter found + * - "cli_missing" → ACP adapter installed but CLI missing + * - "not_installed" → neither adapter nor CLI found */ availability: AcpAvailabilityStatus; } @@ -138,11 +139,9 @@ 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 b7ebd6ff3..f21e5a329 100644 --- a/desktop/src/shared/ui/config-nudge-attachment.tsx +++ b/desktop/src/shared/ui/config-nudge-attachment.tsx @@ -103,6 +103,8 @@ 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 e0ab3ffe4..9bd273217 100644 --- a/desktop/tests/e2e/doctor-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/doctor-cta-screenshots.spec.ts @@ -257,7 +257,44 @@ test.describe("doctor CTA nudge card screenshots", () => { }); }); - // 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). + /** + * 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`, + }); + }); }); diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index 1c462e53e..9fb09c765 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -48,9 +48,6 @@ 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", @@ -64,8 +61,8 @@ const CLAUDE_AVAILABLE_LOGGED_IN = { install_hint: "", install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", - can_auto_install: false, - underlying_cli_path: null, + can_auto_install: true, + underlying_cli_path: "/usr/local/bin/claude", auth_status: { status: "logged_in" }, }; @@ -149,6 +146,7 @@ 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.", }, @@ -416,8 +414,8 @@ test.describe("Doctor panel state screenshots", () => { /** * 09 — available runtime whose adapter is the bridge bundled with the app: * 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. + * resource-dir path; the user's CLI path still renders (the bridge-only + * bundle runs the user's claude CLI). */ test("09-bundled-adapter", async ({ page }) => { const bundledPath = @@ -441,7 +439,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("Bundled with Buzz."); - await expect(row).not.toContainText("CLI:"); + await expect(row).toContainText("/usr/local/bin/claude"); await expect(row).not.toContainText(bundledPath); await expect(row).not.toContainText("installed on PATH"); From 5aee0f4dfbeff4b180d15fe0feb3208fa552831a Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 15 Jul 2026 15:27:14 +1000 Subject: [PATCH 20/20] fix(desktop): enable the windows-sys features the ACP node launcher needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows Rust CI job fails to compile buzz-acp-node-launcher: CreateJobObjectW and JOBOBJECT_EXTENDED_LIMIT_INFORMATION are unresolved (E0432). In windows-sys 0.61 the JobObjects module gates items on the features of the types in their signatures: CreateJobObjectW behind Win32_Security (its SECURITY_ATTRIBUTES parameter) and the JOBOBJECT_* limit structs behind Win32_System_Threading. The launcher's Job Object code was copied from buzz-dev-mcp's KillGroup, but its Cargo.toml only enabled Win32_Foundation + Win32_System_JobObjects — and since the dependency sits under [target.'cfg(windows)'], none of the macOS-host verification for the Windows bundling commit ever compiled it. Add Win32_Security and Win32_System_Threading, matching buzz-dev-mcp's feature set minus its registry lookup, with a comment recording why JobObjects alone is not enough. Verification (macOS host, rust-std for x86_64-pc-windows-msvc added via rustup): - cargo check -p buzz-acp-node-launcher --target x86_64-pc-windows-msvc --all-targets: clean (reproduced the E0432 before the fix) - cargo clippy -p buzz-acp-node-launcher --target x86_64-pc-windows-msvc --all-targets -D warnings: clean - host cargo test -p buzz-acp-node-launcher: 9 passed; host clippy -D warnings and cargo fmt --check: clean Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- crates/buzz-acp-node-launcher/Cargo.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/buzz-acp-node-launcher/Cargo.toml b/crates/buzz-acp-node-launcher/Cargo.toml index f6c00e08a..831a1dcb5 100644 --- a/crates/buzz-acp-node-launcher/Cargo.toml +++ b/crates/buzz-acp-node-launcher/Cargo.toml @@ -15,7 +15,11 @@ serde = { workspace = true } serde_json = { workspace = true } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_JobObjects"] } +# Win32_System_JobObjects alone is not enough: CreateJobObjectW is gated on +# Win32_Security (its SECURITY_ATTRIBUTES parameter) and the JOBOBJECT_* +# limit structs on Win32_System_Threading. Same set as buzz-dev-mcp's +# KillGroup, minus its registry lookup. +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] } [dev-dependencies] tempfile = "3"