Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 127 additions & 1 deletion crates/buzz-backend-ssh/src/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Comment on lines +443 to +445

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the native Claude binding to remote model probes

When the create dialog selects a remote Claude harness, discover.rs::models_script still invokes buzz-acp models --json without resolving or exporting CLAUDE_CODE_EXECUTABLE. Consequently, model discovery continues to use the adapter's bundled Claude binary while the deployed service uses the native launcher resolved here, so the catalog/authentication check can describe a different Claude installation than the one that actually handles turns. Reuse this resolution and binding in the remote probe path as well.

Useful? React with 👍 / 👎.

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"
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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", <name>, "acp"]`, and the args
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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
Expand All @@ -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'
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/env_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions desktop/src-tauri/src/managed_agents/env_vars/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 21 additions & 4 deletions docs/remote-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
7 changes: 6 additions & 1 deletion scripts/provision-buzz-host.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading