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>
This commit is contained in:
Matt Toohey
2026-07-15 13:56:25 +10:00
co-authored by Claude Fable 5
parent 64eae442e3
commit b2ce7a4a01
28 changed files with 414 additions and 654 deletions
-1
View File
@@ -43,7 +43,6 @@ desktop/src-tauri/resources/acp/bin/*
!desktop/src-tauri/resources/acp/bin/.gitkeep
desktop/src-tauri/resources/acp/node/
desktop/src-tauri/resources/acp/node-runtime.json
desktop/src-tauri/resources/acp/harness-clis.json
# sqlx offline query data (generated, not portable)
.sqlx/
-2
View File
@@ -67,8 +67,6 @@ pub(crate) enum AcpAvailabilityStatus {
/// still parse.
AdapterOutdated,
/// CLI binary missing; ACP adapter may be present.
/// Retired on current desktops (the bundled bridges vendor their own
/// CLI); kept so payloads from older app versions still parse.
CliMissing,
/// Neither adapter nor CLI found.
NotInstalled,
+10 -94
View File
@@ -9,20 +9,12 @@
"integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==",
"tarball": "https://registry.npmjs.org/@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.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-darwin-arm64",
"nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-arm64",
"nativeVersion": "0.3.205",
"nativeIntegrity": "sha512-lrfJ4eVtzfPkCpbSkBOGSMQCBbvmW6nbPzgHE4IwMN3scZlpuFMUFqh2aaJa/X2SAcWD9H2S0t2WWvSRgM7BjA==",
"nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.205.tgz",
"nativeExecutable": "claude"
"claudeCodeVersion": "2.1.205"
},
{
"id": "claude-acp",
@@ -33,21 +25,12 @@
"integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==",
"tarball": "https://registry.npmjs.org/@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.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-linux-arm64",
"nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-arm64",
"nativeVersion": "0.3.205",
"nativeIntegrity": "sha512-CXzySK3PV3EizCRPXnxPqeaAtgrBFDnMFOVpMe36oC3U16yDb1b1tAJGqZi/7uFrVvAiaXvnSFxhUWnDDSaO+A==",
"nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.205.tgz",
"nativeExecutable": "claude"
"claudeCodeVersion": "2.1.205"
},
{
"id": "claude-acp",
@@ -58,20 +41,12 @@
"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-apple-darwin",
"npmOs": "darwin",
"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-darwin-x64",
"nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-x64",
"nativeVersion": "0.3.205",
"nativeIntegrity": "sha512-G6ETPmL5mNzJ2DFsWxG3jmsmrXgZX1N2ZCJvxaGUUpjTsKZJ4Tup1cWYvcd/m7o5fYZmx9REmgzTwsAIc1fdPQ==",
"nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.205.tgz",
"nativeExecutable": "claude"
"claudeCodeVersion": "2.1.205"
},
{
"id": "claude-acp",
@@ -82,20 +57,12 @@
"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"
"claudeCodeVersion": "2.1.205"
},
{
"id": "claude-acp",
@@ -106,21 +73,12 @@
"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-unknown-linux-gnu",
"npmOs": "linux",
"npmCpu": "x64",
"npmLibc": "glibc",
"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-linux-x64",
"nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-x64",
"nativeVersion": "0.3.205",
"nativeIntegrity": "sha512-siS+1iNqBSlGFZZvJY6+mhzZ/6/ec/TbX9GMuwmTF0E6fxGhIIp797jJxR1q8r6FAq7d39mEoRNhC0Ffo60uNQ==",
"nativeTarball": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.205.tgz",
"nativeExecutable": "claude"
"claudeCodeVersion": "2.1.205"
},
{
"id": "codex-acp",
@@ -131,19 +89,11 @@
"integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==",
"tarball": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz",
"target": "aarch64-apple-darwin",
"npmOs": "darwin",
"npmCpu": "arm64",
"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-darwin-arm64",
"nativePackageName": "@openai/codex",
"nativeVersion": "0.144.1-darwin-arm64",
"nativeIntegrity": "sha512-dABeDK+ATqMG54MGBd3VjpKfh5EOoqx9PKVQB2QYDaEXx3F6CdUCXue5QIMfr4OxziUj8pUcLAQyd+KFqiTUFw==",
"nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-darwin-arm64.tgz",
"nativeExecutable": "vendor/aarch64-apple-darwin/bin/codex"
"dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz"
},
{
"id": "codex-acp",
@@ -154,20 +104,11 @@
"integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==",
"tarball": "https://registry.npmjs.org/@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.144.1",
"dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==",
"dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz",
"nativePackage": "@openai/codex-linux-arm64",
"nativePackageName": "@openai/codex",
"nativeVersion": "0.144.1-linux-arm64",
"nativeIntegrity": "sha512-451o15+XtaXCCb35t/KCyyPqXHnTPxPxtdqEYOnE3e4sH5AfnI/uVJwfdjOksMG6vRLy6R+fLvSDOMguRFLmQw==",
"nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-linux-arm64.tgz",
"nativeExecutable": "vendor/aarch64-unknown-linux-musl/bin/codex"
"dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz"
},
{
"id": "codex-acp",
@@ -178,19 +119,11 @@
"integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==",
"tarball": "https://registry.npmjs.org/@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.144.1",
"dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==",
"dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz",
"nativePackage": "@openai/codex-darwin-x64",
"nativePackageName": "@openai/codex",
"nativeVersion": "0.144.1-darwin-x64",
"nativeIntegrity": "sha512-K2g3Q3tNxzFhV0SuzO6HcsYK7EQrp/o4HyeReyhkwVrwwUPoYwyIbB0IRjHIiDzRhbKriDccid2iyF5aPqdTcg==",
"nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-darwin-x64.tgz",
"nativeExecutable": "vendor/x86_64-apple-darwin/bin/codex"
"dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz"
},
{
"id": "codex-acp",
@@ -201,19 +134,11 @@
"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"
"dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz"
},
{
"id": "codex-acp",
@@ -224,20 +149,11 @@
"integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==",
"tarball": "https://registry.npmjs.org/@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.144.1",
"dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==",
"dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz",
"nativePackage": "@openai/codex-linux-x64",
"nativePackageName": "@openai/codex",
"nativeVersion": "0.144.1-linux-x64",
"nativeIntegrity": "sha512-HNGVI+BulrOaC/0IzBvd6EL62j7LrlbFKibrhw6hZjjCjAeUYzRB2jB4qDzXN1NfqDi6Xrvniof3kwbwab24lg==",
"nativeTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1-linux-x64.tgz",
"nativeExecutable": "vendor/x86_64-unknown-linux-musl/bin/codex"
"dependencyTarball": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.1.tgz"
}
]
}
+26 -18
View File
@@ -125,7 +125,9 @@ const overrides = new Map([
// the availability_drift half of needs_restart deleted (the bundled bridge
// can't drift out-of-band); ratcheted to bank the deletions (main's
// team-instructions spawn-hash growth stays).
["src-tauri/src/managed_agents/runtime.rs", 2087],
// bridge-only bundling: spawn-time CLAUDE_CODE_EXECUTABLE/CODEX_PATH export
// pointing the bundled bridge at the user's CLI (+25 lines incl. rationale).
["src-tauri/src/managed_agents/runtime.rs", 2112],
// config-bridge setup-payload env-boundary fix adds readiness wiring in
// spawn_agent_child; load-bearing security fix, queued to split.
["src-tauri/src/managed_agents/config_bridge/reader.rs", 1016],
@@ -168,7 +170,9 @@ const overrides = new Map([
// bundle-acps: codex version-gate retirement removes the AdapterOutdated
// probe from cli_login_requirements and its gate tests, both obsolete now
// that the bridges ship bundled; ratcheting 1765 -> 1599 to bank the headroom.
["src-tauri/src/managed_agents/readiness.rs", 1599],
// bridge-only bundling: bridge_cli_env_var + adapter_ships_with_app fields
// in the make_cli_runtime stub (+2 lines).
["src-tauri/src/managed_agents/readiness.rs", 1601],
// applyWorkspace reposDir parameter plus the validateReposDir binding,
// threaded through Tauri invokes for configurable repos_dir, plus the
// harness-persona-sync `harnessOverride` create-input bit — load-bearing
@@ -231,11 +235,11 @@ const overrides = new Map([
// "adapter_outdated" availability retired with the codex version gate (-1 line).
// bundled-adapter-doctor-copy: adapterBundled field on
// AcpRuntimeCatalogEntry (+2 lines).
// bundled-cli-probes: "cli_missing" availability retired; +4 doc lines on
// AcpAvailabilityStatus explaining why the state no longer exists.
// acp-dead-machinery retirement: nodeRequired field deleted;
// ratcheted 1075 -> 1073.
["src/shared/api/types.ts", 1073],
// acp-dead-machinery retirement: nodeRequired field deleted.
// bridge-only bundling: the "cli_missing" availability literal returns —
// the bundled bridges run the user's claude/codex CLI again; ratcheted
// to bank the node_required-era headroom.
["src/shared/api/types.ts", 1069],
// readiness-gate: PersonaDialog.tsx threads computeLocalModeGate +
// requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog
// shows required markers and credential amber rows (parity with
@@ -299,13 +303,14 @@ const overrides = new Map([
// claude-code-acp-fallback-retirement: the legacy command moved from the
// resolution sweep to identity-only aliases; +5 comment lines documenting
// the commands/aliases split that move makes load-bearing.
// bundled-cli-probes: resolve_probe_binary (bundled-CLI-first probe
// resolution) + classify_runtime/underlying_cli doc rewrites for the
// CliMissing retirement (+8 lines).
// acp-dead-machinery retirement: adapter-availability cache +
// availability_drift, runtime_needs_npm/is_npm_global_install, and the
// node_required computation deleted; ratcheted 1267 -> 1166.
["src-tauri/src/managed_agents/discovery.rs", 1166],
// bridge-only bundling: CliMissing classification and user-CLI probe
// resolution return with the cli_missing gate; bridge_cli_env_var +
// adapter_ships_with_app catalog fields (docs + 4 initializers) and the
// restored curl/PowerShell CLI install commands (+15 lines).
["src-tauri/src/managed_agents/discovery.rs", 1181],
// rebase over codex-acp-package-swap: its version-probe tests union with the
// doctor-install-reliability nvm/login-shell/semver tests — each side alone
// stayed under the 1000 default; the union exceeds it.
@@ -320,11 +325,10 @@ const overrides = new Map([
// bundle-acps: version-gate retirement deletes the probe/availability test
// sections; ratcheting 1271 -> 1067 to bank the deletions (main's Windows
// Doctor test growth keeps this above the 1000 default).
// bundled-cli-probes: CliMissing test reworked to assert Available when the
// adapter resolves without an underlying CLI (+2 comment lines); main's
// Windows install-command tests inverted to assert the bundled runtimes
// expose no install commands (-13 lines).
["src-tauri/src/managed_agents/discovery/tests.rs", 1056],
// bridge-only bundling: classifies_cli_missing returns with the CliMissing
// gate + bridge_cli_env_vars catalog mapping test (+24 lines), and main's
// Windows install-command tests return with the restored CLI installers.
["src-tauri/src/managed_agents/discovery/tests.rs", 1093],
// identity-import-keyring: the identity resolution state machine's behavioral
// matrix (46 tests over FakeIdentityStore — probe × marker × file cells,
// adoption / read-back-corruption / marker-failure arms, recovery-mode
@@ -460,7 +464,9 @@ const overrides = new Map([
// is_safe_to_reveal allowlist + baked_env_thinking_effort_is_unmasked test.
// +1: doctor-install-reliability: login_hint: None added to goose_runtime test stub.
// +1: doctor-install-reliability review fixes: auth_probe_args: None added to stub.
["src-tauri/src/commands/agent_config.rs", 1021],
// +2: bridge-only bundling: bridge_cli_env_var + adapter_ships_with_app
// added to the runtime test stub (union with main's Windows stub field).
["src-tauri/src/commands/agent_config.rs", 1022],
// codex-install-auto-restart review-fixes: should_restart_after_install
// takes pid_alive:bool (pure predicate, no OS-dependent call); 3 racy
// cache tests replaced with 6 pure availability_drift predicate tests;
@@ -486,7 +492,9 @@ const overrides = new Map([
// groups, and the availability_drift tests deleted; ratcheted
// 1576 -> 1184 to bank the deletions (main's Windows install-shell
// machinery and tests stay).
["src-tauri/src/commands/agent_discovery.rs", 1184],
// bridge-only bundling: runtime_adapter_is_bundled reads the explicit
// adapter_ships_with_app catalog field (+2 doc lines).
["src-tauri/src/commands/agent_discovery.rs", 1187],
// draft-persistence predicate: submit-time `loadDraft` check + inline comment
// + deps-array entry in submitMessage closes the never-persisted-boundary
// defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to
+46 -79
View File
@@ -16,10 +16,13 @@ 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. 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).
versions and integrity hashes. Installs are bridge-only: --omit=optional
skips the SDK/codex platform packages that vendor native CLIs, so the trees
stay pure JS and the desktop app points each bridge at the user's own
claude/codex CLI at spawn time. 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)
@@ -111,18 +114,10 @@ for (const entry of entries) {
"version",
"integrity",
"tarball",
"npmOs",
"npmCpu",
"dependencyPackage",
"dependencyVersion",
"dependencyIntegrity",
"dependencyTarball",
"nativePackage",
"nativePackageName",
"nativeVersion",
"nativeIntegrity",
"nativeTarball",
"nativeExecutable",
]) {
requireString(entry, field);
}
@@ -161,14 +156,9 @@ validate_npm_install() {
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}"
local claude_code_version="$8"
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'
node - "$install_dir" "$package" "$version" "$integrity" "$dependency_package" "$dependency_version" "$dependency_integrity" "$claude_code_version" <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
@@ -180,11 +170,6 @@ const [
dependencyPackageName,
expectedDependencyVersion,
expectedDependencyIntegrity,
nativePackageName,
expectedNativePackageName,
expectedNativeVersion,
expectedNativeIntegrity,
nativeExecutable,
expectedClaudeCodeVersion,
] = process.argv.slice(2);
@@ -242,23 +227,25 @@ assertEqual(
`${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`,
// Bridge-only invariant: --omit=optional must have skipped the platform
// packages that vendor a native CLI. They install as scope siblings of the
// dependency package (`<dependencyPackage>-<platform>`), so any such
// directory means a native CLI slipped into the bundle.
const dependencySegments = dependencyPackageName.split("/");
const dependencyBasename = dependencySegments.at(-1);
const scopeDir = path.join(
installDir,
"node_modules",
...dependencySegments.slice(0, -1),
);
const vendoredPlatformPackages = fs
.readdirSync(scopeDir)
.filter((name) => name.startsWith(`${dependencyBasename}-`));
if (vendoredPlatformPackages.length > 0) {
throw new Error(
`bridge-only install unexpectedly vendored native CLI package(s): ${vendoredPlatformPackages.join(", ")}`,
);
}
NODE
}
@@ -272,31 +259,21 @@ for (const entry of entries) {
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
' "$lock_entries" | while IFS=$'\x1f' read -r id binary package version integrity tarball node_engine dependency_package dependency_version dependency_integrity dependency_tarball 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/$(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
@@ -306,10 +283,13 @@ for (const entry of entries) {
# 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
if [[ -x "$staged_bin" && -f "$stamp" && -f "$entrypoint" ]]; 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
# STAMP_INSTALL_MODE distinguishes bridge-only trees from caches staged by
# the retired full-CLI bundling (whose stamps lack the marker): those trees
# still vendor the native CLIs and must be reinstalled, not reused.
if [[ "${STAMP_INSTALL_MODE:-}" == "bridge-only" && "${STAMP_PACKAGE:-}" == "$package" && "${STAMP_VERSION:-}" == "$version" && "${STAMP_INTEGRITY:-}" == "$integrity" && "${STAMP_DEPENDENCY_PACKAGE:-}" == "$dependency_package" && "${STAMP_DEPENDENCY_VERSION:-}" == "$dependency_version" && "${STAMP_DEPENDENCY_INTEGRITY:-}" == "$dependency_integrity" ]]; 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).
@@ -323,50 +303,37 @@ for (const entry of entries) {
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
# --omit=optional is what makes the install bridge-only: the native
# claude/codex CLIs ship as optional platform packages of the pinned
# dependency, and skipping them keeps the tree pure JS.
npm install \
--prefix "$install_dir" \
--omit=dev \
--omit=optional \
--ignore-scripts \
--no-audit \
--no-fund \
"$package@$version" >&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"
validate_npm_install "$install_dir" "$package" "$version" "$integrity" "$dependency_package" "$dependency_version" "$dependency_integrity" "$claude_code_version"
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_INSTALL_MODE=bridge-only\n'
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
+11 -58
View File
@@ -67,15 +67,11 @@ 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=()
# 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
@@ -93,7 +89,7 @@ codesign_if_darwin() {
fi
}
while IFS=$'\t' read -r id binary package version node_engine native_package native_executable; do
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"
@@ -118,29 +114,10 @@ while IFS=$'\t' read -r id binary package version node_engine native_package nat
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
# 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"
# 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
# unsigned nested Mach-Os are killed by Gatekeeper. Darwin only, so Linux
# staging skips the file(1) scan.
# 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
@@ -157,15 +134,7 @@ for (const entry of data.tools ?? []) {
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"));
console.log([entry.id, entry.binary, entry.package, entry.version, entry.nodeEngine ?? ">=22"].join("\t"));
}
NODE
)
@@ -186,20 +155,4 @@ fs.writeFileSync(manifestFile, `${JSON.stringify({ tools }, null, 2)}\n`);
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"
+14 -136
View File
@@ -21,13 +21,18 @@ const SUPPORTED_TARGETS = [
// Zed package.
const CODEX_ACP_PACKAGE = "@agentclientprotocol/codex-acp";
// Bridge-only bundling: only the bridge JS trees are pinned and installed
// (`npm install --omit=optional` skips the SDK/codex platform packages that
// vendor the native claude/codex CLIs). The desktop app points each bridge at
// the user's own CLI via CLAUDE_CODE_EXECUTABLE / CODEX_PATH at spawn time.
// The trees are therefore platform-independent, but the lock stays per-target
// so a single target's pins can still be bumped or dropped independently.
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,
},
{
@@ -35,75 +40,9 @@ const TOOL_SPECS = [
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",
},
},
"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
// via the ambient npm registry and validates the registry-agnostic sha512
// integrity. Normalize them to the public registry so regenerating against an
@@ -118,9 +57,11 @@ 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.
writes acp-tools.lock.json. Only the bridge JS packages are pinned — the
SDK/codex platform packages that vendor native CLIs are optional dependencies
the install omits; the app runs the user's own claude/codex CLI. Fails loudly
when a package or its pinned dependency cannot be resolved — never silently
pins an older version.
A --target run regenerates only the selected targets; the existing lock's
entries for every other target are preserved verbatim, so a partial bump
@@ -203,8 +144,8 @@ function requireString(value, label) {
}
// Rebuild the tarball URL on PUBLIC_NPM_REGISTRY, keeping the registry's
// `<package>/-/<basename>.tgz` path (the basename is not always derivable
// from name@version — @openai/codex native tarballs carry a platform suffix).
// `<package>/-/<basename>.tgz` path rather than deriving the basename from
// name@version — registries own that naming, not us.
function normalizeTarballUrl(tarball, packageName, label) {
const separator = "/-/";
const separatorIndex = tarball.lastIndexOf(separator);
@@ -257,27 +198,7 @@ function pickLatestMatch(metadata, label) {
);
}
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",
@@ -308,9 +229,6 @@ async function lockToolForTarget(tool, target) {
integrity: packageInfo.integrity,
tarball: packageInfo.tarball,
target,
npmOs: npmTarget.npmOs,
npmCpu: npmTarget.npmCpu,
...(npmTarget.npmLibc ? { npmLibc: npmTarget.npmLibc } : {}),
nodeEngine: packageMetadata.engines?.node ?? ">=22",
};
@@ -323,7 +241,6 @@ async function lockToolForTarget(tool, target) {
"name",
"version",
"dist",
"optionalDependencies",
"claudeCodeVersion",
]),
`${tool.dependencyPackage}@${dependencyRange}`,
@@ -344,46 +261,7 @@ async function lockToolForTarget(tool, target) {
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,
};
return entry;
}
// Entries preserved from the existing lock when --target selects a subset.
@@ -581,9 +581,11 @@ mod tests {
mcp_command: None,
mcp_hooks: false,
underlying_cli: None,
bridge_cli_env_var: None,
cli_install_commands: &[],
cli_install_commands_windows: &[],
adapter_install_commands: &[],
adapter_ships_with_app: false,
install_instructions_url: "",
cli_install_hint: "",
adapter_install_hint: "",
@@ -89,11 +89,13 @@ fn adapter_verification_step(
}
/// A runtime's adapter ships with the Buzz desktop app when its catalog entry
/// carries no install commands at all — neither CLI nor adapter. Goose has a
/// says so (`adapter_ships_with_app`). Inferring this from empty install
/// commands broke once the bridge-only bundles brought back CLI install
/// commands for claude/codex — their bridges are still bundled. Goose has a
/// curl CLI installer (and its CLI *is* its adapter), so a failed goose verify
/// must not claim the adapter is bundled and point at reinstalling Buzz.
fn runtime_adapter_is_bundled(runtime: &crate::managed_agents::KnownAcpRuntime) -> bool {
runtime.cli_install_commands.is_empty() && runtime.adapter_install_commands.is_empty()
runtime.adapter_ships_with_app
}
#[tauri::command]
@@ -1166,7 +1168,8 @@ mod tests {
.unwrap_or_else(|| panic!("{id} must be in the catalog"));
assert!(
runtime_adapter_is_bundled(runtime),
"{id} carries no install commands — its adapter ships with Buzz"
"{id}'s adapter ships with Buzz (bundled bridge or sidecar), \
even though its user CLI may have install commands"
);
}
}
@@ -26,11 +26,6 @@ const ACP_TOOLS_RESOURCE_DIR: &str = "resources/acp/bin";
/// Node runtime manifest staged by `desktop/scripts/prepare-acp-tools-resource.sh`
/// next to the bundled bin dir.
const NODE_RUNTIME_MANIFEST_FILE: &str = "node-runtime.json";
/// Harness CLI manifest staged next to the bin dir: one entry per native CLI
/// vendored inside a bundled bridge (`claude` inside the claude-agent-sdk
/// native package, `codex` inside @openai/codex), with paths relative to the
/// acp resource root.
const HARNESS_CLI_MANIFEST_FILE: &str = "harness-clis.json";
static BUNDLED_ACP_TOOLS_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
@@ -84,52 +79,6 @@ pub(in crate::managed_agents) fn node_runtime_manifest_path(bin_dir: &Path) -> O
.map(|dir| dir.join(NODE_RUNTIME_MANIFEST_FILE))
}
/// On-disk shape of `resources/acp/harness-clis.json`.
#[derive(serde::Deserialize)]
struct HarnessCliManifest {
#[serde(default)]
clis: Vec<HarnessCliEntry>,
}
#[derive(serde::Deserialize)]
struct HarnessCliEntry {
cli: String,
path: String,
}
/// Resolve the pinned native harness CLI (`claude`, `codex`) vendored inside
/// a bundled bridge, via the staged `harness-clis.json` manifest. This is the
/// same binary the bridge itself runs, so auth probes against it can never
/// drift from what agent sessions see. Bare command names only, mirroring
/// [`command_in_bundled_dir`]. Deliberately separate from the bin dir: these
/// CLIs must never join the agent-spawn PATH, where they would shadow the
/// user's (possibly newer) installs inside every session.
pub(in crate::managed_agents) fn bundled_harness_cli(cli: &str) -> Option<PathBuf> {
let bin_dir = bundled_acp_tools_dir()?;
bundled_harness_cli_in_root(bin_dir.parent()?, cli)
}
fn bundled_harness_cli_in_root(acp_root: &Path, cli: &str) -> Option<PathBuf> {
if command_looks_like_path(cli) {
return None;
}
let manifest = std::fs::read_to_string(acp_root.join(HARNESS_CLI_MANIFEST_FILE)).ok()?;
let manifest: HarnessCliManifest = serde_json::from_str(&manifest).ok()?;
let entry = manifest.clis.into_iter().find(|entry| entry.cli == cli)?;
let relative = PathBuf::from(entry.path);
// Manifest paths are acp-root-relative by contract; anything absolute or
// escaping the root is malformed and must not resolve.
let escapes = relative.is_absolute()
|| relative
.components()
.any(|component| matches!(component, std::path::Component::ParentDir));
if escapes {
return None;
}
let candidate = acp_root.join(relative);
is_executable_file(&candidate).then_some(candidate)
}
fn command_in_dir(dir: &Path, command: &str) -> Option<PathBuf> {
if command_looks_like_path(command) {
return None;
@@ -152,10 +101,7 @@ fn bundled_acp_tools_dir_from_parts(
#[cfg(test)]
mod tests {
use super::{
bundled_acp_tools_dir_from_parts, bundled_harness_cli_in_root, command_in_dir,
path_is_in_dir,
};
use super::{bundled_acp_tools_dir_from_parts, command_in_dir, path_is_in_dir};
use std::ffi::OsStr;
use std::path::Path;
@@ -255,74 +201,6 @@ mod tests {
assert!(command_in_dir(temp.path(), "custom/codex-acp").is_none());
}
#[cfg(unix)]
#[test]
fn bundled_harness_cli_resolves_manifest_relative_path() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("temp dir");
let vendored = temp.path().join("node/claude-acp/node_modules/sdk-native");
fs::create_dir_all(&vendored).expect("vendored dir");
let cli = vendored.join("claude");
fs::write(&cli, "#!/bin/sh\n").expect("write cli");
fs::set_permissions(&cli, fs::Permissions::from_mode(0o755)).expect("chmod cli");
fs::write(
temp.path().join("harness-clis.json"),
r#"{"clis":[{"id":"claude-acp","cli":"claude","path":"node/claude-acp/node_modules/sdk-native/claude"}]}"#,
)
.expect("write manifest");
assert_eq!(
bundled_harness_cli_in_root(temp.path(), "claude").as_deref(),
Some(cli.as_path()),
);
assert!(
bundled_harness_cli_in_root(temp.path(), "codex").is_none(),
"a CLI absent from the manifest must not resolve"
);
}
#[test]
fn bundled_harness_cli_without_manifest_is_none() {
let temp = tempfile::tempdir().expect("temp dir");
assert!(bundled_harness_cli_in_root(temp.path(), "claude").is_none());
}
#[cfg(unix)]
#[test]
fn bundled_harness_cli_rejects_escaping_paths_and_path_like_names() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("temp dir");
let outside = temp.path().join("outside");
fs::write(&outside, "#!/bin/sh\n").expect("write outside");
fs::set_permissions(&outside, fs::Permissions::from_mode(0o755)).expect("chmod outside");
let root = temp.path().join("acp");
fs::create_dir_all(&root).expect("acp root");
fs::write(
root.join("harness-clis.json"),
format!(
r#"{{"clis":[{{"id":"a","cli":"escape","path":"../outside"}},{{"id":"b","cli":"absolute","path":"{}"}}]}}"#,
outside.display()
),
)
.expect("write manifest");
assert!(
bundled_harness_cli_in_root(&root, "escape").is_none(),
"a ..-escaping manifest path must not resolve"
);
assert!(
bundled_harness_cli_in_root(&root, "absolute").is_none(),
"an absolute manifest path must not resolve"
);
// Path-like probe names bypass the manifest entirely — they name a
// specific binary and must fall through to regular resolution.
assert!(bundled_harness_cli_in_root(&root, "some/claude").is_none());
}
#[cfg(unix)]
#[test]
fn command_in_dir_skips_non_executable_files() {
@@ -37,9 +37,11 @@ fn test_runtime() -> &'static KnownAcpRuntime {
mcp_command: None,
mcp_hooks: false,
underlying_cli: None,
bridge_cli_env_var: None,
cli_install_commands: &[],
cli_install_commands_windows: &[],
adapter_install_commands: &[],
adapter_ships_with_app: false,
install_instructions_url: "",
cli_install_hint: "",
adapter_install_hint: "",
@@ -612,9 +614,11 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime {
mcp_command: None,
mcp_hooks: false,
underlying_cli: None,
bridge_cli_env_var: None,
cli_install_commands: &[],
cli_install_commands_windows: &[],
adapter_install_commands: &[],
adapter_ships_with_app: false,
install_instructions_url: "",
cli_install_hint: "",
adapter_install_hint: "",
@@ -21,11 +21,16 @@ pub(crate) struct KnownAcpRuntime {
pub mcp_command: Option<&'static str>,
/// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent.
pub mcp_hooks: bool,
/// CLI binary whose presence distinguishes `AdapterMissing` from
/// `NotInstalled` when the adapter is absent. `None` for the bundled
/// bridges (claude, codex): they ship their own vendored CLI, so the
/// user's install neither gates availability nor serves auth probes.
/// CLI binary that indicates partial install (e.g. `"claude"` when `claude-agent-acp` is missing).
pub underlying_cli: Option<&'static str>,
/// Env var through which the bundled ACP bridge finds the user's
/// `underlying_cli` binary. The bridge-only bundles vendor no CLI: the
/// claude bridge has no PATH fallback (`CLAUDE_CODE_EXECUTABLE` or the
/// omitted SDK native package), and the codex bridge's `CODEX_PATH`
/// fallback hard-requires the omitted platform package — so spawn exports
/// the resolved user CLI here. `None` for runtimes whose adapter needs no
/// separate CLI handle.
pub bridge_cli_env_var: Option<&'static str>,
/// Shell commands to install the runtime CLI itself (run sequentially).
pub cli_install_commands: &'static [&'static str],
/// Windows-specific CLI install commands (e.g. PowerShell installers).
@@ -34,6 +39,13 @@ pub(crate) struct KnownAcpRuntime {
pub cli_install_commands_windows: &'static [&'static str],
/// Shell commands to install the ACP adapter (run sequentially, after CLI).
pub adapter_install_commands: &'static [&'static str],
/// Whether the ACP adapter binary ships inside the Buzz desktop app (the
/// bundled bridges and the buzz-agent sidecar) rather than being
/// user-installed. Drives the post-install verification hint: a bundled
/// adapter that fails to resolve means a broken Buzz installation
/// ("Reinstall Buzz"), not a failed install step. False for goose, whose
/// adapter is the user-installed goose CLI itself.
pub adapter_ships_with_app: bool,
/// Link to docs/repo for manual instructions.
pub install_instructions_url: &'static str,
/// Human-readable hint about installing the CLI binary.
@@ -147,9 +159,11 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
mcp_command: None,
mcp_hooks: false,
underlying_cli: Some("goose"),
bridge_cli_env_var: None,
cli_install_commands: &["curl -fsSL https://github.com/block-open-source/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"],
cli_install_commands_windows: &[], // goose install script is already Windows-aware
adapter_install_commands: &[],
adapter_ships_with_app: false,
install_instructions_url: "https://block.github.io/goose/",
cli_install_hint: "Install Goose via the official install script.",
adapter_install_hint: "",
@@ -179,12 +193,14 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
avatar_url: CLAUDE_CODE_AVATAR_URL,
mcp_command: None,
mcp_hooks: false,
underlying_cli: None,
cli_install_commands: &[],
cli_install_commands_windows: &[],
underlying_cli: Some("claude"),
bridge_cli_env_var: Some("CLAUDE_CODE_EXECUTABLE"),
cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"],
cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""],
adapter_install_commands: &[],
adapter_ships_with_app: true,
install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp",
cli_install_hint: "",
cli_install_hint: "Install the Claude Code CLI via the official install script.",
adapter_install_hint: "The Claude Code ACP adapter ships with the Buzz desktop app. Reinstall Buzz to restore it.",
skill_dir: Some(".claude/skills"),
supports_acp_model_switching: false,
@@ -199,7 +215,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
max_tokens_env_var: None,
context_limit_env_var: None,
required_normalized_fields: &[],
login_hint: Some("Run the Claude CLI to complete authentication (install it first if needed)."),
login_hint: Some("Run the Claude CLI to complete authentication."),
auth_probe_args: Some(&["claude", "auth", "status"]),
},
KnownAcpRuntime {
@@ -210,12 +226,14 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
avatar_url: CODEX_AVATAR_URL,
mcp_command: Some("buzz-dev-mcp"),
mcp_hooks: false,
underlying_cli: None,
cli_install_commands: &[],
cli_install_commands_windows: &[],
underlying_cli: Some("codex"),
bridge_cli_env_var: Some("CODEX_PATH"),
cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"],
cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://chatgpt.com/codex/install.ps1 | iex\""],
adapter_install_commands: &[],
adapter_ships_with_app: true,
install_instructions_url: "https://github.com/agentclientprotocol/codex-acp",
cli_install_hint: "",
cli_install_hint: "Install the Codex CLI via the official install script.",
adapter_install_hint: "The Codex ACP adapter ships with the Buzz desktop app. Reinstall Buzz to restore it.",
skill_dir: Some(".codex/skills"),
supports_acp_model_switching: false,
@@ -230,7 +248,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
max_tokens_env_var: None,
context_limit_env_var: None,
required_normalized_fields: &[],
login_hint: Some("Run `codex login` to authenticate (install the Codex CLI first if needed)."),
login_hint: Some("Run `codex login` to authenticate."),
// Verified: `codex login status` exits 0 when logged in, non-zero otherwise.
auth_probe_args: Some(&["codex", "login", "status"]),
},
@@ -243,9 +261,11 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
mcp_command: Some("buzz-dev-mcp"),
mcp_hooks: true,
underlying_cli: None,
bridge_cli_env_var: None,
cli_install_commands: &[],
cli_install_commands_windows: &[],
adapter_install_commands: &[],
adapter_ships_with_app: true,
install_instructions_url: "https://github.com/block/buzz",
cli_install_hint: "Ships with the Buzz desktop app.",
adapter_install_hint: "",
@@ -876,16 +896,6 @@ pub(crate) fn find_command(command: &str) -> Option<PathBuf> {
resolve_command(command)
}
/// Resolve the binary for a CLI auth probe (`claude`, `codex`): the pinned
/// CLI vendored inside the bundled bridge wins — it is the exact binary agent
/// sessions run and reads the same credential store — falling back to the
/// user's install for builds without staged bundle resources. Not routed
/// through `resolve_command`: probe binaries must stay out of the resolve
/// cache and off the agent-spawn PATH.
pub(crate) fn resolve_probe_binary(cli: &str) -> Option<PathBuf> {
super::acp_tools::bundled_harness_cli(cli).or_else(|| resolve_command(cli))
}
/// Run a CLI auth probe with a 10-second process-level timeout.
///
/// Spawns the probe CLI as a child process. Stdout and stderr are drained on
@@ -1003,21 +1013,25 @@ pub fn missing_command_message(command: &str, role: &str) -> String {
)
}
/// A resolving adapter is available, full stop — whether the runtime is
/// usable beyond that is an auth question (`auth_status`), not an install
/// question. The retired `CliMissing` state gated availability on a user
/// CLI install the bundled bridges no longer need.
pub(crate) fn classify_runtime(
adapter_result: Option<(&str, PathBuf)>,
underlying_cli: Option<&str>,
underlying_cli_found: bool,
) -> (AcpAvailabilityStatus, Option<String>, Option<String>) {
if let Some((cmd, path)) = adapter_result {
(
AcpAvailabilityStatus::Available,
Some(cmd.to_string()),
Some(path.display().to_string()),
)
if underlying_cli.is_some() && !underlying_cli_found {
(
AcpAvailabilityStatus::CliMissing,
Some(cmd.to_string()),
Some(path.display().to_string()),
)
} else {
(
AcpAvailabilityStatus::Available,
Some(cmd.to_string()),
Some(path.display().to_string()),
)
}
} else if underlying_cli.is_some() && underlying_cli_found {
(AcpAvailabilityStatus::AdapterMissing, None, None)
} else {
@@ -1068,6 +1082,7 @@ pub fn discover_acp_runtimes() -> Vec<AcpRuntimeCatalogEntry> {
let adapter_hint = runtime.adapter_install_hint;
let install_hint = match availability {
AcpAvailabilityStatus::Available => cli_hint.to_string(),
AcpAvailabilityStatus::CliMissing => cli_hint.to_string(),
AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(),
AcpAvailabilityStatus::NotInstalled => {
if !cli_hint.is_empty() && !adapter_hint.is_empty() {
@@ -1114,8 +1129,8 @@ pub fn discover_acp_runtimes() -> Vec<AcpRuntimeCatalogEntry> {
return None;
}
let probe_args = partial.runtime.auth_probe_args?;
// Probe the bundled CLI when the app ships one, else the user's.
let binary_path = resolve_probe_binary(probe_args[0])?;
// Need the resolved binary path for the CLI (e.g. the actual `claude` binary).
let binary_path = resolve_command(probe_args[0])?;
let probe_args_owned: Vec<String> = probe_args.iter().map(|s| s.to_string()).collect();
let handle = std::thread::spawn(move || {
@@ -176,19 +176,42 @@ fn classifies_not_installed_when_no_underlying_cli() {
}
#[test]
fn classifies_available_when_adapter_found_even_without_underlying_cli() {
// The retired CliMissing gate must not come back: a resolving adapter is
// available regardless of whether an underlying CLI is on the user PATH.
fn classifies_cli_missing_when_adapter_found_but_cli_absent() {
let (status, cmd, path) = classify_runtime(
Some(("codex-acp", PathBuf::from("/opt/homebrew/bin/codex-acp"))),
Some("codex"),
false,
);
assert_eq!(status, AcpAvailabilityStatus::Available);
assert_eq!(status, AcpAvailabilityStatus::CliMissing);
assert_eq!(cmd.as_deref(), Some("codex-acp"));
assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp"));
}
#[test]
fn bridge_cli_env_vars_map_bundled_bridges_to_the_user_cli() {
// The bridge-only bundles vendor no CLI, so spawn must export exactly
// these env vars for the bridges to find the user's install. Pinned here
// because a drifted mapping fails silently — sessions die at bridge
// startup, not at build time.
let expectations = [
("claude", Some("CLAUDE_CODE_EXECUTABLE")),
("codex", Some("CODEX_PATH")),
("goose", None),
("buzz-agent", None),
];
for (id, expected) in expectations {
let runtime = crate::managed_agents::known_acp_runtime_exact(id)
.unwrap_or_else(|| panic!("{id} must be in the catalog"));
assert_eq!(runtime.bridge_cli_env_var, expected, "runtime {id}");
if runtime.bridge_cli_env_var.is_some() {
assert!(
runtime.underlying_cli.is_some(),
"{id}: a bridge CLI env var is meaningless without an underlying CLI to resolve"
);
}
}
}
fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agents::AgentDefinition {
crate::managed_agents::AgentDefinition {
id: id.to_string(),
@@ -870,51 +893,65 @@ fn test_command_basenames_dotted_name_no_extra_candidates() {
// ── Phase B: cli_install_commands_for_os ────────────────────────────────────
/// Claude and Codex vendor their CLIs inside the bundled ACP packages —
/// the curl-pipe install commands are retired with the cli_missing gate.
/// Claude and Codex have non-empty default cli_install_commands (install.sh) —
/// bridge-only bundling means the user's own CLI must be installable again.
#[test]
fn test_claude_and_codex_have_no_cli_install_commands() {
fn test_claude_and_codex_have_cli_install_commands() {
let claude = super::known_acp_runtime_exact("claude").unwrap();
let codex = super::known_acp_runtime_exact("codex").unwrap();
assert!(
claude.cli_install_commands.is_empty(),
"claude CLI ships inside the bundled adapter — must not have cli install commands"
!claude.cli_install_commands.is_empty(),
"claude must have cli install commands"
);
assert!(
codex.cli_install_commands.is_empty(),
"codex CLI ships inside the bundled adapter — must not have cli install commands"
!codex.cli_install_commands.is_empty(),
"codex must have cli install commands"
);
}
/// cli_install_commands_for_os is empty for claude and codex on every platform.
/// cli_install_commands_for_os returns a non-empty slice for claude and codex.
#[test]
fn test_cli_install_commands_for_os_empty_for_claude_codex() {
fn test_cli_install_commands_for_os_non_empty_for_claude_codex() {
let claude = super::known_acp_runtime_exact("claude").unwrap();
let codex = super::known_acp_runtime_exact("codex").unwrap();
assert!(
claude.cli_install_commands_for_os().is_empty(),
"claude must not have install commands on any platform"
!claude.cli_install_commands_for_os().is_empty(),
"claude must have install commands on every platform"
);
assert!(
codex.cli_install_commands_for_os().is_empty(),
"codex must not have install commands on any platform"
!codex.cli_install_commands_for_os().is_empty(),
"codex must have install commands on every platform"
);
}
/// On Windows, the bundled runtimes still expose no install commands, and
/// goose keeps its platform-neutral commands.
/// On Windows, Claude and Codex select the PowerShell install commands.
#[cfg(windows)]
#[test]
fn test_cli_install_commands_for_os_on_windows() {
fn test_cli_install_commands_for_os_selects_powershell_on_windows() {
let claude = super::known_acp_runtime_exact("claude").unwrap();
let codex = super::known_acp_runtime_exact("codex").unwrap();
// Windows must select the PowerShell commands, not the curl|bash ones.
let claude_cmds = claude.cli_install_commands_for_os();
let codex_cmds = codex.cli_install_commands_for_os();
assert_ne!(
claude_cmds, claude.cli_install_commands,
"Windows must NOT use the default curl|bash commands for claude"
);
assert_ne!(
codex_cmds, codex.cli_install_commands,
"Windows must NOT use the default curl|bash commands for codex"
);
// Verify they are the PowerShell installers.
assert!(
claude.cli_install_commands_for_os().is_empty(),
"claude ships bundled — no Windows install commands"
claude_cmds.iter().any(|c| c.contains("powershell")),
"claude Windows install must use powershell; got: {claude_cmds:?}"
);
assert!(
codex.cli_install_commands_for_os().is_empty(),
"codex ships bundled — no Windows install commands"
codex_cmds.iter().any(|c| c.contains("powershell")),
"codex Windows install must use powershell; got: {codex_cmds:?}"
);
// Goose and buzz-agent must NOT use Windows-specific commands.
@@ -46,7 +46,7 @@ use crate::managed_agents::{
agent_env::baked_build_env,
config_bridge::read_goose_file_config,
discovery::{
classify_runtime, find_command, known_acp_runtime, resolve_probe_binary, KnownAcpRuntime,
classify_runtime, find_command, known_acp_runtime, resolve_command, KnownAcpRuntime,
},
env_vars::merged_user_env,
global_config::GlobalAgentConfig,
@@ -502,10 +502,7 @@ fn cli_login_requirements(
.iter()
.find_map(|cmd| find_command(cmd).map(|path| (*cmd, path)));
// Check whether the underlying CLI is on PATH — only set for runtimes
// whose CLI is a separate install (not the bundled claude/codex bridges,
// which vendor their own); it distinguishes AdapterMissing from
// NotInstalled below.
// Check whether the underlying CLI itself (e.g. "claude", "codex") is on PATH.
let underlying_cli_found = runtime
.underlying_cli
.map(|cli| find_command(cli).is_some())
@@ -516,12 +513,10 @@ fn cli_login_requirements(
match availability {
AcpAvailabilityStatus::Available => {
// Adapter present — probe login status against the bundled CLI
// when the app ships one (the same pinned binary agent sessions
// run), else the user's install resolved via the full login-shell
// PATH so the probe works in a packaged macOS DMG where the GUI
// PATH lacks npm/homebrew.
let Some(binary_path) = resolve_probe_binary(probe_args[0]) else {
// Both adapter and CLI are present — probe login status.
// Resolve via the full login-shell PATH so the probe works in a
// packaged macOS DMG where the GUI PATH lacks npm/homebrew.
let Some(binary_path) = resolve_command(probe_args[0]) else {
// Unexpectedly not resolvable (race or PATH edge case).
return vec![Requirement::CliLogin {
probe_args: probe_args.iter().map(|s| s.to_string()).collect(),
@@ -945,9 +940,11 @@ mod tests {
mcp_command: None,
mcp_hooks: false,
underlying_cli,
bridge_cli_env_var: None,
cli_install_commands: &[],
cli_install_commands_windows: &[],
adapter_install_commands: &[],
adapter_ships_with_app: false,
install_instructions_url: "",
cli_install_hint: "",
adapter_install_hint: "",
@@ -1046,11 +1043,10 @@ mod tests {
}
#[test]
fn cli_login_requirements_probe_runs_even_without_underlying_cli() {
fn cli_login_requirements_cli_missing_emits_cli_missing() {
// Adapter present (use the running test binary as a portable stand-in),
// underlying CLI absent. The retired CliMissing gate would have skipped
// the probe; now the adapter alone means Available, the probe runs
// (here: exit 0 → logged in) and no requirement is emitted.
// underlying CLI absent.
// → CliMissing state → no probe run → CliLogin{CliMissing}.
let exe = present_binary_str();
let rt = make_cli_runtime(
static_commands(vec![exe]), // adapter found via absolute path
@@ -1058,9 +1054,19 @@ mod tests {
);
let reqs = cli_login_requirements(&[exe, "--list"], "install the CLI", &rt);
assert!(
reqs.is_empty(),
"adapter present must probe login regardless of the user CLI; got {reqs:?}"
!reqs.is_empty(),
"CLI missing must produce a CliLogin requirement"
);
if let Requirement::CliLogin {
ref availability, ..
} = reqs[0]
{
assert_eq!(
*availability,
crate::managed_agents::AcpAvailabilityStatus::CliMissing,
"adapter present, CLI absent → CliMissing"
);
}
}
#[test]
@@ -1579,6 +1579,31 @@ pub fn spawn_agent_child(
command.env("MCP_HOOK_SERVERS", "*");
}
// Point the bundled bridge at the user's harness CLI. The bridge-only
// bundles vendor no CLI: the claude bridge only looks at
// CLAUDE_CODE_EXECUTABLE (or the omitted SDK native package) and the
// codex bridge's CODEX_PATH fallback hard-requires the omitted platform
// package — neither falls back to PATH. A value already present in the
// desktop's own environment is inherited untouched (it names a binary the
// user picked), and a per-agent env var applied below overrides this one.
// Resolution failure is non-fatal here: readiness classifies the runtime
// CliMissing and the agent enters setup mode instead of the pool.
if let Some((cli, env_var)) =
runtime_meta.and_then(|r| r.underlying_cli.zip(r.bridge_cli_env_var))
{
if std::env::var_os(env_var).is_none() {
match resolve_command(cli) {
Some(path) => {
command.env(env_var, &path);
}
None => eprintln!(
"buzz-desktop: {cli} not found for {env_var}; \
bridge sessions will fail until it is installed"
),
}
}
}
// ── Readiness check: set setup-payload if agent is not ready ─────────────
//
// Build the effective env the agent would have at start-time, run the
@@ -523,14 +523,12 @@ pub struct ManagedAgentLogResponse {
pub log_path: String,
}
/// The retired `CliMissing` variant (adapter present, user CLI absent) is
/// gone: the bundled bridges vendor their own CLI, so a resolving adapter is
/// available regardless of user installs — sign-in state is `AuthStatus`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AcpAvailabilityStatus {
Available,
AdapterMissing,
CliMissing,
NotInstalled,
}
@@ -562,7 +562,9 @@ export function AgentDefinitionDialog({
<p className="text-xs text-warning">
{selectedRuntime.availability === "adapter_missing"
? `${selectedRuntime.label} CLI is installed but the ACP adapter is missing.`
: `${selectedRuntime.label} is not installed.`}{" "}
: selectedRuntime.availability === "cli_missing"
? `${selectedRuntime.label} ACP adapter is installed but the CLI is missing.`
: `${selectedRuntime.label} is not installed.`}{" "}
Visit Settings &gt; Doctor to set it up.
</p>
) : null;
@@ -97,7 +97,7 @@ test("getDefaultPersonaRuntime falls back to goose when buzz-agent is unavailabl
test("getDefaultPersonaRuntime returns first available when neither buzz-agent nor goose is available", () => {
const runtimes = [
makeRuntime("buzz-agent", "adapter_missing"),
makeRuntime("goose", "not_installed"),
makeRuntime("goose", "cli_missing"),
makeRuntime("claude"),
];
const result = getDefaultPersonaRuntime(runtimes);
@@ -111,7 +111,7 @@ test("getDefaultPersonaRuntime returns null for an empty list", () => {
test("getDefaultPersonaRuntime returns null when no runtime is available", () => {
const runtimes = [
makeRuntime("buzz-agent", "not_installed"),
makeRuntime("goose", "adapter_missing"),
makeRuntime("goose", "cli_missing"),
];
assert.equal(getDefaultPersonaRuntime(runtimes), null);
});
@@ -168,7 +168,11 @@ test("getPersonaModelOptions for buzz-agent with no provider returns default mod
// is non-null (so the UI surfaces the reason) for each unavailability reason.
test("formatModelDiscoveryErrorStatus returns a non-null status for runtime unavailable errors", () => {
for (const availability of ["adapter_missing", "not_installed"]) {
for (const availability of [
"adapter_missing",
"cli_missing",
"not_installed",
]) {
const status = formatModelDiscoveryErrorStatus(
new Error(`Runtime not available: ${availability}`),
"anthropic",
@@ -383,9 +383,11 @@ export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) {
const suffix =
runtime.availability === "adapter_missing"
? " (adapter missing)"
: runtime.availability === "not_installed"
? " (not installed)"
: "";
: runtime.availability === "cli_missing"
? " (CLI missing)"
: runtime.availability === "not_installed"
? " (not installed)"
: "";
return `${runtime.label}${suffix}`;
}
@@ -395,10 +397,12 @@ function runtimeAvailabilitySortRank(
switch (availability) {
case "available":
return 0;
case "not_installed":
case "cli_missing":
return 1;
case "adapter_missing":
case "not_installed":
return 2;
case "adapter_missing":
return 3;
}
}
@@ -353,6 +353,19 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
);
}
if (runtime.availability === "cli_missing") {
return (
<>
<p className="mt-2 text-sm leading-5 text-muted-foreground">
ACP adapter detected; CLI missing.
</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground/80">
{runtime.installHint}
</p>
</>
);
}
return (
<>
<p className="mt-2 text-sm leading-5 text-muted-foreground">
@@ -108,7 +108,7 @@ test("validateLinkedAgentRuntimeEdit rejects unavailable linked-agent runtime ch
input: updateInput({ runtime: "claude" }),
managedAgent: agent(),
previousPersona: persona({ runtime: "goose" }),
runtimes: [runtime({ availability: "not_installed", command: null })],
runtimes: [runtime({ availability: "cli_missing", command: null })],
}),
"Claude Code is not available. Install it before saving this linked agent.",
);
@@ -38,6 +38,8 @@ function StatusIcon({
return <CheckCircle2 className="h-4 w-4 text-status-added" />;
case "adapter_missing":
return <AlertTriangle className="h-4 w-4 text-warning" />;
case "cli_missing":
return <AlertTriangle className="h-4 w-4 text-warning" />;
case "not_installed":
return <Circle className="h-4 w-4 text-muted-foreground/50" />;
}
@@ -136,7 +138,8 @@ function RuntimeRow({
"flex min-h-16 items-start gap-3 px-4 py-3 text-sm",
runtime.availability === "available"
? "bg-background/60"
: runtime.availability === "adapter_missing"
: runtime.availability === "adapter_missing" ||
runtime.availability === "cli_missing"
? "bg-amber-500/5"
: "bg-muted/20",
)}
@@ -173,12 +176,23 @@ function RuntimeRow({
</code>
</p>
) : null}
{/* The bundled bridge's resource-dir path is noise — the
"Bundled with Buzz." line above covers it. The
user-CLI path row is retired with the cli_missing gate: the
bundled bridges vendor their own CLI, so no runtime reports a
separate CLI path anymore. */}
{runtime.adapterBundled ? null : (
{runtime.underlyingCliPath &&
runtime.underlyingCliPath !== runtime.binaryPath ? (
<div className="mt-1 space-y-0.5">
<p className="break-all font-mono text-2xs text-muted-foreground/80">
<span className="text-muted-foreground">CLI:</span>{" "}
{runtime.underlyingCliPath}
</p>
{/* The bundled bridge's resource-dir path is noise — the
"Bundled with Buzz." line above covers it. */}
{runtime.adapterBundled ? null : (
<p className="break-all font-mono text-2xs text-muted-foreground/80">
<span className="text-muted-foreground">ACP adapter:</span>{" "}
{runtime.binaryPath}
</p>
)}
</div>
) : runtime.adapterBundled ? null : (
<>
<p className="mt-1 break-all font-mono text-2xs text-muted-foreground/80">
{runtime.binaryPath}
@@ -229,6 +243,34 @@ function RuntimeRow({
runtime={runtime}
/>
</>
) : runtime.availability === "cli_missing" ? (
<>
<p className="mt-1 text-sm font-normal text-muted-foreground">
{runtime.adapterBundled ? (
<>
Bundled with Buzz, but the {runtime.label} CLI is not
installed.
</>
) : (
<>
ACP adapter found at{" "}
<code className="rounded bg-muted px-1 py-0.5 text-2xs">
{runtime.binaryPath ?? "unknown path"}
</code>{" "}
but the {runtime.label} CLI is not installed.
</>
)}
</p>
<p className="mt-1 text-sm font-normal text-muted-foreground">
{runtime.installHint}
</p>
<InstallActions
hasError={installError !== null}
isInstalling={isInstalling}
onInstall={onInstall}
runtime={runtime}
/>
</>
) : (
<>
<p className="mt-1 text-sm font-normal text-muted-foreground">
+1 -5
View File
@@ -554,14 +554,10 @@ export type GitBashPrerequisite = {
installHint: string;
};
/**
* The retired "cli_missing" state (adapter present, user CLI absent) is gone:
* the bundled bridges vendor their own CLI, so a resolving adapter is
* available regardless of user installs — sign-in state lives in AuthStatus.
*/
export type AcpAvailabilityStatus =
| "available"
| "adapter_missing"
| "cli_missing"
| "not_installed";
/** Authentication/login status for a CLI-based ACP runtime. */
@@ -103,30 +103,6 @@ test("extractConfigNudge rejects retired adapter_outdated availability", () => {
);
});
test("extractConfigNudge rejects retired cli_missing availability", () => {
// The cli_missing gate was retired when auth probes moved to the CLIs
// vendored inside the bundled bridges — a user CLI install no longer gates
// availability. Stale nudge JSON emitted by an older app version must not
// parse into a card the current UI has no rendering for.
const payload = {
agent_name: "Fizz",
agent_pubkey: FIZZ_PUBKEY,
requirements: [
{
surface: "cli_login",
probe_args: ["claude", "auth", "status"],
setup_copy: "install the Claude CLI",
availability: "cli_missing",
},
],
};
assert.equal(
extractConfigNudge(withSentinel("prose", payload)),
null,
"retired cli_missing availability must be rejected by the validator",
);
});
test("extractConfigNudge returns null for cli_login without availability", () => {
// availability is required — old-format payloads (no availability field)
// must not parse so stale nudge JSON from before the Doctor-CTA update
+3 -4
View File
@@ -32,7 +32,8 @@ export type ConfigNudgeRequirement =
* Determines which message and CTA the nudge card shows:
* - "available" → tooling installed, needs login
* - "adapter_missing" → CLI installed but ACP adapter missing
* - "not_installed" → no adapter found
* - "cli_missing" → ACP adapter installed but CLI missing
* - "not_installed" → neither adapter nor CLI found
*/
availability: AcpAvailabilityStatus;
}
@@ -138,11 +139,9 @@ function isConfigNudgeRequirement(v: unknown): v is ConfigNudgeRequirement {
Array.isArray(r.probe_args) &&
r.probe_args.every((a) => typeof a === "string") &&
typeof r.setup_copy === "string" &&
// Retired literals ("adapter_outdated", "cli_missing") emitted by
// older app versions are rejected here so stale nudge JSON cannot
// render a card the current UI has no branch for.
(r.availability === "available" ||
r.availability === "adapter_missing" ||
r.availability === "cli_missing" ||
r.availability === "not_installed")
);
case "git_bash":
@@ -103,6 +103,8 @@ function cliLoginMessage(
switch (req.availability) {
case "not_installed":
return `${harness} isn't installed`;
case "cli_missing":
return `${harness} CLI is missing`;
case "adapter_missing":
return `${harness} ACP adapter isn't installed`;
case "available":
@@ -257,7 +257,44 @@ test.describe("doctor CTA nudge card screenshots", () => {
});
});
// The former 04-cli-login-cli-missing-state test retired with the
// cli_missing gate: the validator now rejects that availability literal,
// so no card renders for it (see configNudge.test.mjs).
/**
* 04 — cli_missing state: ACP adapter present but underlying CLI absent.
* Shows "claude CLI is missing" copy.
*/
test("04-cli-login-cli-missing-state", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped" as const,
channelNames: ["general"],
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const content = makeNudgeSentinel(AGENT_NAME, AGENT_PUBKEY, [
{
surface: "cli_login",
probe_args: ["claude"],
setup_copy: "install the Claude CLI",
availability: "cli_missing",
},
]);
await injectNudgeAndNavigate(page, content);
const card = page.locator("[data-config-nudge]").last();
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(card.getByText(/CLI is missing/)).toBeVisible();
await card.scrollIntoViewIfNeeded();
await settleAnimations(page);
await card.screenshot({
path: `${SHOTS}/04-cli-login-cli-missing-state.png`,
});
});
});
+6 -8
View File
@@ -48,9 +48,6 @@ const BUZZ_AGENT_AVAILABLE = {
/**
* Claude available and logged in — used as a neutral entry when claude is not
* the runtime under test, and as the base for the auth states being tested.
* No `underlying_cli_path` and no auto-install: the bundled bridge vendors
* its own CLI, so the backend reports neither for claude since the
* cli_missing gate was retired.
*/
const CLAUDE_AVAILABLE_LOGGED_IN = {
id: "claude",
@@ -64,8 +61,8 @@ const CLAUDE_AVAILABLE_LOGGED_IN = {
install_hint: "",
install_instructions_url:
"https://github.com/agentclientprotocol/claude-agent-acp",
can_auto_install: false,
underlying_cli_path: null,
can_auto_install: true,
underlying_cli_path: "/usr/local/bin/claude",
auth_status: { status: "logged_in" },
};
@@ -149,6 +146,7 @@ test.describe("Doctor panel state screenshots", () => {
availability: "available",
command: "codex-acp",
binary_path: "/usr/local/bin/codex-acp",
underlying_cli_path: "/usr/local/bin/codex",
auth_status: { status: "logged_out" },
login_hint: "Run `codex login` to authenticate.",
},
@@ -416,8 +414,8 @@ test.describe("Doctor panel state screenshots", () => {
/**
* 09 — available runtime whose adapter is the bridge bundled with the app:
* the row says "Bundled with Buzz" instead of rendering the
* resource-dir path, and no CLI path renders — the bundled bridge vendors
* its own CLI, so the user-CLI row retired with the cli_missing gate.
* resource-dir path; the user's CLI path still renders (the bridge-only
* bundle runs the user's claude CLI).
*/
test("09-bundled-adapter", async ({ page }) => {
const bundledPath =
@@ -441,7 +439,7 @@ test.describe("Doctor panel state screenshots", () => {
const row = page.getByTestId("doctor-runtime-claude");
await expect(row).toBeVisible({ timeout: 10_000 });
await expect(row).toContainText("Bundled with Buzz.");
await expect(row).not.toContainText("CLI:");
await expect(row).toContainText("/usr/local/bin/claude");
await expect(row).not.toContainText(bundledPath);
await expect(row).not.toContainText("installed on PATH");