From 16b115a76bc4350648a4e796f2be8ca7008fd1bb Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 9 Jul 2026 11:07:41 +1000 Subject: [PATCH 1/9] feat(staged): bundle ACP bridge tools Pin the standalone ACP bridge CLIs (claude-agent-acp, codex-acp) from npm in acp-tools.lock.json, per supported target, and ship them as Tauri app resources instead of relying on whatever the user has installed globally. - scripts/update-acp-tools-lock.mjs resolves each package via npm view against the configured registry and fails loudly when a package or one of its per-target native dependencies cannot be resolved, rather than silently pinning a stale version. - scripts/ensure-acp-tools.sh installs the pinned packages into the shared Staged dev cache, validating versions and integrity hashes against the lock. - scripts/prepare-acp-tools-resource.sh stages the vendored package trees under src-tauri/resources/acp/node with executable wrappers in src-tauri/resources/acp/bin (ad-hoc signed on macOS), which tauri.conf.json bundles as resources. The bin dir holds a single target at a time, so staging stays tied to the build target. - just install ensures the pinned tools; just dev stages the resource dir and exports STAGED_ACP_TOOLS_DIR; just build / release-build and the release workflow stage before tauri build; just bump-acp-tools reruns the lock updater. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .github/workflows/staged-release.yml | 4 + .gitignore | 5 + apps/staged/acp-tools.lock.json | 196 +++++++++ apps/staged/justfile | 13 + apps/staged/scripts/ensure-acp-tools.sh | 378 ++++++++++++++++++ .../scripts/prepare-acp-tools-resource.sh | 124 ++++++ apps/staged/scripts/update-acp-tools-lock.mjs | 327 +++++++++++++++ .../src-tauri/resources/acp/bin/.gitkeep | 0 apps/staged/src-tauri/tauri.conf.json | 3 +- 9 files changed, 1049 insertions(+), 1 deletion(-) create mode 100644 apps/staged/acp-tools.lock.json create mode 100755 apps/staged/scripts/ensure-acp-tools.sh create mode 100755 apps/staged/scripts/prepare-acp-tools-resource.sh create mode 100755 apps/staged/scripts/update-acp-tools-lock.mjs create mode 100644 apps/staged/src-tauri/resources/acp/bin/.gitkeep diff --git a/.github/workflows/staged-release.yml b/.github/workflows/staged-release.yml index 413de3b4b..9c2c4146b 100644 --- a/.github/workflows/staged-release.yml +++ b/.github/workflows/staged-release.yml @@ -79,6 +79,10 @@ jobs: STAGED_UPDATER_ENDPOINT: https://github.com/${{ github.repository }}/releases/download/staged-latest/latest.json run: pnpm run tauri:release:config + - name: Stage bundled ACP tools + working-directory: apps/staged + run: ./scripts/prepare-acp-tools-resource.sh "$TAURI_TARGET" + - name: Build unsigned Tauri app working-directory: apps/staged env: diff --git a/.gitignore b/.gitignore index af9ed7944..2721dbe56 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ target/ .DS_Store .hermit node_modules/ + +# Staged bundled ACP bridge tools (regenerated by apps/staged/scripts/prepare-acp-tools-resource.sh) +apps/staged/src-tauri/resources/acp/bin/* +!apps/staged/src-tauri/resources/acp/bin/.gitkeep +apps/staged/src-tauri/resources/acp/node/ diff --git a/apps/staged/acp-tools.lock.json b/apps/staged/acp-tools.lock.json new file mode 100644 index 000000000..86cd661f7 --- /dev/null +++ b/apps/staged/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.56.0", + "integrity": "sha512-yo+51zuCLFfrdZ3PusdQcZ8/6l+qkEYcr2JVBtyqh7oWWomeEDUV93d9lr3y5oLVfyUx6bTAo3qml2yieJc6Ww==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.56.0.tgz", + "target": "aarch64-apple-darwin", + "npmOs": "darwin", + "npmCpu": "arm64", + "nodeEngine": ">=22", + "dependencyPackage": "@anthropic-ai/claude-agent-sdk", + "dependencyVersion": "0.3.201", + "dependencyIntegrity": "sha512-InT1XLmf2QpldWdtznKDWEoGJT4p+sXh24yxbeBQ++lMJCzMrI0W27MEmmmDWx0otpa+ubdHCF5YQ6oiNt7cmg==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.201.tgz", + "claudeCodeVersion": "2.1.201", + "nativePackage": "@anthropic-ai/claude-agent-sdk-darwin-arm64", + "nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-arm64", + "nativeVersion": "0.3.201", + "nativeIntegrity": "sha512-8Mcb3BDyKUGfJWFFTWwt+at37lbDH3ZwVtUNPWGG1toZ75RDCJry5U4kXRvQ2xokvJQlA0E+eNp6keWe5ZH22Q==", + "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.201.tgz", + "nativeExecutable": "claude" + }, + { + "id": "claude-acp", + "binary": "claude-agent-acp", + "source": "npm", + "package": "@agentclientprotocol/claude-agent-acp", + "version": "0.56.0", + "integrity": "sha512-yo+51zuCLFfrdZ3PusdQcZ8/6l+qkEYcr2JVBtyqh7oWWomeEDUV93d9lr3y5oLVfyUx6bTAo3qml2yieJc6Ww==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.56.0.tgz", + "target": "aarch64-unknown-linux-gnu", + "npmOs": "linux", + "npmCpu": "arm64", + "npmLibc": "glibc", + "nodeEngine": ">=22", + "dependencyPackage": "@anthropic-ai/claude-agent-sdk", + "dependencyVersion": "0.3.201", + "dependencyIntegrity": "sha512-InT1XLmf2QpldWdtznKDWEoGJT4p+sXh24yxbeBQ++lMJCzMrI0W27MEmmmDWx0otpa+ubdHCF5YQ6oiNt7cmg==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.201.tgz", + "claudeCodeVersion": "2.1.201", + "nativePackage": "@anthropic-ai/claude-agent-sdk-linux-arm64", + "nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-arm64", + "nativeVersion": "0.3.201", + "nativeIntegrity": "sha512-mShTo3MwF0gkN4dDw78wWJiB6aBDVRkl81cnApvoBofpdyUBYgm9Gw16CCjDTgelMKeBFqN6ErJpwjI3wbP00A==", + "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.201.tgz", + "nativeExecutable": "claude" + }, + { + "id": "claude-acp", + "binary": "claude-agent-acp", + "source": "npm", + "package": "@agentclientprotocol/claude-agent-acp", + "version": "0.56.0", + "integrity": "sha512-yo+51zuCLFfrdZ3PusdQcZ8/6l+qkEYcr2JVBtyqh7oWWomeEDUV93d9lr3y5oLVfyUx6bTAo3qml2yieJc6Ww==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.56.0.tgz", + "target": "x86_64-apple-darwin", + "npmOs": "darwin", + "npmCpu": "x64", + "nodeEngine": ">=22", + "dependencyPackage": "@anthropic-ai/claude-agent-sdk", + "dependencyVersion": "0.3.201", + "dependencyIntegrity": "sha512-InT1XLmf2QpldWdtznKDWEoGJT4p+sXh24yxbeBQ++lMJCzMrI0W27MEmmmDWx0otpa+ubdHCF5YQ6oiNt7cmg==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.201.tgz", + "claudeCodeVersion": "2.1.201", + "nativePackage": "@anthropic-ai/claude-agent-sdk-darwin-x64", + "nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-x64", + "nativeVersion": "0.3.201", + "nativeIntegrity": "sha512-TFR2bu0+ml3RHoMrtsgD0qDK5Oknw8kYGBV7qpQHn+IWmE96gnHhogG1LpJwpHtni08XkJIjfWk1DdlsUYtRkQ==", + "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.201.tgz", + "nativeExecutable": "claude" + }, + { + "id": "claude-acp", + "binary": "claude-agent-acp", + "source": "npm", + "package": "@agentclientprotocol/claude-agent-acp", + "version": "0.56.0", + "integrity": "sha512-yo+51zuCLFfrdZ3PusdQcZ8/6l+qkEYcr2JVBtyqh7oWWomeEDUV93d9lr3y5oLVfyUx6bTAo3qml2yieJc6Ww==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.56.0.tgz", + "target": "x86_64-unknown-linux-gnu", + "npmOs": "linux", + "npmCpu": "x64", + "npmLibc": "glibc", + "nodeEngine": ">=22", + "dependencyPackage": "@anthropic-ai/claude-agent-sdk", + "dependencyVersion": "0.3.201", + "dependencyIntegrity": "sha512-InT1XLmf2QpldWdtznKDWEoGJT4p+sXh24yxbeBQ++lMJCzMrI0W27MEmmmDWx0otpa+ubdHCF5YQ6oiNt7cmg==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.201.tgz", + "claudeCodeVersion": "2.1.201", + "nativePackage": "@anthropic-ai/claude-agent-sdk-linux-x64", + "nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-x64", + "nativeVersion": "0.3.201", + "nativeIntegrity": "sha512-jrJBrRWrSuoFKIgjyqxHqmfd6Pb3Bs5Bvakg0knXCTC4fbUXGnC9Q6u7gdDwgXohUNP6/DD+s8U7bivvvVv0dg==", + "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.201.tgz", + "nativeExecutable": "claude" + }, + { + "id": "codex-acp", + "binary": "codex-acp", + "source": "npm", + "package": "@agentclientprotocol/codex-acp", + "version": "1.1.0", + "integrity": "sha512-EdUwW6n12yy4khxS6YpOgT8dd4JbVeOP8qPLdCEj795kp85qVI6369EWUXHq1CrnvOmGVwieUMvnrMlsqJaRWg==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.0.tgz", + "target": "aarch64-apple-darwin", + "npmOs": "darwin", + "npmCpu": "arm64", + "nodeEngine": ">=22", + "dependencyPackage": "@openai/codex", + "dependencyVersion": "0.142.5", + "dependencyIntegrity": "sha512-WQEpD7l3k68eIAP0aq28EdR18ENBAf8DyprzFhzNwCOQJSv4nHzpwT8Fl30IJacprko2ZCmUBZjM2u941l2yLw==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5.tgz", + "nativePackage": "@openai/codex-darwin-arm64", + "nativePackageName": "@openai/codex", + "nativeVersion": "0.142.5-darwin-arm64", + "nativeIntegrity": "sha512-l43p8xv+Z/2/b6fCUc7/FmcQZsaPB7RFizLponGwHAnFOWe3i9Vky69p+up3BUam9AetoQQUv7Mo+2KdaFEqhA==", + "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5-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.0", + "integrity": "sha512-EdUwW6n12yy4khxS6YpOgT8dd4JbVeOP8qPLdCEj795kp85qVI6369EWUXHq1CrnvOmGVwieUMvnrMlsqJaRWg==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.0.tgz", + "target": "aarch64-unknown-linux-gnu", + "npmOs": "linux", + "npmCpu": "arm64", + "npmLibc": "glibc", + "nodeEngine": ">=22", + "dependencyPackage": "@openai/codex", + "dependencyVersion": "0.142.5", + "dependencyIntegrity": "sha512-WQEpD7l3k68eIAP0aq28EdR18ENBAf8DyprzFhzNwCOQJSv4nHzpwT8Fl30IJacprko2ZCmUBZjM2u941l2yLw==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5.tgz", + "nativePackage": "@openai/codex-linux-arm64", + "nativePackageName": "@openai/codex", + "nativeVersion": "0.142.5-linux-arm64", + "nativeIntegrity": "sha512-77ka5PSnm5HdxdBT99IwntCasmbqevlS0eiC0AtEb6ZXCLkim2gm0AWm+jNYy0EhbssvNK+KghayWo34HMgXeA==", + "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5-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.0", + "integrity": "sha512-EdUwW6n12yy4khxS6YpOgT8dd4JbVeOP8qPLdCEj795kp85qVI6369EWUXHq1CrnvOmGVwieUMvnrMlsqJaRWg==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.0.tgz", + "target": "x86_64-apple-darwin", + "npmOs": "darwin", + "npmCpu": "x64", + "nodeEngine": ">=22", + "dependencyPackage": "@openai/codex", + "dependencyVersion": "0.142.5", + "dependencyIntegrity": "sha512-WQEpD7l3k68eIAP0aq28EdR18ENBAf8DyprzFhzNwCOQJSv4nHzpwT8Fl30IJacprko2ZCmUBZjM2u941l2yLw==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5.tgz", + "nativePackage": "@openai/codex-darwin-x64", + "nativePackageName": "@openai/codex", + "nativeVersion": "0.142.5-darwin-x64", + "nativeIntegrity": "sha512-yk6A06/VmW7NFsa48OVPaj//g/zeSpd79wjuqfXZwW8ZKRYQm3+wCd3hWjPl79F3QnXvDvM2j3JMIBL3m3GXXg==", + "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5-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.0", + "integrity": "sha512-EdUwW6n12yy4khxS6YpOgT8dd4JbVeOP8qPLdCEj795kp85qVI6369EWUXHq1CrnvOmGVwieUMvnrMlsqJaRWg==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.0.tgz", + "target": "x86_64-unknown-linux-gnu", + "npmOs": "linux", + "npmCpu": "x64", + "npmLibc": "glibc", + "nodeEngine": ">=22", + "dependencyPackage": "@openai/codex", + "dependencyVersion": "0.142.5", + "dependencyIntegrity": "sha512-WQEpD7l3k68eIAP0aq28EdR18ENBAf8DyprzFhzNwCOQJSv4nHzpwT8Fl30IJacprko2ZCmUBZjM2u941l2yLw==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5.tgz", + "nativePackage": "@openai/codex-linux-x64", + "nativePackageName": "@openai/codex", + "nativeVersion": "0.142.5-linux-x64", + "nativeIntegrity": "sha512-pxY+d3NgNE57Y/MApD3/TZUAygxJN6I9h3ZeDUwe67mxWjUxsuapxMRFTKSznCalYbRAeZp752+AAXmUbmguEg==", + "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5-linux-x64.tgz", + "nativeExecutable": "vendor/x86_64-unknown-linux-musl/bin/codex" + } + ] +} diff --git a/apps/staged/justfile b/apps/staged/justfile index a7009b6b4..4366f0f87 100644 --- a/apps/staged/justfile +++ b/apps/staged/justfile @@ -9,6 +9,7 @@ install: pnpm install cd src-tauri && cargo fetch + ./scripts/ensure-acp-tools.sh # ============================================================================ # Development @@ -22,6 +23,12 @@ dev repo="": # Install JS deps if needed [[ -d node_modules ]] || pnpm install + # 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. + ./scripts/prepare-acp-tools-resource.sh + export STAGED_ACP_TOOLS_DIR="$(pwd)/src-tauri/resources/acp/bin" + echo "Using ACP tools dir: ${STAGED_ACP_TOOLS_DIR}" + # Derive a stable port from the working directory so the same worktree # always gets the same port. This avoids changing TAURI_CONFIG between # runs, which would invalidate Cargo's build cache and trigger a full @@ -55,6 +62,7 @@ dev repo="": # Build the app for production build: + ./scripts/prepare-acp-tools-resource.sh pnpm run tauri:build # Generate the release-only Tauri config with updater settings @@ -63,6 +71,7 @@ release-config: # Build a signed/notarized release bundle once release config exists release-build target="aarch64-apple-darwin" *args: + ./scripts/prepare-acp-tools-resource.sh {{target}} pnpm exec tauri build --target {{target}} --config src-tauri/tauri.release.conf.json {{args}} # Create a release branch, bump staged versions, and open a release PR @@ -128,6 +137,10 @@ release version: echo "Pushed tag staged/v{{version}} — CI will build and publish the release." +# Query the latest npm releases of the bundled ACP bridge tools and update acp-tools.lock.json +bump-acp-tools *ARGS: + node scripts/update-acp-tools-lock.mjs {{ ARGS }} + # ============================================================================ # Code Quality # ============================================================================ diff --git a/apps/staged/scripts/ensure-acp-tools.sh b/apps/staged/scripts/ensure-acp-tools.sh new file mode 100755 index 000000000..77c474247 --- /dev/null +++ b/apps/staged/scripts/ensure-acp-tools.sh @@ -0,0 +1,378 @@ +#!/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}" + +usage() { + cat <<'USAGE' +Usage: scripts/ensure-acp-tools.sh [--target ] [--print-bin-dir] + +Installs the ACP bridge tools pinned in acp-tools.lock.json into the shared +Staged 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: ./acp-tools.lock.json) + ACP_TOOLS_CACHE_DIR cache dir override +USAGE +} + +default_cache_root() { + if [[ -n "${XDG_CACHE_HOME:-}" ]]; then + printf '%s/staged-dev/acp-tools\n' "$XDG_CACHE_HOME" + return + fi + case "$(uname -s)" in + Darwin) printf '%s/Library/Caches/staged-dev/acp-tools\n' "$HOME" ;; + *) printf '%s/.cache/staged-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 + +write_node_wrapper() { + local wrapper="$1" + local entrypoint="$2" + local node_engine="${3:->=22}" + local required_node_major + required_node_major="$(printf '%s\n' "$node_engine" | sed -n 's/^>=\([0-9][0-9]*\).*$/\1/p')" + if [[ -z "$required_node_major" ]]; then + required_node_major=22 + fi + + 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" +} + +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" + stamp="$tool_dir/stamp.env" + 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 + +if [[ "$print_bin_dir" == "1" ]]; then + printf '%s\n' "$bin_dir" +fi diff --git a/apps/staged/scripts/prepare-acp-tools-resource.sh b/apps/staged/scripts/prepare-acp-tools-resource.sh new file mode 100755 index 000000000..83d05f64c --- /dev/null +++ b/apps/staged/scripts/prepare-acp-tools-resource.sh @@ -0,0 +1,124 @@ +#!/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}" + +usage() { + cat <<'USAGE' +Usage: 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" + +write_node_wrapper() { + local wrapper="$1" + local entrypoint="$2" + local node_engine="${3:->=22}" + local required_node_major + required_node_major="$(printf '%s\n' "$node_engine" | sed -n 's/^>=\([0-9][0-9]*\).*$/\1/p')" + if [[ -z "$required_node_major" ]]; then + required_node_major=22 + fi + + 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' + printf 'wrapper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n' + printf 'entrypoint="$wrapper_dir"/%q\n' "$entrypoint" + printf 'exec node "$entrypoint" "$@"\n' + } > "$wrapper" + chmod +x "$wrapper" +} + +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" + while IFS= read -r native_binary; do + [[ -n "$native_binary" ]] || continue + codesign_if_darwin "$native_binary" + done < <(find "$resource_package_dir" -type f \( -name claude -o -name codex \)) +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 +) + +echo "Staged ACP tools resource: $resource_bin_dir" diff --git a/apps/staged/scripts/update-acp-tools-lock.mjs b/apps/staged/scripts/update-acp-tools-lock.mjs new file mode 100755 index 000000000..a1f514fc2 --- /dev/null +++ b/apps/staged/scripts/update-acp-tools-lock.mjs @@ -0,0 +1,327 @@ +#!/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: 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 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 = await npmView( + `${tool.dependencyPackage}@${dependencyRange}`, + ["name", "version", "dist", "optionalDependencies", "claudeCodeVersion"], + ); + 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/apps/staged/src-tauri/resources/acp/bin/.gitkeep b/apps/staged/src-tauri/resources/acp/bin/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/staged/src-tauri/tauri.conf.json b/apps/staged/src-tauri/tauri.conf.json index a632f8d2b..e0f58500b 100644 --- a/apps/staged/src-tauri/tauri.conf.json +++ b/apps/staged/src-tauri/tauri.conf.json @@ -44,7 +44,8 @@ "icons/icon.ico" ], "resources": [ - "resources/pikchr/grammar.md" + "resources/pikchr/grammar.md", + "resources/acp" ], "macOS": { "infoPlist": "Info.plist" From 55eb2772e298eeced89b8b5338d1284da7978c6d Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 9 Jul 2026 11:08:04 +1000 Subject: [PATCH 2/9] feat(staged): resolve bundled ACP tools at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the bundled ACP bridge bin dir deterministically at runtime — the STAGED_ACP_TOOLS_DIR dev override first, then the Tauri resource dir for packaged apps — and register it with acp-client at startup so find_command prefers the pinned bundled bridges over user-installed copies everywhere (session spawn, provider discovery). Shape the captured shell-env snapshots handed to agent spawns through apply_bundled_tools_env: prepend the bundled dir to the imported shell PATH so the bundled bridges win while user-installed CLIs (and their auth state) remain discoverable, and pin GOOSE_SEARCH_PATHS after the shell-env import so user shell values cannot override it — serialized as a JSON array to match Goose's config env parsing and scoped to the managed dir plus any explicit pre-existing Goose search dirs, not the whole shell PATH. Doctor checks and fixes resolve from the same PATH shape so they never prompt users to install a bridge Staged already bundles. Covered with unit tests for dir resolution, PATH prepending, and the GOOSE_SEARCH_PATHS construction. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/acp_tools.rs | 235 ++++++++++++++++++++ apps/staged/src-tauri/src/doctor.rs | 34 ++- apps/staged/src-tauri/src/lib.rs | 8 + apps/staged/src-tauri/src/session_runner.rs | 22 +- apps/staged/src-tauri/src/web_server.rs | 9 +- crates/acp-client/src/lib.rs | 2 +- crates/acp-client/src/types.rs | 83 ++++++- 7 files changed, 375 insertions(+), 18 deletions(-) create mode 100644 apps/staged/src-tauri/src/acp_tools.rs diff --git a/apps/staged/src-tauri/src/acp_tools.rs b/apps/staged/src-tauri/src/acp_tools.rs new file mode 100644 index 000000000..a53a305c8 --- /dev/null +++ b/apps/staged/src-tauri/src/acp_tools.rs @@ -0,0 +1,235 @@ +//! Bundled ACP bridge tool resolution and spawn-environment shaping. +//! +//! Staged ships pinned ACP bridge CLIs (`claude-agent-acp`, `codex-acp`) as +//! application resources (see `acp-tools.lock.json` and +//! `scripts/prepare-acp-tools-resource.sh`). This module resolves the staged +//! bin directory at runtime and shapes captured shell-env snapshots so the +//! bundled bridges win 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 tauri::path::BaseDirectory; +use tauri::Manager; + +/// Dev-mode override exported by `just dev`, pointing at the freshly staged +/// `src-tauri/resources/acp/bin` in the working tree. +pub const ACP_TOOLS_DIR_ENV: &str = "STAGED_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"; +/// Goose reads extra binary search dirs from this env var as a JSON array. +const GOOSE_SEARCH_PATHS_ENV: &str = "GOOSE_SEARCH_PATHS"; + +/// Resolve the directory holding the bundled ACP bridge executables: the dev +/// env override wins, then the Tauri resource dir for packaged apps. +pub fn resolve_bundled_acp_tools_dir(app_handle: &tauri::AppHandle) -> Option { + 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(), + ) +} + +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) +} + +/// Shape a captured shell-env snapshot so the bundled ACP bridges win: +/// +/// - Prepend `bundled_dir` to the snapshot's PATH, keeping the rest of the +/// imported shell PATH intact so user-installed CLIs (and their auth +/// state) remain discoverable. +/// - Pin `GOOSE_SEARCH_PATHS` *after* the shell-env import so same-named +/// values from the user's shell cannot override the bundled tools. The +/// value is a JSON array to match Goose's config env parsing, scoped to +/// the managed dir plus any explicit pre-existing Goose search dirs — +/// never the whole shell PATH. +pub fn apply_bundled_tools_env(vars: &mut Vec<(String, String)>, bundled_dir: &Path) { + prepend_dir_to_path(vars, bundled_dir); + apply_goose_search_paths(vars, bundled_dir); +} + +fn prepend_dir_to_path(vars: &mut Vec<(String, String)>, dir: &Path) { + match vars.iter_mut().find(|(key, _)| key == "PATH") { + Some((_, value)) => { + let mut paths = vec![dir.to_path_buf()]; + paths.extend(std::env::split_paths(value).filter(|path| path != dir)); + *value = std::env::join_paths(paths) + .unwrap_or_default() + .to_string_lossy() + .to_string(); + } + None => vars.push(("PATH".to_string(), dir.to_string_lossy().to_string())), + } +} + +fn apply_goose_search_paths(vars: &mut Vec<(String, String)>, dir: &Path) { + let mut search_paths = vec![dir.to_string_lossy().to_string()]; + if let Some((_, existing)) = vars.iter().find(|(key, _)| key == GOOSE_SEARCH_PATHS_ENV) { + match parse_goose_search_paths(existing) { + Ok(paths) => search_paths.extend(paths), + Err(error) => log::warn!("Ignoring invalid {GOOSE_SEARCH_PATHS_ENV}: {error}"), + } + } + + let value = serde_json::to_string(&search_paths) + .expect("serializing Goose search path strings should not fail"); + match vars + .iter_mut() + .find(|(key, _)| key == GOOSE_SEARCH_PATHS_ENV) + { + Some((_, existing)) => *existing = value, + None => vars.push((GOOSE_SEARCH_PATHS_ENV.to_string(), value)), + } +} + +fn parse_goose_search_paths(value: &str) -> Result, serde_json::Error> { + if value.trim().is_empty() { + return Ok(Vec::new()); + } + serde_json::from_str(value) +} + +#[cfg(test)] +mod tests { + use super::{apply_bundled_tools_env, bundled_acp_tools_dir_from_parts}; + use std::ffi::OsStr; + use std::path::{Path, PathBuf}; + + fn var<'a>(vars: &'a [(String, String)], key: &str) -> Option<&'a str> { + vars.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) + } + + #[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()); + } + + #[test] + fn bundled_dir_is_prepended_before_shell_path() { + let mut vars = vec![ + ("PATH".to_string(), "/shell/bin:/user/bin".to_string()), + ("LANG".to_string(), "en_US.UTF-8".to_string()), + ]; + + apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + + let path = var(&vars, "PATH").expect("PATH should be set"); + let paths: Vec<_> = std::env::split_paths(path).collect(); + assert_eq!( + paths, + vec![ + PathBuf::from("/acp/bin"), + PathBuf::from("/shell/bin"), + PathBuf::from("/user/bin"), + ] + ); + // Non-PATH variables are untouched. + assert_eq!(var(&vars, "LANG"), Some("en_US.UTF-8")); + } + + #[test] + fn bundled_dir_is_not_duplicated_in_path() { + let mut vars = vec![("PATH".to_string(), "/shell/bin:/acp/bin".to_string())]; + + apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + + let path = var(&vars, "PATH").expect("PATH should be set"); + let paths: Vec<_> = std::env::split_paths(path).collect(); + assert_eq!( + paths, + vec![PathBuf::from("/acp/bin"), PathBuf::from("/shell/bin")] + ); + } + + #[test] + fn missing_path_gets_bundled_dir_only() { + let mut vars = Vec::new(); + + apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + + assert_eq!(var(&vars, "PATH"), Some("/acp/bin")); + } + + #[test] + fn goose_search_paths_is_set_as_json_array() { + let mut vars = vec![("PATH".to_string(), "/shell/bin".to_string())]; + + apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + + let value = var(&vars, "GOOSE_SEARCH_PATHS").expect("GOOSE_SEARCH_PATHS should be set"); + let paths: Vec = serde_json::from_str(value).expect("valid JSON array"); + assert_eq!(paths, vec!["/acp/bin"]); + } + + #[test] + fn goose_search_paths_keeps_existing_goose_dirs_without_shell_path() { + let mut vars = vec![ + ("PATH".to_string(), "/shell/bin:/user/bin".to_string()), + ( + "GOOSE_SEARCH_PATHS".to_string(), + "[\"/custom/goose/bin\"]".to_string(), + ), + ]; + + apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + + let value = var(&vars, "GOOSE_SEARCH_PATHS").expect("GOOSE_SEARCH_PATHS should be set"); + let paths: Vec = serde_json::from_str(value).expect("valid JSON array"); + assert_eq!(paths, vec!["/acp/bin", "/custom/goose/bin"]); + assert!(!paths.iter().any(|path| path == "/shell/bin")); + assert!(!paths.iter().any(|path| path == "/user/bin")); + } + + #[test] + fn invalid_existing_goose_search_paths_is_replaced() { + let mut vars = vec![( + "GOOSE_SEARCH_PATHS".to_string(), + "/not/a/json/array".to_string(), + )]; + + apply_bundled_tools_env(&mut vars, Path::new("/acp/bin")); + + let value = var(&vars, "GOOSE_SEARCH_PATHS").expect("GOOSE_SEARCH_PATHS should be set"); + let paths: Vec = serde_json::from_str(value).expect("valid JSON array"); + assert_eq!(paths, vec!["/acp/bin"]); + } +} diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs index 5b880ac46..c36355c8f 100644 --- a/apps/staged/src-tauri/src/doctor.rs +++ b/apps/staged/src-tauri/src/doctor.rs @@ -6,11 +6,20 @@ pub use doctor::{ RunChecksOptions, }; -async fn doctor_env_vars() -> Vec<(String, String)> { - crate::shell_env::home_env_vars_with_extended_path( +/// Environment snapshot for doctor checks and fixes. Shaped through +/// `apply_bundled_tools_env` so checks resolve binaries from the same PATH +/// the agent spawn path uses — a bridge Staged bundles must never be +/// reported missing (or prompt an install) just because the user has no +/// global copy. +async fn doctor_env_vars(app_handle: &tauri::AppHandle) -> Vec<(String, String)> { + let mut env_vars = crate::shell_env::home_env_vars_with_extended_path( crate::session_runner::shell_env_cache().as_ref(), ) - .await + .await; + if let Some(dir) = crate::acp_tools::resolve_bundled_acp_tools_dir(app_handle) { + crate::acp_tools::apply_bundled_tools_env(&mut env_vars, &dir); + } + env_vars } fn run_checks_options(check_freshness: bool, env_vars: Vec<(String, String)>) -> RunChecksOptions { @@ -44,8 +53,8 @@ fn execute_fix_options( /// The frontend calls this first for an instant paint, then follows up with /// [`run_doctor_freshness`] to fill in version/update information. #[tauri::command] -pub async fn run_doctor() -> DoctorReport { - let env_vars = doctor_env_vars().await; +pub async fn run_doctor(app_handle: tauri::AppHandle) -> DoctorReport { + let env_vars = doctor_env_vars(&app_handle).await; doctor::run_checks_with_options(run_checks_options(false, env_vars)).await } @@ -57,8 +66,8 @@ pub async fn run_doctor() -> DoctorReport { /// `updateAvailable`, and the source-aware `updateCommand`/`updateFixType` on /// each readout. Hits the network, so it must never block first paint. #[tauri::command] -pub async fn run_doctor_freshness() -> DoctorReport { - let env_vars = doctor_env_vars().await; +pub async fn run_doctor_freshness(app_handle: tauri::AppHandle) -> DoctorReport { + let env_vars = doctor_env_vars(&app_handle).await; doctor::run_checks_with_options(run_checks_options(true, env_vars)).await } @@ -67,8 +76,12 @@ pub async fn run_doctor_freshness() -> DoctorReport { /// The actual shell command is looked up from the static check definitions — /// the caller never sends a raw command string. #[tauri::command] -pub async fn run_doctor_fix(check_id: String, fix_type: FixType) -> Result<(), String> { - let env_vars = doctor_env_vars().await; +pub async fn run_doctor_fix( + app_handle: tauri::AppHandle, + check_id: String, + fix_type: FixType, +) -> Result<(), String> { + let env_vars = doctor_env_vars(&app_handle).await; doctor::execute_fix_with_env_options(check_id, fix_type, execute_fix_options(None, env_vars)) .await } @@ -87,11 +100,12 @@ pub async fn run_doctor_fix(check_id: String, fix_type: FixType) -> Result<(), S /// validated against the authoritative backend derivation. #[tauri::command] pub async fn run_doctor_update( + app_handle: tauri::AppHandle, check_id: String, fix_type: FixType, command: String, ) -> Result<(), String> { - let env_vars = doctor_env_vars().await; + let env_vars = doctor_env_vars(&app_handle).await; let expected = expected_update_command(&check_id, &fix_type, env_vars.clone()).await?; if expected != command { return Err(format!( diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index d8b75c9c7..c1b7e8933 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ //! See `src-archive/lib.rs` for the previous implementation. pub(crate) mod acp_config; +pub mod acp_tools; pub mod actions; pub mod agent; pub mod background_sync; @@ -1870,6 +1871,13 @@ pub fn run() { ) .plugin(tauri_plugin_store::Builder::new().build()) .setup(|app| { + // Register the bundled ACP bridge tools dir before any command + // runs so binary resolution (session spawn, provider discovery) + // prefers the pinned bridges Staged ships as resources. + if let Some(dir) = acp_tools::resolve_bundled_acp_tools_dir(app.handle()) { + acp_client::set_bundled_tools_dir(dir); + } + let updater_pubkey_present = app .config() .plugins diff --git a/apps/staged/src-tauri/src/session_runner.rs b/apps/staged/src-tauri/src/session_runner.rs index 18b31ee6f..07ba58c0d 100644 --- a/apps/staged/src-tauri/src/session_runner.rs +++ b/apps/staged/src-tauri/src/session_runner.rs @@ -450,6 +450,10 @@ pub fn start_session( let cancel_token = registry.register(&config.session_id); + // Resolve the bundled ACP tools dir before the AppHandle moves into the + // session thread; the snapshots built there prepend it to the agent PATH. + let bundled_acp_tools_dir = crate::acp_tools::resolve_bundled_acp_tools_dir(&app_handle); + // The agent protocol may use !Send futures, so we spin up a dedicated // thread with its own single-threaded Tokio runtime + LocalSet. let session_id_for_status = config.session_id.clone(); @@ -479,13 +483,27 @@ pub fn start_session( // acp` and don't execute locally, so they keep the fallback. A // cwd capture failure is non-fatal: log it and let the driver fall // back to spawning `$SHELL -ils` and exec'ing the agent. + // + // Both snapshots are shaped through `apply_bundled_tools_env` + // after capture, so the bundled ACP bridges win over + // user-installed copies (and over `GOOSE_SEARCH_PATHS` values + // from the user's shell) for the agent and everything it spawns. let driver = if config.workspace_name.is_none() { let cache = shell_env_cache(); - let home_snapshot = + let mut home_snapshot = crate::shell_env::home_env_vars_with_extended_path(cache.as_ref()).await; + if let Some(dir) = bundled_acp_tools_dir.as_deref() { + crate::acp_tools::apply_bundled_tools_env(&mut home_snapshot, dir); + } let driver = driver.with_interpreter_env_snapshot(home_snapshot); match cache.get(&config.working_dir).await { - Ok(snapshot) => driver.with_env_snapshot(snapshot.vars().to_vec()), + Ok(snapshot) => { + let mut vars = snapshot.vars().to_vec(); + if let Some(dir) = bundled_acp_tools_dir.as_deref() { + crate::acp_tools::apply_bundled_tools_env(&mut vars, dir); + } + driver.with_env_snapshot(vars) + } Err(e) => { log::warn!( "Session {}: failed to capture shell env snapshot for {} \ diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 915d596e7..05d4b4910 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -3702,24 +3702,25 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { - let report = crate::doctor::run_doctor().await; + let report = crate::doctor::run_doctor(app_handle.clone()).await; Ok(serde_json::to_value(report).unwrap()) } "run_doctor_freshness" => { - let report = crate::doctor::run_doctor_freshness().await; + let report = crate::doctor::run_doctor_freshness(app_handle.clone()).await; Ok(serde_json::to_value(report).unwrap()) } "run_doctor_fix" => { let check_id: String = arg(&args, "checkId")?; let fix_type: doctor::FixType = arg(&args, "fixType")?; - crate::doctor::run_doctor_fix(check_id, fix_type).await?; + crate::doctor::run_doctor_fix(app_handle.clone(), check_id, fix_type).await?; Ok(Value::Null) } "run_doctor_update" => { let check_id: String = arg(&args, "checkId")?; let fix_type: doctor::FixType = arg(&args, "fixType")?; let command: String = arg(&args, "command")?; - crate::doctor::run_doctor_update(check_id, fix_type, command).await?; + crate::doctor::run_doctor_update(app_handle.clone(), check_id, fix_type, command) + .await?; Ok(Value::Null) } diff --git a/crates/acp-client/src/lib.rs b/crates/acp-client/src/lib.rs index 49a4beccb..dae186a4b 100644 --- a/crates/acp-client/src/lib.rs +++ b/crates/acp-client/src/lib.rs @@ -36,5 +36,5 @@ pub use driver::{ pub use simple::{run_acp_prompt, run_acp_prompt_with_interpreter_env_snapshot}; pub use types::{ discover_providers, find_acp_agent, find_acp_agent_by_id, find_command, known_agent_commands, - AcpAgent, AcpProviderInfo, + set_bundled_tools_dir, AcpAgent, AcpProviderInfo, }; diff --git a/crates/acp-client/src/types.rs b/crates/acp-client/src/types.rs index 2b2495111..9e2ff9a2a 100644 --- a/crates/acp-client/src/types.rs +++ b/crates/acp-client/src/types.rs @@ -2,6 +2,7 @@ use serde::Serialize; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; // ============================================================================= // Known agents — the registry of ACP-compatible providers @@ -160,13 +161,37 @@ impl AcpAgent { // Binary discovery // ============================================================================= +/// Directory holding app-bundled ACP bridge executables, registered once at +/// app startup via [`set_bundled_tools_dir`]. +static BUNDLED_TOOLS_DIR: OnceLock = OnceLock::new(); + +/// Register the directory holding app-bundled ACP bridge executables. +/// +/// [`find_command`] consults this directory before any other resolution +/// strategy, so the pinned bridges an app ships as resources win +/// deterministically over user-installed copies. Set once at app startup; +/// later calls are ignored. +pub fn set_bundled_tools_dir(dir: PathBuf) { + let _ = BUNDLED_TOOLS_DIR.set(dir); +} + /// Find a CLI binary by command name. /// -/// Delegates to doctor so ACP and Doctor share one binary resolution policy. +/// The app-bundled tools dir (if registered) wins; everything else delegates +/// to doctor so ACP and Doctor share one binary resolution policy. pub fn find_command(cmd: &str) -> Option { + if let Some(path) = command_in_dir(BUNDLED_TOOLS_DIR.get().map(PathBuf::as_path), cmd) { + return Some(path); + } doctor::resolve::resolve_binary(cmd).path } +/// Resolve `cmd` to an executable file inside an optional directory. +fn command_in_dir(dir: Option<&Path>, cmd: &str) -> Option { + let dir = dir?; + doctor::resolve::resolve_executable_from_path(cmd, &dir.to_string_lossy()) +} + /// Map an agent ID to the `--command` value for `blox acp`. /// /// Returns `None` if the agent uses the workspace default (no flag needed). @@ -177,3 +202,59 @@ pub(crate) fn blox_acp_command(agent_id: &str) -> Option { parts.join(",") }) } + +#[cfg(test)] +mod tests { + use super::command_in_dir; + use std::fs; + use std::path::Path; + + fn write_executable(path: &Path) { + fs::write(path, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); + } + } + + #[test] + fn command_in_dir_resolves_executable() { + let dir = std::env::temp_dir().join(format!("acp-bundled-dir-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + write_executable(&dir.join("claude-agent-acp")); + + assert_eq!( + command_in_dir(Some(&dir), "claude-agent-acp").as_deref(), + Some(dir.join("claude-agent-acp").as_path()), + ); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn command_in_dir_skips_missing_and_unregistered() { + let dir = std::env::temp_dir().join(format!("acp-bundled-missing-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + assert_eq!(command_in_dir(Some(&dir), "claude-agent-acp"), None); + assert_eq!(command_in_dir(None, "claude-agent-acp"), None); + let _ = fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn command_in_dir_skips_non_executable_files() { + let dir = std::env::temp_dir().join(format!("acp-bundled-nonexec-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join("claude-agent-acp"); + fs::write(&file, "not executable").unwrap(); + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap(); + + assert_eq!(command_in_dir(Some(&dir), "claude-agent-acp"), None); + let _ = fs::remove_dir_all(&dir); + } +} From eb8c892b9730eadeaa58523dc04a3f7c2c9d1232 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 10 Jul 2026 14:15:20 +1000 Subject: [PATCH 3/9] chore(staged): bump bundled ACP tools to latest Run just bump-acp-tools to re-pin the bundled ACP bridge CLIs in acp-tools.lock.json against the latest npm releases: - claude-agent-acp 0.56.0 -> 0.57.0, pulling in @anthropic-ai/claude-agent-sdk 0.3.202 (Claude Code 2.1.202) and its per-target native packages - codex-acp stays at 1.1.0, which is still the latest release Verified the updated lock installs cleanly via ensure-acp-tools.sh, including integrity hash validation. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/acp-tools.lock.json | 80 ++++++++++++++++----------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/apps/staged/acp-tools.lock.json b/apps/staged/acp-tools.lock.json index 86cd661f7..ee2d795e0 100644 --- a/apps/staged/acp-tools.lock.json +++ b/apps/staged/acp-tools.lock.json @@ -5,23 +5,23 @@ "binary": "claude-agent-acp", "source": "npm", "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.56.0", - "integrity": "sha512-yo+51zuCLFfrdZ3PusdQcZ8/6l+qkEYcr2JVBtyqh7oWWomeEDUV93d9lr3y5oLVfyUx6bTAo3qml2yieJc6Ww==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.56.0.tgz", + "version": "0.57.0", + "integrity": "sha512-CvfPwCFmMeOw3elh1UWKL09sU0z8z+qsmmiFND33jHvMo9hSI5gKC4tlXkp9oU+o6ZIMABXmRjHFicVYOu2RkQ==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.57.0.tgz", "target": "aarch64-apple-darwin", "npmOs": "darwin", "npmCpu": "arm64", "nodeEngine": ">=22", "dependencyPackage": "@anthropic-ai/claude-agent-sdk", - "dependencyVersion": "0.3.201", - "dependencyIntegrity": "sha512-InT1XLmf2QpldWdtznKDWEoGJT4p+sXh24yxbeBQ++lMJCzMrI0W27MEmmmDWx0otpa+ubdHCF5YQ6oiNt7cmg==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.201.tgz", - "claudeCodeVersion": "2.1.201", + "dependencyVersion": "0.3.202", + "dependencyIntegrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz", + "claudeCodeVersion": "2.1.202", "nativePackage": "@anthropic-ai/claude-agent-sdk-darwin-arm64", "nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-arm64", - "nativeVersion": "0.3.201", - "nativeIntegrity": "sha512-8Mcb3BDyKUGfJWFFTWwt+at37lbDH3ZwVtUNPWGG1toZ75RDCJry5U4kXRvQ2xokvJQlA0E+eNp6keWe5ZH22Q==", - "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.201.tgz", + "nativeVersion": "0.3.202", + "nativeIntegrity": "sha512-ujR3zDthDPkZs+AxW95iHpqLT5cuwGImsS3mVxLt1DlDij4qeTnihLX8+EpQTK+oNW9jjvFA86yKwa84fa1KYA==", + "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.202.tgz", "nativeExecutable": "claude" }, { @@ -29,24 +29,24 @@ "binary": "claude-agent-acp", "source": "npm", "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.56.0", - "integrity": "sha512-yo+51zuCLFfrdZ3PusdQcZ8/6l+qkEYcr2JVBtyqh7oWWomeEDUV93d9lr3y5oLVfyUx6bTAo3qml2yieJc6Ww==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.56.0.tgz", + "version": "0.57.0", + "integrity": "sha512-CvfPwCFmMeOw3elh1UWKL09sU0z8z+qsmmiFND33jHvMo9hSI5gKC4tlXkp9oU+o6ZIMABXmRjHFicVYOu2RkQ==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.57.0.tgz", "target": "aarch64-unknown-linux-gnu", "npmOs": "linux", "npmCpu": "arm64", "npmLibc": "glibc", "nodeEngine": ">=22", "dependencyPackage": "@anthropic-ai/claude-agent-sdk", - "dependencyVersion": "0.3.201", - "dependencyIntegrity": "sha512-InT1XLmf2QpldWdtznKDWEoGJT4p+sXh24yxbeBQ++lMJCzMrI0W27MEmmmDWx0otpa+ubdHCF5YQ6oiNt7cmg==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.201.tgz", - "claudeCodeVersion": "2.1.201", + "dependencyVersion": "0.3.202", + "dependencyIntegrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz", + "claudeCodeVersion": "2.1.202", "nativePackage": "@anthropic-ai/claude-agent-sdk-linux-arm64", "nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-arm64", - "nativeVersion": "0.3.201", - "nativeIntegrity": "sha512-mShTo3MwF0gkN4dDw78wWJiB6aBDVRkl81cnApvoBofpdyUBYgm9Gw16CCjDTgelMKeBFqN6ErJpwjI3wbP00A==", - "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.201.tgz", + "nativeVersion": "0.3.202", + "nativeIntegrity": "sha512-a4YtRkgGYt3ogePJDW8Ts6bNW690jb9LHyZaiWXsi+zT53xCNqJB2zKPyRc7hXWOqzIk4nCfwJpjmhLzMu3WIg==", + "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.202.tgz", "nativeExecutable": "claude" }, { @@ -54,23 +54,23 @@ "binary": "claude-agent-acp", "source": "npm", "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.56.0", - "integrity": "sha512-yo+51zuCLFfrdZ3PusdQcZ8/6l+qkEYcr2JVBtyqh7oWWomeEDUV93d9lr3y5oLVfyUx6bTAo3qml2yieJc6Ww==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.56.0.tgz", + "version": "0.57.0", + "integrity": "sha512-CvfPwCFmMeOw3elh1UWKL09sU0z8z+qsmmiFND33jHvMo9hSI5gKC4tlXkp9oU+o6ZIMABXmRjHFicVYOu2RkQ==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.57.0.tgz", "target": "x86_64-apple-darwin", "npmOs": "darwin", "npmCpu": "x64", "nodeEngine": ">=22", "dependencyPackage": "@anthropic-ai/claude-agent-sdk", - "dependencyVersion": "0.3.201", - "dependencyIntegrity": "sha512-InT1XLmf2QpldWdtznKDWEoGJT4p+sXh24yxbeBQ++lMJCzMrI0W27MEmmmDWx0otpa+ubdHCF5YQ6oiNt7cmg==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.201.tgz", - "claudeCodeVersion": "2.1.201", + "dependencyVersion": "0.3.202", + "dependencyIntegrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz", + "claudeCodeVersion": "2.1.202", "nativePackage": "@anthropic-ai/claude-agent-sdk-darwin-x64", "nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-x64", - "nativeVersion": "0.3.201", - "nativeIntegrity": "sha512-TFR2bu0+ml3RHoMrtsgD0qDK5Oknw8kYGBV7qpQHn+IWmE96gnHhogG1LpJwpHtni08XkJIjfWk1DdlsUYtRkQ==", - "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.201.tgz", + "nativeVersion": "0.3.202", + "nativeIntegrity": "sha512-s/RVSGgkVmIMfyt1ndR8braLLu82bARoijmt1kk8d4IptUZ0Sc+zNUWKoFXwR9XqDBu6rBbBF9RIzD02raT57w==", + "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.202.tgz", "nativeExecutable": "claude" }, { @@ -78,24 +78,24 @@ "binary": "claude-agent-acp", "source": "npm", "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.56.0", - "integrity": "sha512-yo+51zuCLFfrdZ3PusdQcZ8/6l+qkEYcr2JVBtyqh7oWWomeEDUV93d9lr3y5oLVfyUx6bTAo3qml2yieJc6Ww==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.56.0.tgz", + "version": "0.57.0", + "integrity": "sha512-CvfPwCFmMeOw3elh1UWKL09sU0z8z+qsmmiFND33jHvMo9hSI5gKC4tlXkp9oU+o6ZIMABXmRjHFicVYOu2RkQ==", + "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.57.0.tgz", "target": "x86_64-unknown-linux-gnu", "npmOs": "linux", "npmCpu": "x64", "npmLibc": "glibc", "nodeEngine": ">=22", "dependencyPackage": "@anthropic-ai/claude-agent-sdk", - "dependencyVersion": "0.3.201", - "dependencyIntegrity": "sha512-InT1XLmf2QpldWdtznKDWEoGJT4p+sXh24yxbeBQ++lMJCzMrI0W27MEmmmDWx0otpa+ubdHCF5YQ6oiNt7cmg==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.201.tgz", - "claudeCodeVersion": "2.1.201", + "dependencyVersion": "0.3.202", + "dependencyIntegrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==", + "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz", + "claudeCodeVersion": "2.1.202", "nativePackage": "@anthropic-ai/claude-agent-sdk-linux-x64", "nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-x64", - "nativeVersion": "0.3.201", - "nativeIntegrity": "sha512-jrJBrRWrSuoFKIgjyqxHqmfd6Pb3Bs5Bvakg0knXCTC4fbUXGnC9Q6u7gdDwgXohUNP6/DD+s8U7bivvvVv0dg==", - "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.201.tgz", + "nativeVersion": "0.3.202", + "nativeIntegrity": "sha512-XIvhdCWAAT4OdOA82fOJII+WH0Tf8pFckckEbJMMmOgQBKOnHT+609Pd3Ehw6zGcA9iFrhG5mY8Ncuckeo1aMw==", + "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.202.tgz", "nativeExecutable": "claude" }, { From afa524e77da22b773f42054624137ab2595db38a Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Sat, 11 Jul 2026 14:41:38 +1000 Subject: [PATCH 4/9] fix(staged): keep best-effort PATH when a prepend dir fails to join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both PATH-building sites in the agent spawn env — the bundled ACP dir prepend in acp_tools::prepend_dir_to_path and the well-known tool dirs appended in shell_env::build_extended_path_from_path — joined the final list with join_paths().unwrap_or_default(), so a single dir that join_paths rejects (e.g. an install path containing ':', legal on macOS) emptied the whole spawned-agent PATH. Route both through a shared join_paths_best_effort that retries the join without the un-joinable dirs (logging each dropped entry), so one bad entry degrades to a best-effort PATH instead of erasing every search path. Ports squareup/berd@bb912c96 to Staged's split PATH-shaping layout. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/acp_tools.rs | 21 +++++++++--- apps/staged/src-tauri/src/shell_env.rs | 45 +++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/apps/staged/src-tauri/src/acp_tools.rs b/apps/staged/src-tauri/src/acp_tools.rs index a53a305c8..2e73344f7 100644 --- a/apps/staged/src-tauri/src/acp_tools.rs +++ b/apps/staged/src-tauri/src/acp_tools.rs @@ -68,10 +68,7 @@ fn prepend_dir_to_path(vars: &mut Vec<(String, String)>, dir: &Path) { Some((_, value)) => { let mut paths = vec![dir.to_path_buf()]; paths.extend(std::env::split_paths(value).filter(|path| path != dir)); - *value = std::env::join_paths(paths) - .unwrap_or_default() - .to_string_lossy() - .to_string(); + *value = crate::shell_env::join_paths_best_effort(paths); } None => vars.push(("PATH".to_string(), dir.to_string_lossy().to_string())), } @@ -180,6 +177,22 @@ mod tests { ); } + #[test] + #[cfg(unix)] + fn unjoinable_bundled_dir_keeps_shell_path_instead_of_emptying_it() { + // A dir embedding the separator (legal in macOS paths) can't be joined + // into PATH; it must be dropped, not erase every shell search path. + let mut vars = vec![("PATH".to_string(), "/shell/bin:/user/bin".to_string())]; + + apply_bundled_tools_env(&mut vars, Path::new("/weird:dir/bin")); + + let path = var(&vars, "PATH").expect("PATH should be set"); + let paths: Vec<_> = std::env::split_paths(path).collect(); + assert!(paths.contains(&PathBuf::from("/shell/bin"))); + assert!(paths.contains(&PathBuf::from("/user/bin"))); + assert!(!paths.iter().any(|p| p.to_string_lossy().contains("weird"))); + } + #[test] fn missing_path_gets_bundled_dir_only() { let mut vars = Vec::new(); diff --git a/apps/staged/src-tauri/src/shell_env.rs b/apps/staged/src-tauri/src/shell_env.rs index 0e3ad15a0..3ec561aa9 100644 --- a/apps/staged/src-tauri/src/shell_env.rs +++ b/apps/staged/src-tauri/src/shell_env.rs @@ -215,10 +215,31 @@ pub fn build_extended_path_from_path(path: Option<&str>) -> String { let mut seen = HashSet::new(); paths.retain(|p| seen.insert(p.clone())); - std::env::join_paths(paths) - .unwrap_or_default() - .to_string_lossy() - .to_string() + join_paths_best_effort(paths) +} + +/// 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, and `unwrap_or_default` would then hand +/// subprocesses an empty PATH. Drop the un-joinable entries (logging each) +/// instead of erasing every search path. +pub(crate) fn join_paths_best_effort(mut paths: Vec) -> String { + match std::env::join_paths(&paths) { + Ok(joined) => joined.to_string_lossy().to_string(), + Err(_) => { + paths.retain(|path| { + let joinable = std::env::join_paths(std::iter::once(path)).is_ok(); + if !joinable { + log::warn!("Dropping un-joinable PATH entry: {}", path.display()); + } + joinable + }); + std::env::join_paths(paths) + .unwrap_or_default() + .to_string_lossy() + .to_string() + } + } } /// Build a deterministic environment snapshot with PATH normalized through @@ -894,6 +915,22 @@ mod tests { assert!(paths.iter().any(|p| p.ends_with(".asdf/shims"))); } + #[test] + #[cfg(unix)] + fn join_paths_best_effort_drops_unjoinable_entries_instead_of_emptying_path() { + let joined = join_paths_best_effort(vec![ + PathBuf::from("/weird:dir/bin"), + PathBuf::from("/shell/bin"), + PathBuf::from("/user/bin"), + ]); + let paths: Vec<_> = std::env::split_paths(&joined).collect(); + + assert_eq!( + paths, + vec![PathBuf::from("/shell/bin"), PathBuf::from("/user/bin")] + ); + } + #[test] fn env_vars_with_extended_path_sanitizes_and_normalizes_path() { let env = HashMap::from([ From f5c6bba97a0163ff7cd96463fb07fe70cb4a0663 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Sat, 11 Jul 2026 14:43:28 +1000 Subject: [PATCH 5/9] fix(staged): re-stage ACP tools after a lock revert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The freshness stamp lived in the per-version tool_dir while the staged output $bin_dir/$binary is shared across versions, so a lock bump followed by a revert (or a branch switch) left the old version's stamp self-consistent and skipped staging while the staged wrapper still pointed at the newer version — silently shipping a different version than the lockfile claims. Move the stamp next to the staged output ($bin_dir/$binary.stamp) so it describes exactly the artifact being freshness-checked; any lock change for that binary now forces a re-stage. The per-version tool_dir installs remain as the download cache. Also prune files in $bin_dir that no longer correspond to a locked binary (or its stamp), so tools removed from the lock don't linger on the Goose search path. Verified with a scratch cache: fresh stage, no-op re-run, staging the pre-bump 0.56.0 lock (only claude re-staged), then reverting to the current lock correctly re-stages 0.57.0 — the previously broken case — and the pruning removes stray staged files while keeping locked ones. Ports squareup/berd@1db993fb (npm loop only; Staged's lock has no github-release staging loop). Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/scripts/ensure-acp-tools.sh | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/staged/scripts/ensure-acp-tools.sh b/apps/staged/scripts/ensure-acp-tools.sh index 77c474247..eb8ff7619 100755 --- a/apps/staged/scripts/ensure-acp-tools.sh +++ b/apps/staged/scripts/ensure-acp-tools.sh @@ -317,7 +317,10 @@ for (const entry of entries) { entrypoint="$package_dir/dist/index.js" native_binary="$install_dir/node_modules/$native_package/$native_executable" staged_bin="$bin_dir/$binary" - stamp="$tool_dir/stamp.env" + # 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" @@ -373,6 +376,19 @@ for (const entry of entries) { } > "$stamp" done +# bin_dir is on the Goose search path, 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 From 288fa7eb7dfa54a22124bf482a526cfcf265ae7d Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Sat, 11 Jul 2026 14:45:14 +1000 Subject: [PATCH 6/9] refactor(staged): share ACP node wrapper generation write_node_wrapper was duplicated (near-)verbatim in ensure-acp-tools.sh and prepare-acp-tools-resource.sh, so a future edit to one copy could make the dev-cache wrapper and the bundled resource wrapper drift. Extract it into scripts/lib/acp-node-wrapper.sh and source it from both scripts with matching shellcheck source= hints. No behavior change; the absolute-vs-relative entrypoint handling stays inside the function (ensure passes the cache-absolute entrypoint, prepare the resource-relative one). Verified by re-running both staging flows: the dev-cache wrapper (absolute entrypoint) and the bundled resource wrapper (relative entrypoint) are generated as before and report bridge versions 0.57.0 (claude) and 1.1.0 (codex). Ports squareup/berd@24d7518b. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/scripts/ensure-acp-tools.sh | 38 ++------------- apps/staged/scripts/lib/acp-node-wrapper.sh | 47 +++++++++++++++++++ .../scripts/prepare-acp-tools-resource.sh | 34 ++------------ 3 files changed, 53 insertions(+), 66 deletions(-) create mode 100644 apps/staged/scripts/lib/acp-node-wrapper.sh diff --git a/apps/staged/scripts/ensure-acp-tools.sh b/apps/staged/scripts/ensure-acp-tools.sh index eb8ff7619..ccdfbced6 100755 --- a/apps/staged/scripts/ensure-acp-tools.sh +++ b/apps/staged/scripts/ensure-acp-tools.sh @@ -5,6 +5,9 @@ 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=scripts/lib/acp-node-wrapper.sh +source "$script_dir/lib/acp-node-wrapper.sh" + usage() { cat <<'USAGE' Usage: scripts/ensure-acp-tools.sh [--target ] [--print-bin-dir] @@ -137,41 +140,6 @@ if [[ "$entry_count" == "0" ]]; then exit 0 fi -write_node_wrapper() { - local wrapper="$1" - local entrypoint="$2" - local node_engine="${3:->=22}" - local required_node_major - required_node_major="$(printf '%s\n' "$node_engine" | sed -n 's/^>=\([0-9][0-9]*\).*$/\1/p')" - if [[ -z "$required_node_major" ]]; then - required_node_major=22 - fi - - 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" -} - validate_npm_install() { local install_dir="$1" local package="$2" diff --git a/apps/staged/scripts/lib/acp-node-wrapper.sh b/apps/staged/scripts/lib/acp-node-wrapper.sh new file mode 100644 index 000000000..02ae8861b --- /dev/null +++ b/apps/staged/scripts/lib/acp-node-wrapper.sh @@ -0,0 +1,47 @@ +# 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). +# +# 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="$(printf '%s\n' "$node_engine" | sed -n 's/^>=\([0-9][0-9]*\).*$/\1/p')" + if [[ -z "$required_node_major" ]]; then + required_node_major=22 + fi + + 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/apps/staged/scripts/prepare-acp-tools-resource.sh b/apps/staged/scripts/prepare-acp-tools-resource.sh index 83d05f64c..8fe98409b 100755 --- a/apps/staged/scripts/prepare-acp-tools-resource.sh +++ b/apps/staged/scripts/prepare-acp-tools-resource.sh @@ -5,6 +5,9 @@ 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=scripts/lib/acp-node-wrapper.sh +source "$script_dir/lib/acp-node-wrapper.sh" + usage() { cat <<'USAGE' Usage: scripts/prepare-acp-tools-resource.sh [target-triple] @@ -48,37 +51,6 @@ find "$resource_bin_dir" -type f ! -name ".gitkeep" -delete rm -rf "$resource_node_dir" mkdir -p "$resource_node_dir" -write_node_wrapper() { - local wrapper="$1" - local entrypoint="$2" - local node_engine="${3:->=22}" - local required_node_major - required_node_major="$(printf '%s\n' "$node_engine" | sed -n 's/^>=\([0-9][0-9]*\).*$/\1/p')" - if [[ -z "$required_node_major" ]]; then - required_node_major=22 - fi - - 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' - printf 'wrapper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n' - printf 'entrypoint="$wrapper_dir"/%q\n' "$entrypoint" - printf 'exec node "$entrypoint" "$@"\n' - } > "$wrapper" - chmod +x "$wrapper" -} - codesign_if_darwin() { local file="$1" if [[ "$(uname -s)" == "Darwin" ]] && command -v codesign >/dev/null 2>&1; then From adea4017c443e7f8b581d85b096a15775d8af430 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Sat, 11 Jul 2026 14:51:44 +1000 Subject: [PATCH 7/9] feat(staged): surface bundled ACP Node.js requirement in doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled npm ACP bridges are bash shims that exec node, so a machine without a suitable Node runtime read as healthy everywhere and only failed at first session spawn with a bare exit 127 on stderr. prepare-acp-tools-resource.sh now stages a node-runtime.json manifest next to the bundled bin dir with one entry per npm-sourced bridge, each carrying its own required Node major derived from that tool's locked nodeEngine range — bridges may require different Node majors and are checked independently. The major parsing is extracted into the shared acp-node-wrapper.sh lib and reused by the wrapper shims, so the version the shim enforces at spawn time and the version the doctor reports cannot disagree. Locks with no npm tools ship no manifest. A new node-runtime doctor check (Tools section, fix_url pointing at nodejs.org) resolves the manifest as the bundled bin dir's parent, resolves node on the doctor PATH, probes process.versions.node, and compares the detected major against each tool's requirement: pass when all are satisfied, warn naming only the unmet bridges, warn listing all requirements when node is missing or unversionable. A missing manifest emits no check; an unreadable one warns instead of silently hiding the packaging break. The check runs in Staged's doctor command layer alongside the shared doctor crate report, over the same env snapshot. Verified with cargo test (9 new tests incl. an end-to-end manifest -> probe run), clippy, and staging runs showing the manifest written for the current lock and removed for an empty lock. Ports squareup/berd@07087303 to Staged's doctor command layer. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .gitignore | 1 + apps/staged/scripts/lib/acp-node-wrapper.sh | 22 +- .../scripts/prepare-acp-tools-resource.sh | 25 + apps/staged/src-tauri/src/acp_tools.rs | 22 + apps/staged/src-tauri/src/doctor.rs | 500 +++++++++++++++++- 5 files changed, 557 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 2721dbe56..89142c17b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ node_modules/ apps/staged/src-tauri/resources/acp/bin/* !apps/staged/src-tauri/resources/acp/bin/.gitkeep apps/staged/src-tauri/resources/acp/node/ +apps/staged/src-tauri/resources/acp/node-runtime.json diff --git a/apps/staged/scripts/lib/acp-node-wrapper.sh b/apps/staged/scripts/lib/acp-node-wrapper.sh index 02ae8861b..7d99839ee 100644 --- a/apps/staged/scripts/lib/acp-node-wrapper.sh +++ b/apps/staged/scripts/lib/acp-node-wrapper.sh @@ -4,6 +4,23 @@ # 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 @@ -16,10 +33,7 @@ write_node_wrapper() { local entrypoint="$2" local node_engine="${3:->=22}" local required_node_major - required_node_major="$(printf '%s\n' "$node_engine" | sed -n 's/^>=\([0-9][0-9]*\).*$/\1/p')" - if [[ -z "$required_node_major" ]]; then - required_node_major=22 - fi + required_node_major="$(acp_required_node_major "$node_engine")" mkdir -p "$(dirname "$wrapper")" { diff --git a/apps/staged/scripts/prepare-acp-tools-resource.sh b/apps/staged/scripts/prepare-acp-tools-resource.sh index 8fe98409b..064f4c164 100755 --- a/apps/staged/scripts/prepare-acp-tools-resource.sh +++ b/apps/staged/scripts/prepare-acp-tools-resource.sh @@ -51,6 +51,14 @@ 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 @@ -75,6 +83,7 @@ while IFS=$'\t' read -r id binary package version node_engine; do 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")") while IFS= read -r native_binary; do [[ -n "$native_binary" ]] || continue codesign_if_darwin "$native_binary" @@ -93,4 +102,20 @@ for (const entry of data.tools ?? []) { 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/apps/staged/src-tauri/src/acp_tools.rs b/apps/staged/src-tauri/src/acp_tools.rs index 2e73344f7..35e05496e 100644 --- a/apps/staged/src-tauri/src/acp_tools.rs +++ b/apps/staged/src-tauri/src/acp_tools.rs @@ -20,6 +20,9 @@ pub const ACP_TOOLS_DIR_ENV: &str = "STAGED_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 `scripts/prepare-acp-tools-resource.sh` +/// next to the bundled bin dir. +const NODE_RUNTIME_MANIFEST_FILE: &str = "node-runtime.json"; /// Goose reads extra binary search dirs from this env var as a JSON array. const GOOSE_SEARCH_PATHS_ENV: &str = "GOOSE_SEARCH_PATHS"; @@ -36,6 +39,16 @@ pub fn resolve_bundled_acp_tools_dir(app_handle: &tauri::AppHandle) -> Option Option { + bin_dir + .parent() + .map(|dir| dir.join(NODE_RUNTIME_MANIFEST_FILE)) +} + fn bundled_acp_tools_dir_from_parts( env_override: Option<&OsStr>, resource_dir: Option<&Path>, @@ -140,6 +153,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()); + } + #[test] fn bundled_dir_is_prepended_before_shell_path() { let mut vars = vec![ diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs index c36355c8f..db78435a5 100644 --- a/apps/staged/src-tauri/src/doctor.rs +++ b/apps/staged/src-tauri/src/doctor.rs @@ -1,5 +1,9 @@ //! Tauri command wrappers for the doctor health-check system. +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + pub use doctor::types::{AuthStatus, InstallSource}; pub use doctor::{ AgentVersionInfo, CheckStatus, DoctorCheck, DoctorReport, ExecuteFixOptions, FixType, @@ -11,13 +15,13 @@ pub use doctor::{ /// the agent spawn path uses — a bridge Staged bundles must never be /// reported missing (or prompt an install) just because the user has no /// global copy. -async fn doctor_env_vars(app_handle: &tauri::AppHandle) -> Vec<(String, String)> { +async fn doctor_env_vars(bundled_dir: Option<&Path>) -> Vec<(String, String)> { let mut env_vars = crate::shell_env::home_env_vars_with_extended_path( crate::session_runner::shell_env_cache().as_ref(), ) .await; - if let Some(dir) = crate::acp_tools::resolve_bundled_acp_tools_dir(app_handle) { - crate::acp_tools::apply_bundled_tools_env(&mut env_vars, &dir); + if let Some(dir) = bundled_dir { + crate::acp_tools::apply_bundled_tools_env(&mut env_vars, dir); } env_vars } @@ -54,8 +58,7 @@ fn execute_fix_options( /// [`run_doctor_freshness`] to fill in version/update information. #[tauri::command] pub async fn run_doctor(app_handle: tauri::AppHandle) -> DoctorReport { - let env_vars = doctor_env_vars(&app_handle).await; - doctor::run_checks_with_options(run_checks_options(false, env_vars)).await + run_doctor_report(&app_handle, false).await } /// Run all health checks with version freshness enabled. @@ -67,8 +70,22 @@ pub async fn run_doctor(app_handle: tauri::AppHandle) -> DoctorReport { /// each readout. Hits the network, so it must never block first paint. #[tauri::command] pub async fn run_doctor_freshness(app_handle: tauri::AppHandle) -> DoctorReport { - let env_vars = doctor_env_vars(&app_handle).await; - doctor::run_checks_with_options(run_checks_options(true, env_vars)).await + run_doctor_report(&app_handle, true).await +} + +/// Run the doctor crate's checks plus Staged-local ones (currently the +/// bundled ACP Node.js runtime check) over one shared env snapshot. +async fn run_doctor_report(app_handle: &tauri::AppHandle, check_freshness: bool) -> DoctorReport { + let bundled_dir = crate::acp_tools::resolve_bundled_acp_tools_dir(app_handle); + let env_vars = doctor_env_vars(bundled_dir.as_deref()).await; + let (mut report, node_runtime) = tokio::join!( + doctor::run_checks_with_options(run_checks_options(check_freshness, env_vars.clone())), + run_node_runtime_check(&env_vars, bundled_dir.as_deref()), + ); + if let Some(check) = node_runtime { + report.checks.push(check); + } + report } /// Run a fix for a doctor check, identified by check ID and fix type. @@ -81,7 +98,8 @@ pub async fn run_doctor_fix( check_id: String, fix_type: FixType, ) -> Result<(), String> { - let env_vars = doctor_env_vars(&app_handle).await; + let bundled_dir = crate::acp_tools::resolve_bundled_acp_tools_dir(&app_handle); + let env_vars = doctor_env_vars(bundled_dir.as_deref()).await; doctor::execute_fix_with_env_options(check_id, fix_type, execute_fix_options(None, env_vars)) .await } @@ -105,7 +123,8 @@ pub async fn run_doctor_update( fix_type: FixType, command: String, ) -> Result<(), String> { - let env_vars = doctor_env_vars(&app_handle).await; + let bundled_dir = crate::acp_tools::resolve_bundled_acp_tools_dir(&app_handle); + let env_vars = doctor_env_vars(bundled_dir.as_deref()).await; let expected = expected_update_command(&check_id, &fix_type, env_vars.clone()).await?; if expected != command { return Err(format!( @@ -151,3 +170,466 @@ async fn expected_update_command( .and_then(|r| r.update_command.clone()) .ok_or_else(|| format!("No actionable update available for {check_id}")) } + +// ============================================================================= +// Bundled ACP Node.js runtime check +// ============================================================================= + +const NODE_RUNTIME_CHECK_ID: &str = "node-runtime"; +const NODE_RUNTIME_CHECK_LABEL: &str = "Node.js Runtime"; +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`, written by +/// `scripts/prepare-acp-tools-resource.sh` while staging npm-sourced ACP +/// bridges. Each tool carries its own required Node major so bridges with +/// different engine ranges are checked independently. +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct NodeRuntimeManifest { + #[serde(default)] + tools: Vec, +} + +#[derive(Debug, serde::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)) + } +} + +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(crate::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 at setup +/// time instead of letting the first session spawn die with a bare exit 127 +/// (the bundled bridges are bash shims that exec node). Returns `None` when +/// no npm-sourced bridges are bundled; an unreadable manifest warns instead +/// of silently hiding a packaging break. +async fn run_node_runtime_check( + env_vars: &[(String, String)], + bundled_bin_dir: Option<&Path>, +) -> Option { + let (manifest_path, manifest) = match load_node_runtime_manifest(bundled_bin_dir) { + NodeRuntimeManifestState::Missing => return None, + NodeRuntimeManifestState::Invalid { path, error } => { + return Some(node_runtime_doctor_check( + CheckStatus::Warn, + "Bundled ACP bridge Node.js manifest is unreadable; bridge runtime requirements cannot be verified".to_string(), + Some(path.display().to_string()), + Some(format!("error: {error}")), + )); + } + NodeRuntimeManifestState::Loaded { path, manifest } => (path, manifest), + }; + if manifest.tools.is_empty() { + return None; + } + + // Resolve node from the same PATH shape every other doctor check and the + // agent spawn path use, so this check cannot disagree with what the + // bundled wrapper shims will find at spawn time. + let path_value = env_vars + .iter() + .find(|(key, _)| key == "PATH") + .map(|(_, value)| value.as_str()) + .unwrap_or_default(); + let node_path = doctor::resolve::resolve_executable_from_path("node", path_value) + .map(|path| path.to_string_lossy().to_string()); + 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, + )) +} + +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, +) -> DoctorCheck { + 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() { + ( + CheckStatus::Warn, + format!( + "Node.js was not found on PATH; bundled ACP bridges require it ({})", + node_requirement_summary(tools) + ), + ) + } else if node_major.is_none() { + ( + CheckStatus::Warn, + format!( + "Could not determine the Node.js version; bundled ACP bridges require it ({})", + node_requirement_summary(tools) + ), + ) + } else if unmet.is_empty() { + ( + CheckStatus::Pass, + format!( + "Node.js {} satisfies the bundled ACP bridge requirements", + node_version.as_deref().unwrap_or("unknown") + ), + ) + } else { + ( + CheckStatus::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 mut detail = vec![ + "checked: bundled ACP bridge Node.js runtime requirement".to_string(), + format!("manifest: {}", manifest_path.display()), + format!( + "node: {}", + node_path.as_deref().unwrap_or("not found on PATH") + ), + format!("version: {}", node_version.as_deref().unwrap_or("unknown")), + "requirements:".to_string(), + ]; + for tool in tools { + let verdict = match node_major { + Some(major) if major >= tool.required_node_major => "satisfied", + Some(_) => "unmet", + None => "unknown", + }; + detail.push(format!( + "- {}: requires Node.js {} [{verdict}]", + tool.binary, + tool.requirement_label() + )); + } + + node_runtime_doctor_check(status, message, node_path, Some(detail.join("\n"))) +} + +fn node_runtime_doctor_check( + status: CheckStatus, + message: String, + path: Option, + raw_output: Option, +) -> DoctorCheck { + DoctorCheck { + id: NODE_RUNTIME_CHECK_ID.to_string(), + label: NODE_RUNTIME_CHECK_LABEL.to_string(), + status, + message, + // Only rendered by the frontend for non-pass statuses. + fix_url: Some(NODE_RUNTIME_FIX_URL.to_string()), + fix_command: None, + fix_type: None, + path, + bridge_path: None, + raw_output, + auth_status: None, + installed_version: None, + latest_version: None, + update_available: None, + install_source: None, + self_updating: None, + main: None, + bridge: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node_tool(binary: &str, engine: &str, major: u32) -> NodeRuntimeTool { + NodeRuntimeTool { + binary: binary.to_string(), + node_engine: Some(engine.to_string()), + required_node_major: major, + } + } + + #[test] + fn node_runtime_check_passes_when_all_bridges_are_satisfied() { + let tools = [ + node_tool("claude-agent-acp", ">=22", 22), + node_tool("codex-acp", ">=20", 20), + ]; + + let check = build_node_runtime_check( + Path::new("/resources/acp/node-runtime.json"), + &tools, + Some("/usr/local/bin/node".to_string()), + Some("22.17.0".to_string()), + ); + + assert_eq!(check.status, CheckStatus::Pass); + assert_eq!( + check.message, + "Node.js 22.17.0 satisfies the bundled ACP bridge requirements" + ); + assert_eq!(check.path.as_deref(), Some("/usr/local/bin/node")); + let output = check.raw_output.as_deref().expect("raw output"); + assert!(output.contains("manifest: /resources/acp/node-runtime.json")); + assert!(output.contains("- claude-agent-acp: requires Node.js >=22 [satisfied]")); + assert!(output.contains("- codex-acp: requires Node.js >=20 [satisfied]")); + } + + #[test] + fn node_runtime_check_warns_only_for_bridges_with_unmet_majors() { + // Bridges may require different Node majors; a Node 21 runtime + // satisfies codex (>=20) but not claude (>=22). + let tools = [ + node_tool("claude-agent-acp", ">=22", 22), + node_tool("codex-acp", ">=20", 20), + ]; + + let check = build_node_runtime_check( + Path::new("/resources/acp/node-runtime.json"), + &tools, + Some("/usr/local/bin/node".to_string()), + Some("21.7.3".to_string()), + ); + + assert_eq!(check.status, CheckStatus::Warn); + assert_eq!( + check.message, + "Node.js 21.7.3 is too old for bundled ACP bridges: claude-agent-acp needs Node.js >=22" + ); + assert!(!check.message.contains("codex-acp")); + let output = check.raw_output.as_deref().expect("raw output"); + assert!(output.contains("- claude-agent-acp: requires Node.js >=22 [unmet]")); + assert!(output.contains("- codex-acp: requires Node.js >=20 [satisfied]")); + } + + #[test] + fn node_runtime_check_warns_when_node_is_missing() { + let tools = [ + node_tool("claude-agent-acp", ">=22", 22), + node_tool("codex-acp", ">=20", 20), + ]; + + let check = build_node_runtime_check( + Path::new("/resources/acp/node-runtime.json"), + &tools, + None, + None, + ); + + assert_eq!(check.status, CheckStatus::Warn); + assert_eq!( + check.message, + "Node.js was not found on PATH; bundled ACP bridges require it (claude-agent-acp needs Node.js >=22, codex-acp needs Node.js >=20)" + ); + assert!(check.path.is_none()); + assert_eq!( + check.fix_url.as_deref(), + Some("https://nodejs.org/en/download") + ); + } + + #[test] + fn node_runtime_check_warns_when_version_is_unknown() { + let tools = [node_tool("claude-agent-acp", ">=22", 22)]; + + let check = build_node_runtime_check( + Path::new("/resources/acp/node-runtime.json"), + &tools, + Some("/usr/local/bin/node".to_string()), + None, + ); + + assert_eq!(check.status, CheckStatus::Warn); + assert!(check + .message + .starts_with("Could not determine the Node.js version")); + assert!(check + .message + .contains("claude-agent-acp needs Node.js >=22")); + } + + #[test] + fn node_runtime_manifest_loads_from_bundled_dir_parent() { + let dir = tempfile::tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + std::fs::create_dir(&bin_dir).unwrap(); + std::fs::write( + dir.path().join("node-runtime.json"), + r#"{"tools":[{"id":"claude-acp","binary":"claude-agent-acp","nodeEngine":">=22","requiredNodeMajor":22},{"id":"codex-acp","binary":"codex-acp","nodeEngine":">=20","requiredNodeMajor":20}]}"#, + ) + .unwrap(); + + let NodeRuntimeManifestState::Loaded { path, manifest } = + load_node_runtime_manifest(Some(&bin_dir)) + else { + panic!("expected manifest to load"); + }; + + assert_eq!(path, dir.path().join("node-runtime.json")); + assert_eq!(manifest.tools.len(), 2); + assert_eq!(manifest.tools[0].binary, "claude-agent-acp"); + assert_eq!(manifest.tools[0].required_node_major, 22); + assert_eq!(manifest.tools[1].required_node_major, 20); + } + + #[test] + fn node_runtime_manifest_missing_or_invalid() { + assert!(matches!( + load_node_runtime_manifest(None), + NodeRuntimeManifestState::Missing + )); + + let dir = tempfile::tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + std::fs::create_dir(&bin_dir).unwrap(); + assert!(matches!( + load_node_runtime_manifest(Some(&bin_dir)), + NodeRuntimeManifestState::Missing + )); + + std::fs::write(dir.path().join("node-runtime.json"), "not json").unwrap(); + assert!(matches!( + load_node_runtime_manifest(Some(&bin_dir)), + NodeRuntimeManifestState::Invalid { .. } + )); + } + + #[tokio::test] + async fn node_runtime_check_runs_end_to_end_from_manifest() { + let dir = tempfile::tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + std::fs::create_dir(&bin_dir).unwrap(); + std::fs::write( + dir.path().join("node-runtime.json"), + r#"{"tools":[{"id":"claude-acp","binary":"claude-agent-acp","nodeEngine":">=0","requiredNodeMajor":0}]}"#, + ) + .unwrap(); + let env_vars = vec![("PATH".to_string(), std::env::var("PATH").unwrap())]; + + let check = run_node_runtime_check(&env_vars, Some(&bin_dir)) + .await + .expect("check emitted for npm-bundled bridges"); + + assert_eq!(check.id, "node-runtime"); + // With a zero required major any resolvable Node passes; on a host + // without Node the check still surfaces as a warning instead of + // vanishing. + if check.path.is_some() { + assert_eq!(check.status, CheckStatus::Pass); + } else { + assert_eq!(check.status, CheckStatus::Warn); + } + + assert!(run_node_runtime_check(&env_vars, None).await.is_none()); + } + + #[test] + fn parse_node_major_handles_plain_and_prefixed_versions() { + assert_eq!(parse_node_major("22.17.0"), Some(22)); + assert_eq!(parse_node_major("v20.11.1\n"), Some(20)); + assert_eq!(parse_node_major("not-a-version"), None); + assert_eq!(parse_node_major(""), None); + } +} From 5124302ac67c249836f5313ffd1dbecd8211ca40 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Sat, 11 Jul 2026 14:54:43 +1000 Subject: [PATCH 8/9] fix(staged): ad-hoc sign all nested Mach-Os in staged ACP packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staging codesign filter only matched files named exactly claude or codex, so other Mach-O executables vendored by the npm packages — the codex package ships rg and a bundled zsh, and upstream can add more — were staged unsigned and killed by Gatekeeper when the bundled bridge spawned them. Replace the name filter with a scan of every file in the staged package, signing anything file(1) identifies as Mach-O. The scan is gated on Darwin so Linux staging does not pay the file(1) cost. Verified by re-running prepare-acp-tools-resource.sh: all four Mach-Os in the staged tree (claude SDK binary, codex CLI, and the previously missed rg and zsh) now carry valid ad-hoc signatures. Release signing needs no extra config: staged-release.yml signs via block/apple-codesign-action, which routes through the same internal codesign_helper service as berd's Buildkite plugin — it deep-scans the bundle and Developer-ID-signs every nested Mach-O before notarizing. Ports squareup/berd@737e33a5. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/scripts/prepare-acp-tools-resource.sh | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/staged/scripts/prepare-acp-tools-resource.sh b/apps/staged/scripts/prepare-acp-tools-resource.sh index 064f4c164..2250e4b88 100755 --- a/apps/staged/scripts/prepare-acp-tools-resource.sh +++ b/apps/staged/scripts/prepare-acp-tools-resource.sh @@ -84,10 +84,17 @@ 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")") - while IFS= read -r native_binary; do - [[ -n "$native_binary" ]] || continue - codesign_if_darwin "$native_binary" - done < <(find "$resource_package_dir" -type f \( -name claude -o -name codex \)) + # 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); From de6a9782862e2ad915aa4bc38007354b55a91a24 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 13 Jul 2026 11:54:19 +1000 Subject: [PATCH 9/9] chore(staged): bump bundled ACP tools to latest Run just bump-acp-tools to re-pin the bundled ACP bridge CLIs in acp-tools.lock.json against the latest npm releases: - claude-agent-acp 0.57.0 -> 0.58.1, pulling in @anthropic-ai/claude-agent-sdk 0.3.205 (Claude Code 2.1.205) and its per-target native packages - codex-acp 1.1.0 -> 1.1.2, pulling in @openai/codex 0.144.1 The bump surfaced a gap in update-acp-tools-lock.mjs: codex-acp 1.1.2 declares its @openai/codex dependency as a range (^0.144.0) rather than an exact pin, and npm view returns an array of per-version objects when a range matches multiple published versions, so the updater failed with "Missing @openai/codex@^0.144.0 version". Teach it to pick the highest matching version from multi-match results so ranged dependencies still pin the newest release (0.144.1 over 0.144.0 here). Verified the updated lock installs cleanly via ensure-acp-tools.sh (integrity hashes validated) and stages via prepare-acp-tools-resource.sh, with both staged bridges reporting the new versions. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/acp-tools.lock.json | 152 +++++++++--------- apps/staged/scripts/update-acp-tools-lock.mjs | 40 ++++- 2 files changed, 114 insertions(+), 78 deletions(-) diff --git a/apps/staged/acp-tools.lock.json b/apps/staged/acp-tools.lock.json index ee2d795e0..d2361a3b6 100644 --- a/apps/staged/acp-tools.lock.json +++ b/apps/staged/acp-tools.lock.json @@ -5,23 +5,23 @@ "binary": "claude-agent-acp", "source": "npm", "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.57.0", - "integrity": "sha512-CvfPwCFmMeOw3elh1UWKL09sU0z8z+qsmmiFND33jHvMo9hSI5gKC4tlXkp9oU+o6ZIMABXmRjHFicVYOu2RkQ==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.57.0.tgz", + "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.202", - "dependencyIntegrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz", - "claudeCodeVersion": "2.1.202", + "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.202", - "nativeIntegrity": "sha512-ujR3zDthDPkZs+AxW95iHpqLT5cuwGImsS3mVxLt1DlDij4qeTnihLX8+EpQTK+oNW9jjvFA86yKwa84fa1KYA==", - "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.202.tgz", + "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" }, { @@ -29,24 +29,24 @@ "binary": "claude-agent-acp", "source": "npm", "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.57.0", - "integrity": "sha512-CvfPwCFmMeOw3elh1UWKL09sU0z8z+qsmmiFND33jHvMo9hSI5gKC4tlXkp9oU+o6ZIMABXmRjHFicVYOu2RkQ==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.57.0.tgz", + "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.202", - "dependencyIntegrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz", - "claudeCodeVersion": "2.1.202", + "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.202", - "nativeIntegrity": "sha512-a4YtRkgGYt3ogePJDW8Ts6bNW690jb9LHyZaiWXsi+zT53xCNqJB2zKPyRc7hXWOqzIk4nCfwJpjmhLzMu3WIg==", - "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.202.tgz", + "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" }, { @@ -54,23 +54,23 @@ "binary": "claude-agent-acp", "source": "npm", "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.57.0", - "integrity": "sha512-CvfPwCFmMeOw3elh1UWKL09sU0z8z+qsmmiFND33jHvMo9hSI5gKC4tlXkp9oU+o6ZIMABXmRjHFicVYOu2RkQ==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.57.0.tgz", + "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.202", - "dependencyIntegrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz", - "claudeCodeVersion": "2.1.202", + "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.202", - "nativeIntegrity": "sha512-s/RVSGgkVmIMfyt1ndR8braLLu82bARoijmt1kk8d4IptUZ0Sc+zNUWKoFXwR9XqDBu6rBbBF9RIzD02raT57w==", - "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.202.tgz", + "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" }, { @@ -78,24 +78,24 @@ "binary": "claude-agent-acp", "source": "npm", "package": "@agentclientprotocol/claude-agent-acp", - "version": "0.57.0", - "integrity": "sha512-CvfPwCFmMeOw3elh1UWKL09sU0z8z+qsmmiFND33jHvMo9hSI5gKC4tlXkp9oU+o6ZIMABXmRjHFicVYOu2RkQ==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.57.0.tgz", + "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.202", - "dependencyIntegrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz", - "claudeCodeVersion": "2.1.202", + "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.202", - "nativeIntegrity": "sha512-XIvhdCWAAT4OdOA82fOJII+WH0Tf8pFckckEbJMMmOgQBKOnHT+609Pd3Ehw6zGcA9iFrhG5mY8Ncuckeo1aMw==", - "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.202.tgz", + "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" }, { @@ -103,22 +103,22 @@ "binary": "codex-acp", "source": "npm", "package": "@agentclientprotocol/codex-acp", - "version": "1.1.0", - "integrity": "sha512-EdUwW6n12yy4khxS6YpOgT8dd4JbVeOP8qPLdCEj795kp85qVI6369EWUXHq1CrnvOmGVwieUMvnrMlsqJaRWg==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.0.tgz", + "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.142.5", - "dependencyIntegrity": "sha512-WQEpD7l3k68eIAP0aq28EdR18ENBAf8DyprzFhzNwCOQJSv4nHzpwT8Fl30IJacprko2ZCmUBZjM2u941l2yLw==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5.tgz", + "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.142.5-darwin-arm64", - "nativeIntegrity": "sha512-l43p8xv+Z/2/b6fCUc7/FmcQZsaPB7RFizLponGwHAnFOWe3i9Vky69p+up3BUam9AetoQQUv7Mo+2KdaFEqhA==", - "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5-darwin-arm64.tgz", + "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" }, { @@ -126,23 +126,23 @@ "binary": "codex-acp", "source": "npm", "package": "@agentclientprotocol/codex-acp", - "version": "1.1.0", - "integrity": "sha512-EdUwW6n12yy4khxS6YpOgT8dd4JbVeOP8qPLdCEj795kp85qVI6369EWUXHq1CrnvOmGVwieUMvnrMlsqJaRWg==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.0.tgz", + "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.142.5", - "dependencyIntegrity": "sha512-WQEpD7l3k68eIAP0aq28EdR18ENBAf8DyprzFhzNwCOQJSv4nHzpwT8Fl30IJacprko2ZCmUBZjM2u941l2yLw==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5.tgz", + "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.142.5-linux-arm64", - "nativeIntegrity": "sha512-77ka5PSnm5HdxdBT99IwntCasmbqevlS0eiC0AtEb6ZXCLkim2gm0AWm+jNYy0EhbssvNK+KghayWo34HMgXeA==", - "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5-linux-arm64.tgz", + "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" }, { @@ -150,22 +150,22 @@ "binary": "codex-acp", "source": "npm", "package": "@agentclientprotocol/codex-acp", - "version": "1.1.0", - "integrity": "sha512-EdUwW6n12yy4khxS6YpOgT8dd4JbVeOP8qPLdCEj795kp85qVI6369EWUXHq1CrnvOmGVwieUMvnrMlsqJaRWg==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.0.tgz", + "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.142.5", - "dependencyIntegrity": "sha512-WQEpD7l3k68eIAP0aq28EdR18ENBAf8DyprzFhzNwCOQJSv4nHzpwT8Fl30IJacprko2ZCmUBZjM2u941l2yLw==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5.tgz", + "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.142.5-darwin-x64", - "nativeIntegrity": "sha512-yk6A06/VmW7NFsa48OVPaj//g/zeSpd79wjuqfXZwW8ZKRYQm3+wCd3hWjPl79F3QnXvDvM2j3JMIBL3m3GXXg==", - "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5-darwin-x64.tgz", + "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" }, { @@ -173,23 +173,23 @@ "binary": "codex-acp", "source": "npm", "package": "@agentclientprotocol/codex-acp", - "version": "1.1.0", - "integrity": "sha512-EdUwW6n12yy4khxS6YpOgT8dd4JbVeOP8qPLdCEj795kp85qVI6369EWUXHq1CrnvOmGVwieUMvnrMlsqJaRWg==", - "tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.0.tgz", + "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.142.5", - "dependencyIntegrity": "sha512-WQEpD7l3k68eIAP0aq28EdR18ENBAf8DyprzFhzNwCOQJSv4nHzpwT8Fl30IJacprko2ZCmUBZjM2u941l2yLw==", - "dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5.tgz", + "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.142.5-linux-x64", - "nativeIntegrity": "sha512-pxY+d3NgNE57Y/MApD3/TZUAygxJN6I9h3ZeDUwe67mxWjUxsuapxMRFTKSznCalYbRAeZp752+AAXmUbmguEg==", - "nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.142.5-linux-x64.tgz", + "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/apps/staged/scripts/update-acp-tools-lock.mjs b/apps/staged/scripts/update-acp-tools-lock.mjs index a1f514fc2..c17fa02bf 100755 --- a/apps/staged/scripts/update-acp-tools-lock.mjs +++ b/apps/staged/scripts/update-acp-tools-lock.mjs @@ -182,6 +182,36 @@ function packageDist(metadata, label) { }; } +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 }; @@ -243,9 +273,15 @@ async function lockToolForTarget(tool, target) { packageMetadata.dependencies?.[tool.dependencyPackage], `${packageName} dependency ${tool.dependencyPackage}`, ); - const dependencyMetadata = await npmView( + const dependencyMetadata = pickLatestMatch( + await npmView(`${tool.dependencyPackage}@${dependencyRange}`, [ + "name", + "version", + "dist", + "optionalDependencies", + "claudeCodeVersion", + ]), `${tool.dependencyPackage}@${dependencyRange}`, - ["name", "version", "dist", "optionalDependencies", "claudeCodeVersion"], ); const dependencyVersion = requireString( dependencyMetadata.version,