mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): bundle the ACP bridge tools on Windows
The windows-x86_64 build was the one supported target left out of the
ACP bundling series: no lock entries, no staged resources, and — since
the series made the claude/codex catalog entries platform-unconditional
(empty install commands, "ships with the Buzz desktop app" hint) — a
Windows user without their own claude-agent-acp hit a dead end that
reinstalling could never fix. Everything upstream already exists (both
bridges publish win32-x64 native packages; the release job already runs
under Git Bash; the desktop's resolution/spawn layer is Windows-aware),
so extend the bundle to Windows. The one genuinely Windows-shaped
problem is the runtime shim: the staged bridges are bash wrappers that
Windows cannot execute — and the resolver only looks for <binary>.exe
anyway. Solve it with a tiny compiled launcher instead of .cmd shims,
which would have rippled a two-candidate list (.exe, .cmd) through the
shared resolution code and left an intermediate cmd.exe in the spawn
chain that kill_on_drop cannot reap through.
- New root-workspace crate buzz-acp-node-launcher: reads the sibling
<binary>.shim.json ({entrypoint, nodeEngine, requiredNodeMajor})
written by the staging scripts, resolves node from PATH, enforces the
lock's Node major with the exact wrapper-shim error message and exit
codes (127 missing / 1 too old), then runs node on the vendored
entrypoint, proxying stdio and the exit code. On Windows the child
joins a Job Object with KILL_ON_JOB_CLOSE (mirroring buzz-dev-mcp's
KillGroup) so terminating the launcher — as buzz-acp's kill_on_drop
does — takes the node tree with it; on Unix it execs node like the
bash shim, existing there so workspace clippy/tests pass everywhere.
Unsafe is confined to the Win32 FFI, cfg-forbidden elsewhere, per the
buzz-dev-mcp precedent.
- update-acp-tools-lock.mjs gains x86_64-pc-windows-msvc (npmOs win32,
npmCpu x64, no libc; native executables claude.exe and
vendor/x86_64-pc-windows-msvc/bin/codex.exe). The committed lock
grows 8 -> 10 entries via a partial --target run: the two new
Windows pins match the other targets' versions exactly (0.58.1 /
1.1.2, sdk 0.3.205, codex 0.144.1-win32-x64) and the existing 8
entries are preserved byte-identically.
- The staging scripts branch per target family through the shared
wrapper lib: Unix targets keep the bash wrapper; Windows targets
stage the launcher as <binary>.exe next to <binary>.shim.json
(write_windows_node_launcher). The launcher builds via cargo on
first use (ACP_NODE_LAUNCHER_EXE overrides; target dir resolved via
cargo metadata, never ./target), a target with no locked tools still
stages nothing without needing cargo, and dev-cache shims embed
bin-dir-relative entrypoints because Git Bash absolute paths
(/c/Users/...) are unresolvable to a native exe. The freshness path
re-copies the launcher when the built binary changes (cmp-gated: a
running agent's open .exe is never rewritten), the prune reduces
<binary>[.exe][.stamp] and <binary>.shim.json to the lock's bare
binary name, and harness-clis.json drops the .exe suffix from its
cli keys so the app's bare-name auth probes ("claude", "codex")
resolve the vendored CLIs on Windows too.
- release.yml's Windows job needs no extra wiring: the staging step
added with the previous commit now finds lock entries and builds the
launcher with the job's already-installed MSVC toolchain. The
windows-rust CI job gains a cargo test step for the launcher so its
spawn path gates on a real Windows runner.
Per the plan's risk note, codex-on-Windows maturity is a validate-
before-release concern: the lock's per-tool-per-target shape allows
dropping the codex-acp windows entry if a real session shakes out
badly. win32-arm64 packages exist but there is no arm64 Windows
release job; deferred until one exists. Node.js stays a user
prerequisite, surfaced by the existing node-runtime Doctor section.
Verification (macOS host):
- cargo test -p buzz-acp-node-launcher: 9 passed — manifest parsing,
version parsing, path resolution, plus end-to-end launcher runs
(arg/stdio/exit-code proxying via real node, missing-manifest,
missing-entrypoint, and too-old-Node failures with the wrapper-shim
message). cargo clippy --all-targets -D warnings and
cargo fmt --all --check: clean.
- Cross-staged the Windows target end-to-end with
ACP_NODE_LAUNCHER_EXE standing in for the MSVC launcher: both win32
npm trees install and validate against the lock (integrity + X_OK on
the vendored claude.exe/codex.exe, confirming the locked vendor
paths), bin dir stages <binary>.exe + .exe.stamp + .shim.json with
relative entrypoints, re-run performs zero installs, stray
.exe/.shim.json artifacts are pruned, and prepare writes
harness-clis.json with bare "claude"/"codex" keys mapping to the
vendored .exe paths.
- Empty-target Windows staging (aarch64-pc-windows-msvc) exits 0 with
the notice, without cargo on PATH.
- Darwin re-stage after the cross-stage: bash wrappers report 0.58.1 /
1.1.2, manifests back to darwin shape — Unix staging unregressed.
- biome check on update-acp-tools-lock.mjs: clean.
Windows-runner validation (NSIS install, Doctor states, live claude +
codex sessions, auth probes against the vendored CLIs) needs a real
Windows machine and rides the first release train with these entries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
056788026d
commit
64eae442e3
@@ -859,6 +859,12 @@ jobs:
|
||||
# Serial: windows_resolver_tests mutate process-global env
|
||||
# (BUZZ_SHELL/GIT_BASH/SystemRoot) that SharedState::new reads.
|
||||
run: cargo test -p buzz-dev-mcp --target $env:TARGET -- --test-threads=1
|
||||
- name: Test (buzz-acp-node-launcher)
|
||||
# The compiled launcher shim staged as the bundled ACP bridges'
|
||||
# <binary>.exe on Windows; its spawn path (Job Object, exit-code
|
||||
# proxying) only gates if tested ON Windows. The end-to-end tests use
|
||||
# the node preinstalled on windows-latest runners.
|
||||
run: cargo test -p buzz-acp-node-launcher --target $env:TARGET
|
||||
# Smoke-test the new host-prereq contract: Git for Windows (which provides
|
||||
# bash) is available on the runner, a shell command round-trips, and bash
|
||||
# does NOT resolve from System32 (so WSL's launcher is never picked up).
|
||||
|
||||
@@ -757,6 +757,9 @@ jobs:
|
||||
cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
|
||||
./scripts/bundle-sidecars.sh "$TARGET"
|
||||
|
||||
# Also builds the buzz-acp-node-launcher shim for the target with the
|
||||
# toolchain installed above (Windows targets stage it as the bridges'
|
||||
# <binary>.exe).
|
||||
- name: Stage bundled ACP tools
|
||||
shell: bash
|
||||
run: ./desktop/scripts/prepare-acp-tools-resource.sh "$TARGET"
|
||||
|
||||
Generated
+10
@@ -762,6 +762,16 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "buzz-acp-node-launcher"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "buzz-admin"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -10,6 +10,7 @@ members = [
|
||||
"crates/buzz-search",
|
||||
"crates/buzz-audit",
|
||||
"crates/buzz-acp",
|
||||
"crates/buzz-acp-node-launcher",
|
||||
"crates/buzz-agent",
|
||||
"crates/sprig",
|
||||
"crates/buzz-test-client",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "buzz-acp-node-launcher"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
description = "Compiled launcher shim for the bundled ACP bridge tools on Windows"
|
||||
|
||||
[[bin]]
|
||||
name = "buzz-acp-node-launcher"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_JobObjects"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,278 @@
|
||||
#![cfg_attr(not(windows), forbid(unsafe_code))]
|
||||
//! Compiled launcher shim for the bundled ACP bridge tools.
|
||||
//!
|
||||
//! Unix targets stage the bundled bridges (`claude-agent-acp`, `codex-acp`)
|
||||
//! as bash wrapper shims (`desktop/scripts/lib/acp-node-wrapper.sh`), which
|
||||
//! Windows cannot execute — and the desktop's command resolution only looks
|
||||
//! for `<binary>.exe` there anyway. Windows targets stage this launcher as
|
||||
//! `<binary>.exe` next to a `<binary>.shim.json` manifest instead. The
|
||||
//! launcher reproduces the wrapper contract exactly: verify a Node.js
|
||||
//! runtime satisfying the locked engine range is on PATH, then run node on
|
||||
//! the vendored entrypoint, forwarding arguments, stdio, and the exit code.
|
||||
//!
|
||||
//! On Windows the node child is additionally assigned to a Job Object with
|
||||
//! `KILL_ON_JOB_CLOSE`, so terminating the launcher (as the buzz-acp
|
||||
//! harness's `kill_on_drop` does when a session ends) takes the node process
|
||||
//! tree with it instead of orphaning it. On Unix the launcher execs node,
|
||||
//! matching the bash shim; it exists there only so the crate builds and
|
||||
//! tests on every workspace platform.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// On-disk shape of the `<binary>.shim.json` manifest written by
|
||||
/// `desktop/scripts/lib/acp-node-wrapper.sh` next to the staged launcher.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ShimManifest {
|
||||
/// JS entrypoint to run; a relative path resolves against the launcher's
|
||||
/// own directory, mirroring the bash wrapper.
|
||||
entrypoint: String,
|
||||
/// The lock's Node engine range (e.g. ">=22"), used verbatim in error
|
||||
/// messages so they match the bash wrapper's.
|
||||
node_engine: String,
|
||||
/// Minimum Node.js major version enforced before spawning.
|
||||
required_node_major: u32,
|
||||
}
|
||||
|
||||
/// The manifest sits next to the launcher under the same staged binary name:
|
||||
/// `claude-agent-acp.exe` reads `claude-agent-acp.shim.json`.
|
||||
fn shim_manifest_path(exe: &Path) -> PathBuf {
|
||||
exe.with_extension("shim.json")
|
||||
}
|
||||
|
||||
fn resolve_entrypoint(exe_dir: &Path, entrypoint: &str) -> PathBuf {
|
||||
let entrypoint = Path::new(entrypoint);
|
||||
if entrypoint.is_absolute() {
|
||||
entrypoint.to_path_buf()
|
||||
} else {
|
||||
exe_dir.join(entrypoint)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_node_major(version: &str) -> Option<u32> {
|
||||
version.trim().split('.').next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// `Ok(None)` means node ran but produced no parsable version; `Err` is a
|
||||
/// spawn failure (`NotFound` when node is not on PATH at all).
|
||||
fn node_major_version() -> std::io::Result<Option<u32>> {
|
||||
let output = Command::new("node")
|
||||
.args(["-p", "process.versions.node"])
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(parse_node_major(&String::from_utf8_lossy(&output.stdout)))
|
||||
}
|
||||
|
||||
fn fail(message: std::fmt::Arguments<'_>, code: i32) -> ! {
|
||||
eprintln!("{message}");
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
/// Run node on the entrypoint, forwarding stdio and the exit code. Diverges:
|
||||
/// on Unix this execs (the launcher *becomes* node, like the bash shim); on
|
||||
/// Windows it waits on a Job-Object-managed child.
|
||||
fn run_node(name: &str, entrypoint: &Path, args: std::env::ArgsOs) -> ! {
|
||||
let mut command = Command::new("node");
|
||||
command.arg(entrypoint).args(args.skip(1));
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
let error = command.exec();
|
||||
fail(format_args!("{name}: failed to exec node: {error}"), 1);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => fail(format_args!("{name}: failed to spawn node: {error}"), 1),
|
||||
};
|
||||
// Held for the launcher's lifetime: the OS closes the handle at
|
||||
// process exit, which is exactly when KILL_ON_JOB_CLOSE should fire.
|
||||
// After a normal wait() the child is already gone and the close is a
|
||||
// no-op; on TerminateProcess it reaps the whole node tree.
|
||||
let _job = job::KillOnCloseJob::assign(&child);
|
||||
match child.wait() {
|
||||
Ok(status) => std::process::exit(status.code().unwrap_or(1)),
|
||||
Err(error) => fail(format_args!("{name}: failed to wait on node: {error}"), 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let exe = match std::env::current_exe() {
|
||||
Ok(exe) => exe,
|
||||
Err(error) => fail(
|
||||
format_args!("acp-node-launcher: cannot determine own path: {error}"),
|
||||
1,
|
||||
),
|
||||
};
|
||||
let name = exe
|
||||
.file_stem()
|
||||
.map(|stem| stem.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "acp-node-launcher".to_string());
|
||||
|
||||
let manifest_path = shim_manifest_path(&exe);
|
||||
let manifest_raw = match std::fs::read_to_string(&manifest_path) {
|
||||
Ok(raw) => raw,
|
||||
Err(error) => fail(
|
||||
format_args!(
|
||||
"{name}: cannot read shim manifest {}: {error}",
|
||||
manifest_path.display()
|
||||
),
|
||||
1,
|
||||
),
|
||||
};
|
||||
let manifest: ShimManifest = match serde_json::from_str(&manifest_raw) {
|
||||
Ok(manifest) => manifest,
|
||||
Err(error) => fail(
|
||||
format_args!(
|
||||
"{name}: invalid shim manifest {}: {error}",
|
||||
manifest_path.display()
|
||||
),
|
||||
1,
|
||||
),
|
||||
};
|
||||
|
||||
let exe_dir = exe.parent().unwrap_or_else(|| Path::new("."));
|
||||
let entrypoint = resolve_entrypoint(exe_dir, &manifest.entrypoint);
|
||||
if !entrypoint.is_file() {
|
||||
fail(
|
||||
format_args!(
|
||||
"{name}: bundled entrypoint missing: {}",
|
||||
entrypoint.display()
|
||||
),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
// Same message and exit codes as the bash wrapper: 127 when node is not
|
||||
// on PATH at all, 1 when it is too old (or unidentifiable).
|
||||
match node_major_version() {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => fail(
|
||||
format_args!("{name} requires Node.js {} on PATH.", manifest.node_engine),
|
||||
127,
|
||||
),
|
||||
Ok(Some(major)) if major >= manifest.required_node_major => {}
|
||||
Ok(_) | Err(_) => fail(
|
||||
format_args!("{name} requires Node.js {} on PATH.", manifest.node_engine),
|
||||
1,
|
||||
),
|
||||
}
|
||||
|
||||
run_node(&name, &entrypoint, std::env::args_os());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod job {
|
||||
//! Job Object holding the node child, mirroring buzz-dev-mcp's
|
||||
//! `KillGroup` (crates/buzz-dev-mcp/src/shell.rs): `KILL_ON_JOB_CLOSE`
|
||||
//! kills every process still in the job when the last handle closes.
|
||||
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
|
||||
use windows_sys::Win32::Foundation::HANDLE;
|
||||
use windows_sys::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
};
|
||||
|
||||
pub struct KillOnCloseJob {
|
||||
_job: HANDLE,
|
||||
}
|
||||
|
||||
impl KillOnCloseJob {
|
||||
/// Best-effort: a creation or assignment failure leaves the child
|
||||
/// unmanaged (the outer harness Job Object still reaps it at app
|
||||
/// shutdown) rather than failing the launch.
|
||||
pub fn assign(child: &std::process::Child) -> Self {
|
||||
// SAFETY: each call is a documented Win32 FFI call with arguments
|
||||
// that satisfy its contract — a null SECURITY_ATTRIBUTES/name for
|
||||
// an anonymous job, a zeroed #[repr(C)] info struct sized by
|
||||
// size_of, and the live process handle owned by `child`. A null
|
||||
// job HANDLE on failure makes the later calls harmless no-ops.
|
||||
let job = unsafe {
|
||||
let job: HANDLE = CreateJobObjectW(std::ptr::null(), std::ptr::null());
|
||||
if !job.is_null() {
|
||||
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
SetInformationJobObject(
|
||||
job,
|
||||
JobObjectExtendedLimitInformation,
|
||||
std::ptr::addr_of!(info).cast(),
|
||||
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
);
|
||||
AssignProcessToJobObject(job, child.as_raw_handle() as HANDLE);
|
||||
}
|
||||
job
|
||||
};
|
||||
Self { _job: job }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_node_major, resolve_entrypoint, shim_manifest_path, ShimManifest};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn manifest_parses_the_staging_script_shape() {
|
||||
let manifest: ShimManifest = serde_json::from_str(
|
||||
r#"{
|
||||
"entrypoint": "../node/claude-acp/node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js",
|
||||
"nodeEngine": ">=22",
|
||||
"requiredNodeMajor": 22
|
||||
}"#,
|
||||
)
|
||||
.expect("parse manifest");
|
||||
assert!(manifest.entrypoint.ends_with("dist/index.js"));
|
||||
assert_eq!(manifest.node_engine, ">=22");
|
||||
assert_eq!(manifest.required_node_major, 22);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_missing_fields() {
|
||||
assert!(serde_json::from_str::<ShimManifest>(r#"{"entrypoint": "index.js"}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shim_manifest_sits_next_to_the_launcher_under_the_staged_name() {
|
||||
assert_eq!(
|
||||
shim_manifest_path(Path::new("/acp/bin/claude-agent-acp.exe")),
|
||||
Path::new("/acp/bin/claude-agent-acp.shim.json"),
|
||||
);
|
||||
// Unix-style staging without an extension gains the suffix whole.
|
||||
assert_eq!(
|
||||
shim_manifest_path(Path::new("/acp/bin/codex-acp")),
|
||||
Path::new("/acp/bin/codex-acp.shim.json"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_entrypoints_resolve_against_the_launcher_dir() {
|
||||
assert_eq!(
|
||||
resolve_entrypoint(Path::new("/acp/bin"), "../node/x/dist/index.js"),
|
||||
Path::new("/acp/bin/../node/x/dist/index.js"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_entrypoint(Path::new("/acp/bin"), "/abs/dist/index.js"),
|
||||
Path::new("/abs/dist/index.js"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_major_parses_plain_and_noisy_versions() {
|
||||
assert_eq!(parse_node_major("22.14.0\n"), Some(22));
|
||||
assert_eq!(parse_node_major("24"), Some(24));
|
||||
assert_eq!(parse_node_major(""), None);
|
||||
assert_eq!(parse_node_major("not-a-version"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! End-to-end tests for the launcher shim: copy the built binary under a
|
||||
//! bridge name next to a `<name>.shim.json` manifest — exactly how the
|
||||
//! staging scripts lay it out — and run it against the real node on PATH.
|
||||
//! Tests that need node skip (loudly) when it is absent, so the suite still
|
||||
//! passes on stripped-down environments.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn node_available() -> bool {
|
||||
Command::new("node")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Stage the launcher under `name` in `dir`, as the staging scripts do.
|
||||
fn stage_launcher(dir: &Path, name: &str) -> PathBuf {
|
||||
let staged = dir.join(format!("{name}{}", std::env::consts::EXE_SUFFIX));
|
||||
fs::copy(env!("CARGO_BIN_EXE_buzz-acp-node-launcher"), &staged).expect("stage launcher");
|
||||
staged
|
||||
}
|
||||
|
||||
fn write_shim_manifest(dir: &Path, name: &str, entrypoint: &str, required_node_major: u32) {
|
||||
fs::write(
|
||||
dir.join(format!("{name}.shim.json")),
|
||||
format!(
|
||||
r#"{{"entrypoint":{},"nodeEngine":">={required_node_major}","requiredNodeMajor":{required_node_major}}}"#,
|
||||
serde_json::to_string(entrypoint).expect("encode entrypoint"),
|
||||
),
|
||||
)
|
||||
.expect("write shim manifest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_entrypoint_forwarding_args_stdio_and_exit_code() {
|
||||
if !node_available() {
|
||||
eprintln!("skipping: node not on PATH");
|
||||
return;
|
||||
}
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
fs::write(
|
||||
temp.path().join("entry.js"),
|
||||
"console.log(['ok', ...process.argv.slice(2)].join(' '));\nprocess.exit(7);\n",
|
||||
)
|
||||
.expect("write entrypoint");
|
||||
// Relative entrypoint: must resolve against the launcher's directory.
|
||||
write_shim_manifest(temp.path(), "claude-agent-acp", "entry.js", 0);
|
||||
let staged = stage_launcher(temp.path(), "claude-agent-acp");
|
||||
|
||||
let output = Command::new(&staged)
|
||||
.args(["--flag", "value"])
|
||||
.output()
|
||||
.expect("run staged launcher");
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stdout).trim(),
|
||||
"ok --flag value"
|
||||
);
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_manifest_fails_loudly() {
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let staged = stage_launcher(temp.path(), "codex-acp");
|
||||
|
||||
let output = Command::new(&staged).output().expect("run staged launcher");
|
||||
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stderr).contains("shim manifest"),
|
||||
"stderr should name the missing manifest: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_entrypoint_fails_loudly() {
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
write_shim_manifest(temp.path(), "codex-acp", "no-such/dist/index.js", 0);
|
||||
let staged = stage_launcher(temp.path(), "codex-acp");
|
||||
|
||||
let output = Command::new(&staged).output().expect("run staged launcher");
|
||||
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stderr).contains("entrypoint missing"),
|
||||
"stderr should name the missing entrypoint: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_major_below_requirement_is_rejected() {
|
||||
if !node_available() {
|
||||
eprintln!("skipping: node not on PATH");
|
||||
return;
|
||||
}
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
fs::write(temp.path().join("entry.js"), "process.exit(0);\n").expect("write entrypoint");
|
||||
write_shim_manifest(temp.path(), "claude-agent-acp", "entry.js", 999);
|
||||
let staged = stage_launcher(temp.path(), "claude-agent-acp");
|
||||
|
||||
let output = Command::new(&staged).output().expect("run staged launcher");
|
||||
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stderr).contains("requires Node.js >=999 on PATH."),
|
||||
"stderr should carry the wrapper-shim message: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
@@ -73,6 +73,30 @@
|
||||
"nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.205.tgz",
|
||||
"nativeExecutable": "claude"
|
||||
},
|
||||
{
|
||||
"id": "claude-acp",
|
||||
"binary": "claude-agent-acp",
|
||||
"source": "npm",
|
||||
"package": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.58.1",
|
||||
"integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==",
|
||||
"tarball": "https://registry.npmjs.org/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz",
|
||||
"target": "x86_64-pc-windows-msvc",
|
||||
"npmOs": "win32",
|
||||
"npmCpu": "x64",
|
||||
"nodeEngine": ">=22",
|
||||
"dependencyPackage": "@anthropic-ai/claude-agent-sdk",
|
||||
"dependencyVersion": "0.3.205",
|
||||
"dependencyIntegrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==",
|
||||
"dependencyTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz",
|
||||
"claudeCodeVersion": "2.1.205",
|
||||
"nativePackage": "@anthropic-ai/claude-agent-sdk-win32-x64",
|
||||
"nativePackageName": "@anthropic-ai/claude-agent-sdk-win32-x64",
|
||||
"nativeVersion": "0.3.205",
|
||||
"nativeIntegrity": "sha512-kg2kkXyeSoFLruO3Ic2IruLxzBR0xCUtmlJHdWi3SYW7JhAKNJg4fcrdJsWcardmEw23Y2UDGDJbRyxqSVx6wg==",
|
||||
"nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.205.tgz",
|
||||
"nativeExecutable": "claude.exe"
|
||||
},
|
||||
{
|
||||
"id": "claude-acp",
|
||||
"binary": "claude-agent-acp",
|
||||
@@ -168,6 +192,29 @@
|
||||
"nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-darwin-x64.tgz",
|
||||
"nativeExecutable": "vendor/x86_64-apple-darwin/bin/codex"
|
||||
},
|
||||
{
|
||||
"id": "codex-acp",
|
||||
"binary": "codex-acp",
|
||||
"source": "npm",
|
||||
"package": "@agentclientprotocol/codex-acp",
|
||||
"version": "1.1.2",
|
||||
"integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==",
|
||||
"tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz",
|
||||
"target": "x86_64-pc-windows-msvc",
|
||||
"npmOs": "win32",
|
||||
"npmCpu": "x64",
|
||||
"nodeEngine": ">=22",
|
||||
"dependencyPackage": "@openai/codex",
|
||||
"dependencyVersion": "0.144.1",
|
||||
"dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==",
|
||||
"dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz",
|
||||
"nativePackage": "@openai/codex-win32-x64",
|
||||
"nativePackageName": "@openai/codex",
|
||||
"nativeVersion": "0.144.1-win32-x64",
|
||||
"nativeIntegrity": "sha512-qv2HOp6v/nVP31p5I5GxYyL0wa79PMzim1+W9CKSV0UldjFV9AMbualA8PeXcYhbvvh9Y1UASXxwjuQdlyfAvw==",
|
||||
"nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-win32-x64.tgz",
|
||||
"nativeExecutable": "vendor/x86_64-pc-windows-msvc/bin/codex.exe"
|
||||
},
|
||||
{
|
||||
"id": "codex-acp",
|
||||
"binary": "codex-acp",
|
||||
|
||||
@@ -16,7 +16,10 @@ Installs the ACP bridge tools pinned in acp-tools.lock.json into the shared
|
||||
Buzz dev cache. The lockfile is target-specific; only entries matching the
|
||||
requested target are prepared. Each tool is installed as a vendored npm
|
||||
package tree with a small executable wrapper, validated against the locked
|
||||
versions and integrity hashes.
|
||||
versions and integrity hashes. Unix targets get a bash wrapper shim; Windows
|
||||
targets get the compiled buzz-acp-node-launcher staged as <binary>.exe next
|
||||
to a <binary>.shim.json manifest (built with cargo, override with
|
||||
ACP_NODE_LAUNCHER_EXE).
|
||||
|
||||
Environment variables:
|
||||
ACP_TOOLS_LOCK_FILE lockfile path (default: desktop/acp-tools.lock.json)
|
||||
@@ -141,6 +144,15 @@ if [[ "$entry_count" == "0" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Windows targets stage the compiled launcher shim instead of a bash
|
||||
# wrapper; it needs the repo's Rust toolchain. Built once up front — the
|
||||
# per-tool loop below runs in a pipeline subshell.
|
||||
launcher_exe=""
|
||||
if acp_target_is_windows "$target"; then
|
||||
require_tool cargo
|
||||
launcher_exe="$(acp_node_launcher_exe "$target")"
|
||||
fi
|
||||
|
||||
validate_npm_install() {
|
||||
local install_dir="$1"
|
||||
local package="$2"
|
||||
@@ -285,7 +297,11 @@ for (const entry of entries) {
|
||||
package_dir="$install_dir/node_modules/$package"
|
||||
entrypoint="$package_dir/dist/index.js"
|
||||
native_binary="$install_dir/node_modules/$native_package/$native_executable"
|
||||
staged_bin="$bin_dir/$binary"
|
||||
staged_bin="$bin_dir/$(acp_staged_binary_name "$binary" "$target")"
|
||||
# Windows shims embed a bin-dir-relative entrypoint: under Git Bash the
|
||||
# absolute cache path is POSIX-style (/c/Users/...), which the native
|
||||
# launcher cannot resolve.
|
||||
entrypoint_from_bin_dir="../../$target/$id/$version/npm/node_modules/$package/dist/index.js"
|
||||
# The staged output is shared across lock versions, so its freshness stamp
|
||||
# must live next to it, not in the per-version tool_dir: a per-version stamp
|
||||
# stays self-consistent after a lock revert and would skip re-staging.
|
||||
@@ -294,6 +310,12 @@ for (const entry of entries) {
|
||||
# shellcheck disable=SC1090
|
||||
source "$stamp"
|
||||
if [[ "${STAMP_PACKAGE:-}" == "$package" && "${STAMP_VERSION:-}" == "$version" && "${STAMP_INTEGRITY:-}" == "$integrity" && "${STAMP_DEPENDENCY_PACKAGE:-}" == "$dependency_package" && "${STAMP_DEPENDENCY_VERSION:-}" == "$dependency_version" && "${STAMP_DEPENDENCY_INTEGRITY:-}" == "$dependency_integrity" && "${STAMP_NATIVE_PACKAGE:-}" == "$native_package" && "${STAMP_NATIVE_PACKAGE_NAME:-}" == "$native_package_name" && "${STAMP_NATIVE_VERSION:-}" == "$native_version" && "${STAMP_NATIVE_INTEGRITY:-}" == "$native_integrity" && "${STAMP_NATIVE_EXECUTABLE:-}" == "$native_executable" ]]; then
|
||||
# The npm tree is fresh, but the compiled launcher tracks the crate,
|
||||
# not the lock, so the stamp cannot see it change — refresh it every
|
||||
# run (the copy no-ops when already identical).
|
||||
if acp_target_is_windows "$target"; then
|
||||
write_windows_node_launcher "$staged_bin" "$launcher_exe" "$entrypoint_from_bin_dir" "$node_engine"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
@@ -319,7 +341,11 @@ for (const entry of entries) {
|
||||
npm "${npm_args[@]}" >&2
|
||||
|
||||
validate_npm_install "$install_dir" "$package" "$version" "$integrity" "$dependency_package" "$dependency_version" "$dependency_integrity" "$native_package" "$native_package_name" "$native_version" "$native_integrity" "$native_executable" "$claude_code_version"
|
||||
write_node_wrapper "$staged_bin" "$entrypoint" "$node_engine"
|
||||
if acp_target_is_windows "$target"; then
|
||||
write_windows_node_launcher "$staged_bin" "$launcher_exe" "$entrypoint_from_bin_dir" "$node_engine"
|
||||
else
|
||||
write_node_wrapper "$staged_bin" "$entrypoint" "$node_engine"
|
||||
fi
|
||||
{
|
||||
printf 'STAMP_TARGET=%q\n' "$target"
|
||||
printf 'STAMP_PACKAGE=%q\n' "$package"
|
||||
@@ -354,7 +380,12 @@ for (const entry of entries) console.log(entry.binary);
|
||||
' "$lock_entries" | sort -u)"
|
||||
find "$bin_dir" -type f -print0 | while IFS= read -r -d '' staged_file; do
|
||||
name="$(basename "$staged_file")"
|
||||
if ! printf '%s\n' "$locked_binaries" | grep -Fxq -- "${name%.stamp}"; then
|
||||
# Reduce every staged artifact shape to the lock's bare binary name:
|
||||
# <binary>[.exe][.stamp] and the Windows launcher's <binary>.shim.json.
|
||||
base="${name%.stamp}"
|
||||
base="${base%.shim.json}"
|
||||
base="${base%.exe}"
|
||||
if ! printf '%s\n' "$locked_binaries" | grep -Fxq -- "$base"; then
|
||||
rm -f -- "$staged_file"
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -4,6 +4,30 @@
|
||||
# resources cannot drift (a drift would make dev and bundled installs fail
|
||||
# differently on the same missing/old Node runtime).
|
||||
#
|
||||
# Unix targets stage a bash wrapper shim (write_node_wrapper); Windows
|
||||
# targets stage the compiled buzz-acp-node-launcher as `<binary>.exe` next
|
||||
# to a `<binary>.shim.json` manifest (write_windows_node_launcher) — Windows
|
||||
# cannot execute bash shims, and the desktop's command resolution only looks
|
||||
# for `<binary>.exe`. Both shims enforce the same lock-derived Node engine
|
||||
# requirement.
|
||||
#
|
||||
# acp_target_is_windows <target-triple>
|
||||
# Whether the Rust target triple names a Windows target.
|
||||
acp_target_is_windows() {
|
||||
[[ "$1" == *-windows-* ]]
|
||||
}
|
||||
|
||||
# acp_staged_binary_name <binary> <target-triple>
|
||||
# The filename a tool's shim is staged under for <target>: the lock's bare
|
||||
# binary name on Unix, `<binary>.exe` on Windows.
|
||||
acp_staged_binary_name() {
|
||||
if acp_target_is_windows "$2"; then
|
||||
printf '%s.exe\n' "$1"
|
||||
else
|
||||
printf '%s\n' "$1"
|
||||
fi
|
||||
}
|
||||
#
|
||||
# acp_required_node_major <node-engine>
|
||||
# Prints the minimum Node.js major version implied by a ">=N..." engine
|
||||
# range, defaulting to 22 when the range is not in that form. Shared by
|
||||
@@ -59,3 +83,66 @@ write_node_wrapper() {
|
||||
} > "$wrapper"
|
||||
chmod +x "$wrapper"
|
||||
}
|
||||
|
||||
# acp_node_launcher_exe <target-triple>
|
||||
# Prints the path to the compiled Windows launcher shim for <target>,
|
||||
# building it with cargo when needed (a no-op rebuild when up to date).
|
||||
# ACP_NODE_LAUNCHER_EXE overrides the build entirely — for callers that
|
||||
# already built the crate, and for cross-target staging tests on hosts
|
||||
# without the Windows toolchain.
|
||||
acp_node_launcher_exe() {
|
||||
local target="$1"
|
||||
if [[ -n "${ACP_NODE_LAUNCHER_EXE:-}" ]]; then
|
||||
printf '%s\n' "$ACP_NODE_LAUNCHER_EXE"
|
||||
return
|
||||
fi
|
||||
# The lib lives at desktop/scripts/lib; the launcher crate is in the repo
|
||||
# root workspace so the Windows release job's warm target dir is reused.
|
||||
local repo_root manifest target_dir
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
manifest="$repo_root/Cargo.toml"
|
||||
echo "Building ACP node launcher shim for $target..." >&2
|
||||
cargo build --release --manifest-path "$manifest" -p buzz-acp-node-launcher --target "$target" >&2
|
||||
# Resolve the target dir instead of assuming ./target — CARGO_TARGET_DIR
|
||||
# and .cargo/config redirects are common.
|
||||
target_dir="$(cargo metadata --format-version 1 --no-deps --manifest-path "$manifest" \
|
||||
| node -e 'process.stdout.write(JSON.parse(require("node:fs").readFileSync(0, "utf8")).target_directory)')"
|
||||
printf '%s/%s/release/buzz-acp-node-launcher.exe\n' "$target_dir" "$target"
|
||||
}
|
||||
|
||||
# write_windows_node_launcher <dest-exe> <launcher-exe> <entrypoint> [node-engine]
|
||||
# Stages the compiled launcher shim at <dest-exe> and writes the sibling
|
||||
# `<name>.shim.json` manifest the launcher reads at spawn time. Mirrors
|
||||
# write_node_wrapper's contract: a relative <entrypoint> resolves against
|
||||
# the launcher's directory at run time. Idempotent — the copy is skipped
|
||||
# when the staged launcher is already identical, so a re-stage never
|
||||
# rewrites an .exe a running agent may hold open.
|
||||
write_windows_node_launcher() {
|
||||
local dest_exe="$1"
|
||||
local launcher_exe="$2"
|
||||
local entrypoint="$3"
|
||||
local node_engine="${4:->=22}"
|
||||
local required_node_major
|
||||
required_node_major="$(acp_required_node_major "$node_engine")"
|
||||
|
||||
if [[ ! -f "$launcher_exe" ]]; then
|
||||
echo "ACP node launcher shim not found: $launcher_exe" >&2
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$(dirname "$dest_exe")"
|
||||
if ! cmp -s "$launcher_exe" "$dest_exe"; then
|
||||
cp -f "$launcher_exe" "$dest_exe"
|
||||
fi
|
||||
chmod +x "$dest_exe"
|
||||
ACP_SHIM_ENTRYPOINT="$entrypoint" \
|
||||
ACP_SHIM_NODE_ENGINE="$node_engine" \
|
||||
ACP_SHIM_REQUIRED_NODE_MAJOR="$required_node_major" \
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
fs.writeFileSync(process.argv[1], `${JSON.stringify({
|
||||
entrypoint: process.env.ACP_SHIM_ENTRYPOINT,
|
||||
nodeEngine: process.env.ACP_SHIM_NODE_ENGINE,
|
||||
requiredNodeMajor: Number(process.env.ACP_SHIM_REQUIRED_NODE_MAJOR),
|
||||
}, null, 2)}\n`);
|
||||
' "${dest_exe%.exe}.shim.json"
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@ Usage: desktop/scripts/prepare-acp-tools-resource.sh [target-triple]
|
||||
|
||||
Stages the locked ACP bridge tools into src-tauri/resources/acp so Tauri can
|
||||
bundle them as application resources: vendored npm package trees under
|
||||
resources/acp/node and executable wrappers under resources/acp/bin. The
|
||||
optional target triple defaults to the Rust host target.
|
||||
resources/acp/node and executable wrappers under resources/acp/bin (bash
|
||||
shims on Unix targets; the compiled buzz-acp-node-launcher plus
|
||||
<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.
|
||||
@@ -41,6 +43,12 @@ fi
|
||||
|
||||
cache_bin_dir="$("$script_dir/ensure-acp-tools.sh" ${ensure_args[@]+"${ensure_args[@]}"} --print-bin-dir)"
|
||||
cache_root="$(dirname "$(dirname "$cache_bin_dir")")"
|
||||
|
||||
# Windows targets stage the compiled launcher shim instead of a bash
|
||||
# wrapper. Resolved lazily at first use so a target with no locked tools
|
||||
# stays a no-op stage; ensure-acp-tools.sh above already built the launcher
|
||||
# for any target that has them, so resolution hits a warm target dir.
|
||||
launcher_exe=""
|
||||
resource_root="$app_root/src-tauri/resources/acp"
|
||||
resource_bin_dir="$resource_root/bin"
|
||||
resource_node_dir="$resource_root/node"
|
||||
@@ -101,7 +109,14 @@ while IFS=$'\t' read -r id binary package version node_engine native_package nat
|
||||
echo "Failed to stage npm ACP tool: $package@$version" >&2
|
||||
exit 1
|
||||
fi
|
||||
write_node_wrapper "$resource_bin_dir/$binary" "../node/$id/node_modules/$package/dist/index.js" "$node_engine"
|
||||
if acp_target_is_windows "$target"; then
|
||||
if [[ -z "$launcher_exe" ]]; then
|
||||
launcher_exe="$(acp_node_launcher_exe "$target")"
|
||||
fi
|
||||
write_windows_node_launcher "$resource_bin_dir/$(acp_staged_binary_name "$binary" "$target")" "$launcher_exe" "../node/$id/node_modules/$package/dist/index.js" "$node_engine"
|
||||
else
|
||||
write_node_wrapper "$resource_bin_dir/$binary" "../node/$id/node_modules/$package/dist/index.js" "$node_engine"
|
||||
fi
|
||||
node_runtime_entries+=("$id"$'\t'"$binary"$'\t'"$node_engine"$'\t'"$(acp_required_node_major "$node_engine")")
|
||||
# Record the vendored native harness CLI (relative to the acp resource
|
||||
# root) for the auth-probe manifest. Fail loudly if the lock names one
|
||||
@@ -115,7 +130,12 @@ while IFS=$'\t' read -r id binary package version node_engine native_package nat
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$cli_abspath"
|
||||
harness_cli_entries+=("$id"$'\t'"$(basename "$native_executable")"$'\t'"$cli_relpath")
|
||||
# The manifest keys CLIs by the bare probe name the app resolves
|
||||
# ("claude", "codex"), so the Windows vendored executables drop their
|
||||
# .exe suffix here.
|
||||
cli_name="$(basename "$native_executable")"
|
||||
cli_name="${cli_name%.exe}"
|
||||
harness_cli_entries+=("$id"$'\t'"$cli_name"$'\t'"$cli_relpath")
|
||||
fi
|
||||
# Ad-hoc sign every Mach-O in the staged package, not just the main CLIs:
|
||||
# the codex native package also vendors executables like rg and zsh, and
|
||||
|
||||
@@ -13,6 +13,7 @@ const SUPPORTED_TARGETS = [
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-pc-windows-msvc",
|
||||
];
|
||||
|
||||
// The Codex ACP executable stays `codex-acp`, but bundled installs must come
|
||||
@@ -89,6 +90,18 @@ const NPM_TARGET_CONFIG = {
|
||||
openaiCodex: "vendor/x86_64-unknown-linux-musl/bin/codex",
|
||||
},
|
||||
},
|
||||
"x86_64-pc-windows-msvc": {
|
||||
npmOs: "win32",
|
||||
npmCpu: "x64",
|
||||
nativePackages: {
|
||||
claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-win32-x64",
|
||||
openaiCodex: "@openai/codex-win32-x64",
|
||||
},
|
||||
nativeExecutables: {
|
||||
claudeAgentSdk: "claude.exe",
|
||||
openaiCodex: "vendor/x86_64-pc-windows-msvc/bin/codex.exe",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Tarball URLs in the lock are informational — ensure-acp-tools.sh installs
|
||||
|
||||
Reference in New Issue
Block a user