Files
buzz/desktop/scripts/prepare-acp-tools-resource.sh
Matt TooheyandClaude Fable 5 b2ce7a4a01 feat(desktop): bundle only the ACP bridges, not the harness CLIs
Switch the bundled ACP tooling from full-CLI bundling to bridge-only
bundling. The staging scripts now install the bridge JS trees with
`npm --omit=optional`, which skips the SDK/codex platform packages that
vendor the native claude/codex CLIs — the bundled resource drops from
~586MB to ~56MB of pure JS, and ad-hoc codesigning of vendored Mach-O
binaries is no longer needed.

The bridges instead run the user's own harness CLI: at spawn time the
desktop resolves `claude`/`codex` from PATH and exports
CLAUDE_CODE_EXECUTABLE / CODEX_PATH (neither bridge falls back to PATH
itself), driven by a new `bridge_cli_env_var` catalog field. A value
already present in the desktop's environment wins, and per-agent env
overrides still apply afterwards.

Because the app once again depends on a user-installed CLI, this
reinstates the machinery that 55a80e5c retired: the CliMissing
availability gate, PATH-based auth probes, curl CLI install commands,
and the Doctor's CLI-missing copy and CLI-path row. The Doctor's
"bundled" badge now reads an explicit `adapter_ships_with_app` catalog
field, since inferring it from empty install-command lists breaks once
claude/codex regain CLI install commands.

The lock drops the native*/npmOs/npmCpu/npmLibc fields (trees are
platform-independent, but stay per-target so pins can be bumped
independently), install validation asserts no platform package slipped
into the tree, and freshness stamps gain STAMP_INSTALL_MODE=bridge-only
so stale full-CLI caches are reinstalled rather than reused. The
harness-clis.json manifest and its resolution path are removed; the
prepare script deletes the stale file from previously staged resource
dirs.

Verified: desktop cargo tests (1421), buzz-acp tests, clippy + fmt on
both, tsc, biome, desktop unit tests (2791), doctor-states +
doctor-cta-screenshots Playwright specs, file-size/px guards, and
end-to-end staging on aarch64-apple-darwin (bridge wrappers report
0.58.1/1.1.2, no Mach-O in the tree, idempotent re-run).

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

159 lines
6.4 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 (bash
shims on Unix targets; the compiled buzz-acp-node-launcher plus
<binary>.shim.json manifests on Windows targets). The optional target triple
defaults to the Rust host target.
Note: resources/acp/bin holds a single target at a time, so staging must stay
tied to the build target.
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")")"
# Windows targets stage the compiled launcher shim instead of a bash
# wrapper. Resolved lazily at first use so a target with no locked tools
# stays a no-op stage; ensure-acp-tools.sh above already built the launcher
# for any target that has them, so resolution hits a warm target dir.
launcher_exe=""
resource_root="$app_root/src-tauri/resources/acp"
resource_bin_dir="$resource_root/bin"
resource_node_dir="$resource_root/node"
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=()
# Stale artifact of the retired full-CLI bundling: the harness CLI manifest
# pointed auth probes at CLIs vendored inside the bundles. Bridge-only
# bundles carry no CLIs, so remove any leftover manifest from an earlier
# stage — it is unread, but it would otherwise ride into the app bundle.
rm -f "$resource_root/harness-clis.json"
# Ad-hoc signing failure is a warning, not a hard stop: an unsignable Mach-O
# fragment that never executes should not sink the stage, and release builds
# re-sign everything with the real identity anyway. But it must be visible —
# a silently unsigned binary surfaces much later as Gatekeeper killing a
# subprocess mid-session, which is undiagnosable from build output.
codesign_if_darwin() {
local file="$1"
local output
if [[ "$(uname -s)" == "Darwin" ]] && command -v codesign >/dev/null 2>&1; then
if ! output="$(codesign --force --sign - "$file" 2>&1)"; then
echo "Warning: ad-hoc codesign failed for $file — Gatekeeper may kill it at spawn time:" >&2
echo "$output" >&2
fi
fi
}
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
if acp_target_is_windows "$target"; then
if [[ -z "$launcher_exe" ]]; then
launcher_exe="$(acp_node_launcher_exe "$target")"
fi
write_windows_node_launcher "$resource_bin_dir/$(acp_staged_binary_name "$binary" "$target")" "$launcher_exe" "../node/$id/node_modules/$package/dist/index.js" "$node_engine"
else
write_node_wrapper "$resource_bin_dir/$binary" "../node/$id/node_modules/$package/dist/index.js" "$node_engine"
fi
node_runtime_entries+=("$id"$'\t'"$binary"$'\t'"$node_engine"$'\t'"$(acp_required_node_major "$node_engine")")
# Ad-hoc sign every Mach-O in the staged package. Bridge-only trees are
# pure JS, so this scan normally finds nothing — kept because unsigned
# nested Mach-Os are killed by Gatekeeper, and a future dependency could
# reintroduce one. Darwin only, so Linux staging skips the file(1) scan.
if [[ "$(uname -s)" == "Darwin" ]]; then
while IFS= read -r -d '' candidate; do
if file -b "$candidate" | grep -q "Mach-O"; then
codesign_if_darwin "$candidate"
fi
done < <(find "$resource_package_dir" -type f -print0)
fi
done < <(node - "$lock_file" "$target" <<'NODE'
const fs = require("node:fs");
const [lockFile, target] = process.argv.slice(2);
const data = JSON.parse(fs.readFileSync(lockFile, "utf8"));
for (const entry of data.tools ?? []) {
if (entry.target !== target || typeof entry.binary !== "string") continue;
if (entry.source !== "npm") {
throw new Error(`Unsupported ACP tool source: ${entry.source}`);
}
console.log([entry.id, entry.binary, entry.package, entry.version, entry.nodeEngine ?? ">=22"].join("\t"));
}
NODE
)
# One manifest entry per npm-sourced bridge, each carrying its own required
# Node major, so bridges with different engine ranges surface distinct
# requirements in the doctor check.
if ((${#node_runtime_entries[@]} > 0)); then
node -e '
const fs = require("node:fs");
const [manifestFile, ...entries] = process.argv.slice(1);
const tools = entries.map((line) => {
const [id, binary, nodeEngine, requiredNodeMajor] = line.split("\t");
return { id, binary, nodeEngine, requiredNodeMajor: Number(requiredNodeMajor) };
});
fs.writeFileSync(manifestFile, `${JSON.stringify({ tools }, null, 2)}\n`);
' "$node_runtime_manifest" ${node_runtime_entries[@]+"${node_runtime_entries[@]}"}
echo "Wrote ACP Node runtime manifest: $node_runtime_manifest"
fi
echo "Staged ACP tools resource: $resource_bin_dir"