From 303b48f8194581e0b34613bbb2c2ac5dfd6c8d24 Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Tue, 28 Jul 2026 00:35:30 +0200 Subject: [PATCH] fix: bind remote Claude agents to native CLI Signed-off-by: Troy Hoffman --- crates/buzz-backend-ssh/src/deploy.rs | 128 +++++++++++++++++- .../src-tauri/src/managed_agents/env_vars.rs | 1 + .../src/managed_agents/env_vars/tests.rs | 8 ++ docs/remote-agents.md | 25 +++- scripts/provision-buzz-host.sh | 7 +- 5 files changed, 163 insertions(+), 6 deletions(-) diff --git a/crates/buzz-backend-ssh/src/deploy.rs b/crates/buzz-backend-ssh/src/deploy.rs index 400b17b56f..59f8ea300e 100644 --- a/crates/buzz-backend-ssh/src/deploy.rs +++ b/crates/buzz-backend-ssh/src/deploy.rs @@ -32,6 +32,7 @@ const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", + "CLAUDE_CODE_EXECUTABLE", "BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST", "BUZZ_ACP_AGENT_OWNER", @@ -439,6 +440,17 @@ fn deploy_script( r#"harness_name={command} {resolve_acp} harness=$(command -v "$harness_name" 2>/dev/null) || {{ echo "harness $harness_name not found on the server's PATH" >&2; exit 91; }} +claude_cli="" +case "${{harness##*/}}" in + claude-agent-acp|claude-code-acp) + if [ -x "$HOME/.local/bin/claude" ]; then + claude_cli="$HOME/.local/bin/claude" + else + claude_cli=$(command -v claude 2>/dev/null || true) + fi + if [ -z "$claude_cli" ]; then echo "Claude Code CLI not found in ~/.local/bin or on the server's PATH" >&2; exit 95; fi + ;; +esac {resolve_cli} cred=$(command -v git-credential-nostr 2>/dev/null || true) conf="$HOME/.config/buzz-acp" @@ -481,6 +493,17 @@ tmp="$env_file.new" script.push_str("cat <<'BUZZ_ENV_EOF'\n"); script.push_str(&env_file_body(agent)?); script.push_str("BUZZ_ENV_EOF\n"); + // Match local desktop spawn's `configure_runtime_cli`: the adapter + // bundles a point-in-time Claude binary, while the native launcher follows + // Claude Code updates. Preserve the stable launcher path rather than + // resolving its symlink so every new ACP child inherits the current native + // version. This is emitted after the user-env heredoc as an authoritative + // provider binding; the key is also reserved so a payload cannot spoof it. + script.push_str( + "if [ -n \"$claude_cli\" ]; then\n\ +printf 'CLAUDE_CODE_EXECUTABLE=\"%s\"\\n' \"$claude_cli\"\n\ +fi\n", + ); // Git over the relay's NIP-98 endpoint, only when the helper is installed. // NOSTR_PRIVATE_KEY mirrors BUZZ_PRIVATE_KEY, as it does locally. let helper_key = format!("credential.{relay_http}/git.helper"); @@ -718,6 +741,89 @@ mod tests { assert!(script.contains("exit 91")); } + #[cfg(unix)] + #[test] + fn remote_claude_adapters_prefer_the_stable_native_launcher() { + for (index, adapter) in ["claude-agent-acp", "claude-code-acp"] + .into_iter() + .enumerate() + { + let root = sandbox_host(&format!("claude-cli-{index}"), HostAcp::Installed); + let bin = root.join("bin"); + let adapter_path = seed_stub(&bin, adapter, "#!/bin/sh\nexit 0\n"); + seed_stub(&bin, "claude", "#!/bin/sh\nexit 0\n"); + let claude = seed_stub(&root.join(".local/bin"), "claude", "#!/bin/sh\nexit 0\n"); + + let mut request = request(); + request["agent"]["agent_command"] = if index == 0 { + serde_json::json!(adapter) + } else { + serde_json::json!(adapter_path) + }; + let agent = Agent::from_request(&request).unwrap(); + let script = + deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + let output = run_in_sandbox(&root, &script); + assert!( + output.status.success(), + "deploy script failed for {adapter}: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let env_file = root + .join(".config/buzz-acp") + .join(format!("{}.env", agent.slug())); + let written = std::fs::read_to_string(env_file).unwrap(); + assert!( + written.contains(&format!("CLAUDE_CODE_EXECUTABLE=\"{}\"", claude.display())), + "{written}" + ); + } + } + + #[cfg(unix)] + #[test] + fn a_remote_claude_adapter_falls_back_to_the_hosts_path() { + let root = sandbox_host("claude-cli-path", HostAcp::Installed); + let bin = root.join("bin"); + seed_stub(&bin, "claude-agent-acp", "#!/bin/sh\nexit 0\n"); + let claude = seed_stub(&bin, "claude", "#!/bin/sh\nexit 0\n"); + + let mut request = request(); + request["agent"]["agent_command"] = serde_json::json!("claude-agent-acp"); + let agent = Agent::from_request(&request).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + let output = run_in_sandbox(&root, &script); + assert!(output.status.success()); + + let written = std::fs::read_to_string( + root.join(".config/buzz-acp") + .join(format!("{}.env", agent.slug())), + ) + .unwrap(); + assert!( + written.contains(&format!("CLAUDE_CODE_EXECUTABLE=\"{}\"", claude.display())), + "{written}" + ); + } + + #[cfg(unix)] + #[test] + fn a_remote_claude_adapter_requires_the_vendor_cli() { + let root = sandbox_host("claude-cli-missing", HostAcp::Installed); + seed_stub(&root.join("bin"), "claude-agent-acp", "#!/bin/sh\nexit 0\n"); + + let mut request = request(); + request["agent"]["agent_command"] = serde_json::json!("claude-agent-acp"); + let agent = Agent::from_request(&request).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + let output = run_in_sandbox(&root, &script); + + assert_eq!(output.status.code(), Some(95)); + assert!(String::from_utf8_lossy(&output.stderr).contains("Claude Code CLI not found")); + assert!(!root.join(".config/buzz-acp").exists()); + } + /// A Hermes per-profile pin, end to end through the deploy path. /// /// `discover_harnesses` emits `["--profile", , "acp"]`, and the args @@ -864,7 +970,12 @@ mod tests { #[test] fn reserved_and_malformed_env_keys_are_refused() { - for key in ["BUZZ_PRIVATE_KEY", "buzz_relay_url", "BUZZ_MANAGED_AGENT"] { + for key in [ + "BUZZ_PRIVATE_KEY", + "buzz_relay_url", + "BUZZ_MANAGED_AGENT", + "CLAUDE_CODE_EXECUTABLE", + ] { let mut request = request(); request["agent"]["env_vars"] = serde_json::json!({ key: "x" }); let error = env_file_body(&Agent::from_request(&request).unwrap()).unwrap_err(); @@ -1087,6 +1198,7 @@ mod tests { ); assert!(written.contains(&format!("BUZZ_PRIVATE_KEY=\"{NSEC}\""))); assert!(written.contains("ANTHROPIC_API_KEY=\"sk-ant-secret\"")); + assert!(!written.contains("CLAUDE_CODE_EXECUTABLE")); // The git block only lands because the stub helper exists, and it // carries the helper's resolved path. assert!(written.contains(&format!( @@ -1284,6 +1396,17 @@ acp=$(command -v 'buzz-acp' 2>/dev/null || true) if [ -z "$acp" ] && [ -x "$HOME/.local/bin/buzz-acp" ]; then acp="$HOME/.local/bin/buzz-acp"; fi if [ -z "$acp" ]; then echo "buzz-acp not found on the server's PATH or in ~/.local/bin — install it, or set 'buzz-acp path on the server'" >&2; exit 90; fi harness=$(command -v "$harness_name" 2>/dev/null) || {{ echo "harness $harness_name not found on the server's PATH" >&2; exit 91; }} +claude_cli="" +case "${{harness##*/}}" in + claude-agent-acp|claude-code-acp) + if [ -x "$HOME/.local/bin/claude" ]; then + claude_cli="$HOME/.local/bin/claude" + else + claude_cli=$(command -v claude 2>/dev/null || true) + fi + if [ -z "$claude_cli" ]; then echo "Claude Code CLI not found in ~/.local/bin or on the server's PATH" >&2; exit 95; fi + ;; +esac cli=$(command -v 'buzz' 2>/dev/null || true) if [ -z "$cli" ] && [ -x "$HOME/.local/bin/buzz" ]; then cli="$HOME/.local/bin/buzz"; fi if [ -z "$cli" ]; then echo "WARNING: no 'buzz' CLI on the server's PATH or in ~/.local/bin — agents on this host cannot reply with 'buzz messages send' and will degrade to slower replies; install it there, or set BUZZ_CLI_PUSH_BINARY on the desktop and redeploy" >&2; fi @@ -1298,6 +1421,9 @@ printf 'BUZZ_ACP_AGENT_COMMAND="%s"\n' "$harness" printf 'PATH="%s"\n' "$HOME/.local/bin:$PATH" cat <<'BUZZ_ENV_EOF' {env}BUZZ_ENV_EOF +if [ -n "$claude_cli" ]; then +printf 'CLAUDE_CODE_EXECUTABLE="%s"\n' "$claude_cli" +fi if [ -n "$cred" ]; then printf 'GIT_CONFIG_VALUE_0="%s"\n' "$cred" cat <<'BUZZ_GIT_EOF' diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 592a5cbbd9..4c8bb3f63b 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -71,6 +71,7 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", + "CLAUDE_CODE_EXECUTABLE", // Security gates: respond-to mode + allowlist + legacy owner-only // fallback. Overriding would make the running agent's gate diverge // from the saved/UI-visible settings. diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index cf57b12546..bafa3059d9 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -135,6 +135,14 @@ fn is_reserved_recognises_full_list() { assert!(!is_reserved_env_key("BUZZ_ACP_MODEL")); // behavior knob } +#[test] +fn reserved_keys_include_claude_code_executable() { + assert!(is_reserved_env_key("CLAUDE_CODE_EXECUTABLE")); + let agent = map(&[("CLAUDE_CODE_EXECUTABLE", "/tmp/untrusted-claude")]); + let merged = merged_user_env(&BTreeMap::new(), &agent); + assert!(merged.is_empty()); +} + #[test] fn reserved_keys_include_agent_owner_for_legacy_records() { // Legacy records without auth_tag fall back to BUZZ_ACP_AGENT_OWNER diff --git a/docs/remote-agents.md b/docs/remote-agents.md index 4bacef4322..73b84de0bc 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -277,10 +277,11 @@ missing. It is a preflight, not an installer. otherwise emits a warning and provisions the agent anyway. Not a prerequisite — but a host that satisfies it gets noticeably better agents. -4. **At least one harness CLI**, named exactly as `discover_harnesses` probes it — the ACP adapter, - not the vendor CLI. `claude-agent-acp` or `claude-code-acp` for Claude Code, `codex-acp` for - Codex, `goose` for Goose, `cursor-agent`, `omp`, `grok`, `opencode`, `kimi`, `amp-acp`, - `hermes-acp`, `openclaw`, or `buzz-agent`. +4. **At least one harness CLI**, named exactly as `discover_harnesses` probes it. Most harnesses + require only their ACP adapter: `codex-acp` for Codex, `goose` for Goose, `cursor-agent`, `omp`, + `grok`, `opencode`, `kimi`, `amp-acp`, `hermes-acp`, `openclaw`, or `buzz-agent`. Claude is the + deliberate exception: it requires both `claude-agent-acp` or `claude-code-acp` **and** the + vendor `claude` CLI whose stable launcher is bound into the adapter. 5. **SSH key auth.** Every invocation is `BatchMode=yes`, so a password prompt is an immediate failure and never a hang. Add the desktop machine's public key to `~/.ssh/authorized_keys`, or @@ -381,6 +382,7 @@ harness path, `git-credential-nostr`, `PATH` — are appended by the remote scri | var | value | |---|---| | `BUZZ_ACP_AGENT_COMMAND` | the pinned harness, resolved on the host with `command -v` | +| `CLAUDE_CODE_EXECUTABLE` | for a Claude ACP adapter, `~/.local/bin/claude` when executable, otherwise the host's `claude` launcher resolved from `PATH` | | `PATH` | `$HOME/.local/bin:$PATH`, **expanded by the host's shell at deploy time** — see below | | `BUZZ_PRIVATE_KEY` | payload `private_key_nsec` | | `BUZZ_RELAY_URL`, `BUZZ_AUTH_TAG` | payload (auth tag omitted when absent) | @@ -430,6 +432,16 @@ locally. Runtimes absent from the table declare no such vars in `KNOWN_ACP_RUNTI Claude is `provider_locked`, and neither Claude nor Codex has a model env var. An unset payload field writes no key at all. +Claude has one additional executable binding. When the pinned harness is +`claude-agent-acp` or `claude-code-acp`, deploy prefers the stable +`~/.local/bin/claude` launcher and otherwise resolves `claude` from the host's +`PATH`, then writes it as `CLAUDE_CODE_EXECUTABLE`. This matches local desktop +spawn behavior and prevents the adapter from silently using the point-in-time +Claude binary bundled with its SDK dependency. The launcher path is preserved +instead of dereferencing its native-install symlink, so newly spawned ACP +children follow subsequent Claude Code updates. Deploy fails with exit 95 +before writing the unit when the adapter is present but the Claude CLI is not. + `BUZZ_MANAGED_AGENT` is deliberately absent. It is the desktop's process-ownership marker for reclaiming orphaned local children; where systemd owns the lifecycle it would be actively misleading. @@ -522,6 +534,11 @@ budget fires first and the desktop reports a provider timeout rather than an age under that name. Deploy stops before writing anything — no env file, no unit. Install the ACP adapter and re-run discovery so the pin names a binary that exists. +**`Claude Code CLI not found in ~/.local/bin or on the server's PATH` (exit 95).** A Claude ACP +adapter is installed, but the vendor CLI it drives is not. Install Claude Code through its native +installer so `~/.local/bin/claude` exists, or put another `claude` launcher on the deploying +shell's `PATH`, then redeploy. The adapter's bundled SDK binary is deliberately not used. + **`Permission denied (publickey)`.** `BatchMode=yes` means SSH declined rather than prompting. Add the public key to `~/.ssh/authorized_keys`, or `tailscale set --ssh` on the host. diff --git a/scripts/provision-buzz-host.sh b/scripts/provision-buzz-host.sh index b60c013165..0b83b0ec0b 100755 --- a/scripts/provision-buzz-host.sh +++ b/scripts/provision-buzz-host.sh @@ -157,7 +157,12 @@ check_harness() { # label, adapter command, vendor cli, install hint fi } -check_harness "claude" "claude-agent-acp" "claude" \ +CLAUDE_ACP_ADAPTER="claude-agent-acp" +if ! command -v "${CLAUDE_ACP_ADAPTER}" >/dev/null 2>&1 && + command -v claude-code-acp >/dev/null 2>&1; then + CLAUDE_ACP_ADAPTER="claude-code-acp" +fi +check_harness "claude" "${CLAUDE_ACP_ADAPTER}" "claude" \ "npm i -g @agentclientprotocol/claude-agent-acp; curl -fsSL https://claude.ai/install.sh | bash" check_harness "codex" "codex-acp" "codex" \ "npm i -g @agentclientprotocol/codex-acp; curl -fsSL https://chatgpt.com/codex/install.sh | sh"