Files
buzz/desktop/scripts/update-acp-tools-lock.mjs
Matt Toohey a29a3da6fe feat(desktop): pin and stage bundled ACP bridge tools
Buzz desktop spawned the claude-agent-acp and codex-acp bridges from
whatever the user had installed globally — an unpinned `npm install -g`
surface with no integrity checking, which produced stale-bridge drift
(the deprecated @zed-industries/codex-acp 0.16.x gate) and
missing-tool failures. Bundle both bridges as app resources instead,
pinned per target:

- desktop/acp-tools.lock.json pins @agentclientprotocol/claude-agent-acp
  0.58.1 and @agentclientprotocol/codex-acp 1.1.2 (npm `latest` at time
  of commit) for the four supported targets, with integrity hashes and
  Block Artifactory tarballs for the package, its claude-agent-sdk /
  @openai/codex dependency, and the per-target native package.
- desktop/scripts/update-acp-tools-lock.mjs regenerates the lock from
  the registry's `latest` dist-tags, failing loudly on any unresolvable
  package — never silently pinning an older version. Ranged
  dependencies (codex-acp's ^0.144.0) resolve to the highest matching
  version when `npm view` returns an array.
- desktop/scripts/ensure-acp-tools.sh installs the locked tools into a
  shared dev cache (~/Library/Caches/buzz-dev/acp-tools), validates
  versions + integrity against the lock, and stamps staged binaries
  next to the shared bin dir so any lock change — including a revert —
  forces a re-stage; binaries no longer in the lock are pruned.
- desktop/scripts/prepare-acp-tools-resource.sh stages the vendored npm
  trees + node wrapper shims into desktop/src-tauri/resources/acp,
  writes the node-runtime.json manifest for the app's Node.js doctor
  check, and ad-hoc signs every nested Mach-O (file(1) scan — the codex
  package vendors rg, zsh, and codex-code-mode-host beyond the main
  CLIs) so Gatekeeper doesn't kill them in local builds.
- desktop/scripts/lib/acp-node-wrapper.sh is the single wrapper-shim
  generator shared by both scripts, so the dev-cache and bundled
  wrappers (and the Node major they enforce) cannot drift.
- Wiring: `just dev` / `just staging` stage the resources and export
  BUZZ_ACP_TOOLS_DIR; `just desktop-release-build` stages per target
  before `tauri build`; `just bump-acp-tools` reruns the lock updater;
  tauri.conf.json bundles resources/acp; staged artifacts are
  gitignored with a .gitkeep placeholder.

Runtime resolution of the staged dir follows in the next commit. The
sprout-releases internal pipeline will need the same staging step
before its `tauri build`; that lives in a separate repo.

Ports the build-time half of the Staged implementation in
block/builderbot#876 (branch commits 16b115a7 bundle feature, f5c6bba9
re-stage after lock revert, 288fa7eb shared node wrapper lib, adea4017
node-runtime manifest, 5124302a sign all nested Mach-Os, de6a9782
ranged-dependency updater fix), itself ported from squareup/berd
f07df1d2 + 1db993fb + 24d7518b + 07087303 + 737e33a5.

Verified: update-acp-tools-lock.mjs regenerated the lock against the
Block registry (byte-identical pins to the donor lock; both packages
confirmed at npm `latest`); fresh stage installs both tools and the
staged wrappers report 0.58.1 / 1.1.2; no-op re-run performs zero npm
installs; a stamp/lock mismatch forces a re-stage and stray binaries
are pruned; all five staged Mach-Os pass `codesign --verify`; cargo
check on desktop/src-tauri passes with the new resources entry.

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

364 lines
11 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { execFile } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { promisify } from "node:util";
const appRoot = path.resolve(import.meta.dirname, "..");
const defaultLockFile = path.join(appRoot, "acp-tools.lock.json");
const SUPPORTED_TARGETS = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
];
// The Codex ACP executable stays `codex-acp`, but bundled installs must come
// from the maintained Agent Client Protocol package rather than the stale
// Zed package.
const CODEX_ACP_PACKAGE = "@agentclientprotocol/codex-acp";
const TOOL_SPECS = [
{
id: "claude-acp",
binary: "claude-agent-acp",
package: "@agentclientprotocol/claude-agent-acp",
dependencyPackage: "@anthropic-ai/claude-agent-sdk",
nativePackageKey: "claudeAgentSdk",
includeClaudeCodeVersion: true,
},
{
id: "codex-acp",
binary: "codex-acp",
package: CODEX_ACP_PACKAGE,
dependencyPackage: "@openai/codex",
nativePackageKey: "openaiCodex",
},
];
const NPM_TARGET_CONFIG = {
"aarch64-apple-darwin": {
npmOs: "darwin",
npmCpu: "arm64",
nativePackages: {
claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-darwin-arm64",
openaiCodex: "@openai/codex-darwin-arm64",
},
nativeExecutables: {
claudeAgentSdk: "claude",
openaiCodex: "vendor/aarch64-apple-darwin/bin/codex",
},
},
"x86_64-apple-darwin": {
npmOs: "darwin",
npmCpu: "x64",
nativePackages: {
claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-darwin-x64",
openaiCodex: "@openai/codex-darwin-x64",
},
nativeExecutables: {
claudeAgentSdk: "claude",
openaiCodex: "vendor/x86_64-apple-darwin/bin/codex",
},
},
"aarch64-unknown-linux-gnu": {
npmOs: "linux",
npmCpu: "arm64",
npmLibc: "glibc",
nativePackages: {
claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-linux-arm64",
openaiCodex: "@openai/codex-linux-arm64",
},
nativeExecutables: {
claudeAgentSdk: "claude",
openaiCodex: "vendor/aarch64-unknown-linux-musl/bin/codex",
},
},
"x86_64-unknown-linux-gnu": {
npmOs: "linux",
npmCpu: "x64",
npmLibc: "glibc",
nativePackages: {
claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-linux-x64",
openaiCodex: "@openai/codex-linux-x64",
},
nativeExecutables: {
claudeAgentSdk: "claude",
openaiCodex: "vendor/x86_64-unknown-linux-musl/bin/codex",
},
},
};
const npmViewCache = new Map();
const execFileAsync = promisify(execFile);
function usage() {
console.log(`Usage: desktop/scripts/update-acp-tools-lock.mjs [--target <triple>]... [--lock-file <path>]
Queries npm for the latest release of each supported ACP bridge tool and
writes acp-tools.lock.json. Fails loudly when a package or one of its
per-target native dependencies cannot be resolved — never silently pins an
older version.
Supported targets:
${SUPPORTED_TARGETS.join("\n ")}
Environment:
npm registry config used to resolve packages
ACP_TOOLS_LOCK_FILE lockfile path override
`);
}
function parseArgs(argv) {
const targets = [];
let lockFile = process.env.ACP_TOOLS_LOCK_FILE ?? defaultLockFile;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "-h" || arg === "--help") {
usage();
process.exit(0);
}
if (arg === "--target") {
const value = argv[++i];
if (!value) throw new Error("--target requires a value");
targets.push(value);
continue;
}
if (arg === "--lock-file") {
const value = argv[++i];
if (!value) throw new Error("--lock-file requires a value");
lockFile = path.resolve(value);
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
const selectedTargets = targets.length ? targets : SUPPORTED_TARGETS;
for (const target of selectedTargets) {
if (!SUPPORTED_TARGETS.includes(target)) {
throw new Error(`Unsupported target '${target}'`);
}
}
return { targets: selectedTargets, lockFile };
}
async function npmView(spec, fields) {
const cacheKey = `${spec}\0${fields.join("\0")}`;
if (!npmViewCache.has(cacheKey)) {
npmViewCache.set(
cacheKey,
execFileAsync("npm", ["view", spec, ...fields, "--json"], {
maxBuffer: 10 * 1024 * 1024,
}).then(({ stdout }) => {
try {
return JSON.parse(stdout);
} catch (error) {
throw new Error(
`npm view ${spec} returned invalid JSON: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}),
);
}
return npmViewCache.get(cacheKey);
}
function requireString(value, label) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`Missing ${label}`);
}
return value;
}
function packageDist(metadata, label) {
const dist = metadata?.dist;
return {
tarball: requireString(dist?.tarball, `${label} dist.tarball`),
integrity: requireString(dist?.integrity, `${label} dist.integrity`),
};
}
function compareSemver(left, right) {
const leftCore = left.split("-", 1)[0].split(".").map(Number);
const rightCore = right.split("-", 1)[0].split(".").map(Number);
for (let i = 0; i < 3; i += 1) {
if ((leftCore[i] ?? 0) !== (rightCore[i] ?? 0)) {
return (leftCore[i] ?? 0) - (rightCore[i] ?? 0);
}
}
// A release outranks any prerelease of the same core version.
return (left.includes("-") ? 0 : 1) - (right.includes("-") ? 0 : 1);
}
// npm view returns a single object when a spec matches one version, but an
// array of per-version objects when a range matches several. Pick the highest
// matching version so a ranged dependency still pins the newest release.
function pickLatestMatch(metadata, label) {
if (!Array.isArray(metadata)) return metadata;
if (metadata.length === 0) {
throw new Error(`No versions match ${label}`);
}
return metadata.reduce((best, candidate) =>
compareSemver(
requireString(candidate?.version, `${label} version`),
requireString(best?.version, `${label} version`),
) > 0
? candidate
: best,
);
}
function parseNpmAliasSpec(spec, fallbackPackage) {
if (!spec.startsWith("npm:")) {
return { packageName: fallbackPackage, version: spec };
}
const aliased = spec.slice("npm:".length);
const versionSeparator = aliased.lastIndexOf("@");
if (versionSeparator <= 0) {
throw new Error(`Unsupported npm alias spec: ${spec}`);
}
return {
packageName: aliased.slice(0, versionSeparator),
version: aliased.slice(versionSeparator + 1),
};
}
async function lockToolForTarget(tool, target) {
const npmTarget = NPM_TARGET_CONFIG[target];
if (!npmTarget) {
throw new Error(`No npm target mapping for ${target}`);
}
const packageName = tool.package;
const packageMetadata = await npmView(`${packageName}@latest`, [
"name",
"version",
"dist",
"dependencies",
"engines",
"bin",
]);
if (packageMetadata.name !== packageName) {
throw new Error(
`npm package ${packageName} resolved to ${packageMetadata.name}`,
);
}
const version = requireString(
packageMetadata.version,
`${packageName} version`,
);
const packageInfo = packageDist(packageMetadata, `${packageName}@${version}`);
const entry = {
id: tool.id,
binary: tool.binary,
source: "npm",
package: packageName,
version,
integrity: packageInfo.integrity,
tarball: packageInfo.tarball,
target,
npmOs: npmTarget.npmOs,
npmCpu: npmTarget.npmCpu,
...(npmTarget.npmLibc ? { npmLibc: npmTarget.npmLibc } : {}),
nodeEngine: packageMetadata.engines?.node ?? ">=22",
};
const dependencyRange = requireString(
packageMetadata.dependencies?.[tool.dependencyPackage],
`${packageName} dependency ${tool.dependencyPackage}`,
);
const dependencyMetadata = pickLatestMatch(
await npmView(`${tool.dependencyPackage}@${dependencyRange}`, [
"name",
"version",
"dist",
"optionalDependencies",
"claudeCodeVersion",
]),
`${tool.dependencyPackage}@${dependencyRange}`,
);
const dependencyVersion = requireString(
dependencyMetadata.version,
`${tool.dependencyPackage}@${dependencyRange} version`,
);
const dependencyInfo = packageDist(
dependencyMetadata,
`${tool.dependencyPackage}@${dependencyVersion}`,
);
entry.dependencyPackage = tool.dependencyPackage;
entry.dependencyVersion = dependencyVersion;
entry.dependencyIntegrity = dependencyInfo.integrity;
entry.dependencyTarball = dependencyInfo.tarball;
if (tool.includeClaudeCodeVersion) {
entry.claudeCodeVersion = dependencyMetadata.claudeCodeVersion ?? null;
}
const nativePackage = requireString(
npmTarget.nativePackages?.[tool.nativePackageKey],
`${target} native package for ${tool.nativePackageKey}`,
);
const nativeExecutable = requireString(
npmTarget.nativeExecutables?.[tool.nativePackageKey],
`${target} native executable for ${tool.nativePackageKey}`,
);
const nativeSpec = requireString(
dependencyMetadata.optionalDependencies?.[nativePackage],
`${tool.dependencyPackage}@${dependencyVersion} optional dependency ${nativePackage}`,
);
const nativeAlias = parseNpmAliasSpec(nativeSpec, nativePackage);
const nativeMetadata = await npmView(
`${nativeAlias.packageName}@${nativeAlias.version}`,
["name", "version", "dist"],
);
const nativeVersion = requireString(
nativeMetadata.version,
`${nativeAlias.packageName}@${nativeAlias.version} version`,
);
if (nativeVersion !== nativeAlias.version) {
throw new Error(
`${nativeAlias.packageName}@${nativeAlias.version} resolved to ${nativeVersion}`,
);
}
const nativeInfo = packageDist(
nativeMetadata,
`${nativeAlias.packageName}@${nativeVersion}`,
);
return {
...entry,
nativePackage,
nativePackageName: nativeMetadata.name ?? nativePackage,
nativeVersion,
nativeIntegrity: nativeInfo.integrity,
nativeTarball: nativeInfo.tarball,
nativeExecutable,
};
}
async function main() {
const { targets, lockFile } = parseArgs(process.argv.slice(2));
const tools = [];
for (const tool of TOOL_SPECS) {
for (const target of targets) {
tools.push(await lockToolForTarget(tool, target));
}
}
tools.sort((left, right) =>
`${left.id}:${left.target}`.localeCompare(`${right.id}:${right.target}`),
);
await mkdir(path.dirname(lockFile), { recursive: true });
await writeFile(lockFile, `${JSON.stringify({ tools }, null, 2)}\n`);
console.log(`Updated ${path.relative(process.cwd(), lockFile)}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});