Files
buzz/desktop/scripts/prepare-acp-tools-resource.sh
Matt Toohey a4e592ed3a feat(desktop): redirect auth probes to bundled CLIs and retire the cli_missing gate
Since the ACP bundles vendor full native CLIs (Claude Code inside
@anthropic-ai/claude-agent-sdk-*, codex inside @openai/codex-*), agent
sessions never touch a user-installed claude/codex. Yet the auth probes
still resolved those CLIs from the user's PATH, and a missing user CLI
put the runtime behind a CliMissing availability gate — blocking agents
that would have run fine on the bundle. Point the probes at the vendored
CLIs and delete the gate plus everything downstream of it (curl-pipe CLI
install commands, Doctor's user-CLI path row, the cli_missing nudge card).

The vendored CLIs are deliberately NOT staged into resources/acp/bin:
that dir is the highest-priority segment of the agent-spawn PATH, and
CLIs there would shadow the user's (possibly newer) claude/codex inside
every session. Instead a probe-only manifest maps CLI names to paths
inside the staged node trees.

- prepare-acp-tools-resource.sh reads nativePackage/nativeExecutable
  from acp-tools.lock.json, verifies the vendored binary exists in the
  staged tree (chmod +x), and writes harness-clis.json next to
  node-runtime.json: {"clis":[{id,cli,path}]} with resource-root-relative
  paths. The manifest is gitignored like its sibling.
- acp_tools::bundled_harness_cli() resolves a CLI name through the
  manifest — bare names only, relative non-escaping paths only, must be
  an executable file. Absent manifest/entry degrades to None.
- discovery::resolve_probe_binary() tries the bundled CLI first, then
  the user's PATH — auth probes in the discovery sweep and
  readiness::cli_login_requirements both go through it. Auth state now
  reflects the CLI the sessions actually use, while dev builds without
  a staged bundle keep working via the PATH fallback.
- classify_runtime: a resolving adapter is Available unconditionally;
  underlying_cli now only disambiguates AdapterMissing vs NotInstalled.
  CliMissing is removed from the desktop enum (runtime-only, never
  persisted) and from the TS union; buzz-acp's wire mirror keeps the
  variant so payloads from older desktops still parse (AdapterOutdated
  precedent), while the FE nudge validator rejects the retired literal
  so stale JSON can't render a card the UI has no branch for.
- claude/codex catalog entries drop underlying_cli and the curl-pipe
  cli_install_commands; login provisioning copy stays as-is.
- UI: cli_missing branches removed from Doctor (StatusIcon, RuntimeRow,
  user-CLI path row), SetupStep, persona pickers ("(CLI missing)"
  suffix + sort rank), AgentDefinitionDialog warning, and the nudge
  card; retired the cli_missing nudge screenshot spec.

Verification:
- cargo test --lib (desktop/src-tauri): 1436 passed, 0 failed (new
  bundled_harness_cli manifest/escape/absent tests, probe-runs-without-
  underlying-CLI readiness test, adapter-implies-Available discovery test)
- cargo test -p buzz-acp --lib: 497 passed (cli_missing round-trip kept)
- cargo clippy --lib --tests -D warnings (both crates): clean;
  cargo fmt --check (both crates): clean
- tsc --noEmit: clean; biome check + file-size/px-text/pubkey guards: pass
- pnpm test (desktop): 2792 passed, 0 failed (new validator-rejects-
  cli_missing test)
- playwright doctor-states.spec.ts + doctor-cta-screenshots.spec.ts:
  12 passed (Doctor shows no CLI path row; nudge cards unaffected)
- prepare-acp-tools-resource.sh: manifest written; vendored
  claude --version → 2.1.205 (Claude Code); vendored
  codex login status → exit 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
2026-07-15 13:48:46 +10:00

177 lines
6.8 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
app_root="$(cd "$script_dir/.." && pwd)"
lock_file="${ACP_TOOLS_LOCK_FILE:-$app_root/acp-tools.lock.json}"
# shellcheck source=lib/acp-node-wrapper.sh
source "$script_dir/lib/acp-node-wrapper.sh"
usage() {
cat <<'USAGE'
Usage: desktop/scripts/prepare-acp-tools-resource.sh [target-triple]
Stages the locked ACP bridge tools into src-tauri/resources/acp so Tauri can
bundle them as application resources: vendored npm package trees under
resources/acp/node and executable wrappers under resources/acp/bin. The
optional target triple defaults to the Rust host target.
Note: resources/acp/bin holds a single target at a time, so staging must stay
tied to the build target.
USAGE
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
target="${1:-}"
ensure_args=()
if [[ -n "$target" ]]; then
ensure_args+=(--target "$target")
else
target="$(rustc -vV | sed -n 's|host: ||p')"
fi
if [[ -z "$target" ]]; then
echo "Could not determine rust host target." >&2
exit 1
fi
cache_bin_dir="$("$script_dir/ensure-acp-tools.sh" ${ensure_args[@]+"${ensure_args[@]}"} --print-bin-dir)"
cache_root="$(dirname "$(dirname "$cache_bin_dir")")"
resource_root="$app_root/src-tauri/resources/acp"
resource_bin_dir="$resource_root/bin"
resource_node_dir="$resource_root/node"
mkdir -p "$resource_bin_dir"
# Keep .gitkeep but refresh any staged tools from the lock.
find "$resource_bin_dir" -type f ! -name ".gitkeep" -delete
rm -rf "$resource_node_dir"
mkdir -p "$resource_node_dir"
# Manifest for the app's Node.js runtime doctor check, staged next to the
# bin dir so the app can resolve it as the bin dir's parent. Removed up
# front so locks with no npm-sourced tools ship no manifest and the doctor
# check stays silent.
node_runtime_manifest="$resource_root/node-runtime.json"
rm -f "$node_runtime_manifest"
node_runtime_entries=()
# Manifest of the native harness CLIs vendored inside the bundled bridges
# (e.g. `claude` inside the claude-agent-sdk native package, `codex` inside
# @openai/codex). The app resolves auth probes against these pinned binaries
# instead of user installs. Kept OUT of resources/acp/bin on purpose: that
# dir is the highest-priority segment of the agent-spawn PATH, and staging
# `claude`/`codex` there would shadow the user's CLIs inside every session.
harness_cli_manifest="$resource_root/harness-clis.json"
rm -f "$harness_cli_manifest"
harness_cli_entries=()
codesign_if_darwin() {
local file="$1"
if [[ "$(uname -s)" == "Darwin" ]] && command -v codesign >/dev/null 2>&1; then
codesign --force --sign - "$file" >/dev/null 2>&1 || true
fi
}
while IFS=$'\t' read -r id binary package version node_engine native_package native_executable; do
[[ -n "$id" ]] || continue
install_dir="$cache_root/$target/$id/$version/npm"
entrypoint="$install_dir/node_modules/$package/dist/index.js"
if [[ ! -f "$entrypoint" ]]; then
echo "Locked npm ACP tool missing from cache: $package@$version" >&2
exit 1
fi
resource_package_dir="$resource_node_dir/$id"
mkdir -p "$resource_package_dir"
cp -R "$install_dir/." "$resource_package_dir/"
resource_entrypoint="$resource_package_dir/node_modules/$package/dist/index.js"
if [[ ! -f "$resource_entrypoint" ]]; then
echo "Failed to stage npm ACP tool: $package@$version" >&2
exit 1
fi
write_node_wrapper "$resource_bin_dir/$binary" "../node/$id/node_modules/$package/dist/index.js" "$node_engine"
node_runtime_entries+=("$id"$'\t'"$binary"$'\t'"$node_engine"$'\t'"$(acp_required_node_major "$node_engine")")
# Record the vendored native harness CLI (relative to the acp resource
# root) for the auth-probe manifest. Fail loudly if the lock names one
# that is not in the staged tree — a silent miss would quietly send auth
# probes back to unpinned user installs.
if [[ -n "$native_package" && -n "$native_executable" ]]; then
cli_relpath="node/$id/node_modules/$native_package/$native_executable"
cli_abspath="$resource_root/$cli_relpath"
if [[ ! -f "$cli_abspath" ]]; then
echo "Locked native harness CLI missing from staged tree: $cli_relpath" >&2
exit 1
fi
chmod +x "$cli_abspath"
harness_cli_entries+=("$id"$'\t'"$(basename "$native_executable")"$'\t'"$cli_relpath")
fi
# Ad-hoc sign every Mach-O in the staged package, not just the main CLIs:
# the codex native package also vendors executables like rg and zsh, and
# unsigned nested Mach-Os are killed by Gatekeeper. Darwin only, so Linux
# staging skips the file(1) scan.
if [[ "$(uname -s)" == "Darwin" ]]; then
while IFS= read -r -d '' candidate; do
if file -b "$candidate" | grep -q "Mach-O"; then
codesign_if_darwin "$candidate"
fi
done < <(find "$resource_package_dir" -type f -print0)
fi
done < <(node - "$lock_file" "$target" <<'NODE'
const fs = require("node:fs");
const [lockFile, target] = process.argv.slice(2);
const data = JSON.parse(fs.readFileSync(lockFile, "utf8"));
for (const entry of data.tools ?? []) {
if (entry.target !== target || typeof entry.binary !== "string") continue;
if (entry.source !== "npm") {
throw new Error(`Unsupported ACP tool source: ${entry.source}`);
}
console.log([
entry.id,
entry.binary,
entry.package,
entry.version,
entry.nodeEngine ?? ">=22",
entry.nativePackage ?? "",
entry.nativeExecutable ?? "",
].join("\t"));
}
NODE
)
# One manifest entry per npm-sourced bridge, each carrying its own required
# Node major, so bridges with different engine ranges surface distinct
# requirements in the doctor check.
if ((${#node_runtime_entries[@]} > 0)); then
node -e '
const fs = require("node:fs");
const [manifestFile, ...entries] = process.argv.slice(1);
const tools = entries.map((line) => {
const [id, binary, nodeEngine, requiredNodeMajor] = line.split("\t");
return { id, binary, nodeEngine, requiredNodeMajor: Number(requiredNodeMajor) };
});
fs.writeFileSync(manifestFile, `${JSON.stringify({ tools }, null, 2)}\n`);
' "$node_runtime_manifest" ${node_runtime_entries[@]+"${node_runtime_entries[@]}"}
echo "Wrote ACP Node runtime manifest: $node_runtime_manifest"
fi
# One manifest entry per vendored native harness CLI, keyed by the bare CLI
# name the app's auth probes use (`claude`, `codex`). Paths are relative to
# the acp resource root (the bin dir's parent).
if ((${#harness_cli_entries[@]} > 0)); then
node -e '
const fs = require("node:fs");
const [manifestFile, ...entries] = process.argv.slice(1);
const clis = entries.map((line) => {
const [id, cli, path] = line.split("\t");
return { id, cli, path };
});
fs.writeFileSync(manifestFile, `${JSON.stringify({ clis }, null, 2)}\n`);
' "$harness_cli_manifest" ${harness_cli_entries[@]+"${harness_cli_entries[@]}"}
echo "Wrote ACP harness CLI manifest: $harness_cli_manifest"
fi
echo "Staged ACP tools resource: $resource_bin_dir"