From 178ea41b07490559e8c85bb2b3c64eb2e960b118 Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Wed, 29 Jul 2026 01:59:48 +0200 Subject: [PATCH 01/13] feat(backend-ssh): add the SSH remote-agent provider crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference implementation of the `BackendKind::Provider` seam that `managed_agents/backend.rs` already describes and #2859 says has no OSS implementation. One process per op, no daemon, no state: the desktop writes one JSON request to stdin and reads one JSON response from stdout, and this crate turns that into ssh. Six modules, one protocol implementation: protocol the wire types and the `Secret` that renders `[REDACTED]` ssh the transport; every secret rides the stdin channel tailscale peer enumeration and the re-auth recovery URL discover `check`, `discover_harnesses`, `probe_models` install resolve-or-install for `buzz-acp` and the `buzz` CLI deploy the remote script, the env file, the systemd unit They are one commit because they are one protocol implementation, and because they do not separate: `deploy.rs` reaches into `install` at 43 call sites and `include_str!`s the unit template, and `discover` and `deploy` are mutually dependent (`string_list` one way, `env_map` and `is_well_formed_env_key` the other). Any smaller unit would be a file state that never compiled. Three invariants hold across every op, stated in `main.rs`'s module doc and enforced at named sites: 1. Secrets never reach a log, an error string, a `Debug` rendering, or an argv. They travel only inside the script body on the SSH stdin channel, because the remote `ps` is world-readable. 2. A deploy without the desktop-minted `private_key_nsec` fails closed, inside `Agent::from_request` so no caller can route around it. Env values are written through a control-character guard, because a newline would split the assignment into a second, attacker-chosen line. 3. Tailscale is an enhancement, never a dependency: absent, logged out, or empty, the `info` schema is byte-identical to the plain one. Trust-on-first-use is allowed for exactly one class of address — a device this machine's own Tailscale daemon already lists as a peer, which required a WireGuard-authenticated tunnel to reach. For anything the user typed, host-key checking stays strict. The re-auth URL is constructed, never parsed out of host output, so a compromised host cannot make the desktop open an arbitrary URL. Harness discovery extends upstream's `hermes` preset rather than replacing it. Because a host commonly runs several Hermes profiles out of one install, `discover_harnesses` fans that single preset out into `hermes-` entries carrying `--profile` args, so each configuration appears as its own harness. Profile names are untrusted input on their way into a catalog id and then an argument vector, so they are filtered against Hermes's own charset and capped — enforced twice, once in the script so the bytes are never sent and once on the way back in, because a host is free to ignore the script it was handed. The crate is deliberately NOT bundled with the desktop app. Provider discovery prepends the app bundle's own directory to the search path, so shipping it inside the bundle would give every install an auto-discovered SSH-deploy capability and quietly undermine the "Only use providers from trusted sources" warning the desktop already shows. It installs to `~/.local/bin`, which discovery already covers. 115 tests, no `unsafe`, no `unwrap`/`expect` on a production path. `base64` inherits from the workspace, matching the `sha2` line beside it and the comment above both, which already claimed the inheritance. Signed-off-by: Troy Hoffman --- Cargo.lock | 11 + Cargo.toml | 1 + Justfile | 6 + crates/buzz-backend-ssh/Cargo.toml | 31 + .../buzz-backend-ssh/assets/buzz-acp@.service | 24 + crates/buzz-backend-ssh/src/deploy.rs | 1962 +++++++++++++++++ crates/buzz-backend-ssh/src/discover.rs | 1266 +++++++++++ crates/buzz-backend-ssh/src/install.rs | 790 +++++++ crates/buzz-backend-ssh/src/main.rs | 161 ++ crates/buzz-backend-ssh/src/protocol.rs | 369 ++++ crates/buzz-backend-ssh/src/ssh.rs | 478 ++++ crates/buzz-backend-ssh/src/tailscale.rs | 473 ++++ 12 files changed, 5572 insertions(+) create mode 100644 crates/buzz-backend-ssh/Cargo.toml create mode 100644 crates/buzz-backend-ssh/assets/buzz-acp@.service create mode 100644 crates/buzz-backend-ssh/src/deploy.rs create mode 100644 crates/buzz-backend-ssh/src/discover.rs create mode 100644 crates/buzz-backend-ssh/src/install.rs create mode 100644 crates/buzz-backend-ssh/src/main.rs create mode 100644 crates/buzz-backend-ssh/src/protocol.rs create mode 100644 crates/buzz-backend-ssh/src/ssh.rs create mode 100644 crates/buzz-backend-ssh/src/tailscale.rs diff --git a/Cargo.lock b/Cargo.lock index 73dbd8c2512..a1e5d9b3c15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -965,6 +965,17 @@ dependencies = [ "tower", ] +[[package]] +name = "buzz-backend-ssh" +version = "0.1.0" +dependencies = [ + "base64", + "serde", + "serde_json", + "sha2 0.11.0", + "zeroize", +] + [[package]] name = "buzz-cli" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 98d68fb5215..d6651c07ef4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/buzz-audit", "crates/buzz-acp", "crates/buzz-agent", + "crates/buzz-backend-ssh", "crates/sprig", "crates/buzz-test-client", "crates/buzz-ws-client", diff --git a/Justfile b/Justfile index 2e62599dacf..f05ce21a4d8 100644 --- a/Justfile +++ b/Justfile @@ -319,6 +319,12 @@ test-unit: # because nothing in CI runs `cargo test --workspace` — workspace # membership alone buys clippy/check, not a single executed test. cargo nextest run -p buzz-backend-kubernetes + # Remote-deploy provider (buzz-backend-ssh). Infra-free: the deploy + # tests execute the generated script against a local /bin/sh with a + # stubbed HOME, no network. This is the only place the shell-injection + # canary runs — the Windows job's copy of these tests is #[cfg(unix)]d + # out — so dropping this step lets an injection regression ship green. + cargo nextest run -p buzz-backend-ssh else ./scripts/run-tests.sh unit fi diff --git a/crates/buzz-backend-ssh/Cargo.toml b/crates/buzz-backend-ssh/Cargo.toml new file mode 100644 index 00000000000..ebc58b26271 --- /dev/null +++ b/crates/buzz-backend-ssh/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "buzz-backend-ssh" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Buzz backend provider that deploys managed agents to a remote host over SSH" + +# Deliberately binary-only and deliberately NOT bundled with the desktop app +# (not in `tauri.conf.json` externalBin, not in `scripts/bundle-sidecars.sh`). +# `discover_provider_candidates` prepends the app bundle's own directory to the +# provider search path, so shipping this inside the bundle would give every +# install an auto-discovered SSH-deploy capability and quietly undermine the +# "Only use providers from trusted sources" warning the desktop shows. It is a +# release artifact the user installs to `~/.local/bin`, which is already on the +# discovery path. +[[bin]] +name = "buzz-backend-ssh" +path = "src/main.rs" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +zeroize = { workspace = true } +# Deploy can install `buzz-acp` on the host by streaming it inside the script +# that already travels on the SSH stdin channel. base64 is what keeps raw bytes +# from corrupting that stream; sha2 is what lets the host refuse a payload that +# arrived damaged. Both are already workspace dependencies. +base64 = { workspace = true } +sha2 = { workspace = true } diff --git a/crates/buzz-backend-ssh/assets/buzz-acp@.service b/crates/buzz-backend-ssh/assets/buzz-acp@.service new file mode 100644 index 00000000000..fb1f7414b83 --- /dev/null +++ b/crates/buzz-backend-ssh/assets/buzz-acp@.service @@ -0,0 +1,24 @@ +[Unit] +Description=Buzz agent %i +After=network-online.target +Wants=network-online.target +# A long-running agent must never be rate-limited into staying down: a unit +# held by the start limiter looks exactly like an agent that silently died, +# and only `systemctl reset-failed` clears it. +StartLimitIntervalSec=0 + +[Service] +Type=simple +# Holds the agent's minted nsec. Written by the provider with umask 077 and +# chmod 600; systemd reads it as the owning user. +EnvironmentFile=%h/.config/buzz-acp/%i.env +# Absolute path, substituted at install time from the host's resolved +# `buzz-acp`. systemd does not expand environment variables in the program +# position, and the shell indirection that would work around that is not worth +# adding to a unit whose environment carries a private key. +ExecStart=@BUZZ_ACP_BIN@ +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/crates/buzz-backend-ssh/src/deploy.rs b/crates/buzz-backend-ssh/src/deploy.rs new file mode 100644 index 00000000000..39508829bcf --- /dev/null +++ b/crates/buzz-backend-ssh/src/deploy.rs @@ -0,0 +1,1962 @@ +//! `deploy`: provision the agent as a `systemd --user` unit on the host. +//! +//! `--user` rather than a system unit keeps the flow root-free and puts the env +//! file beside the harness credentials that already live in the deploying +//! user's home (`~/.claude`, `~/.config/goose`). +//! +//! Deploy is also the *start* path — `start_managed_agent` re-enters +//! `deploy_to_provider` — so everything here must be idempotent. + +use std::collections::BTreeMap; +use std::time::Duration; + +use crate::install::{self, Payload, Tool}; +use crate::protocol::{Failure, Secret, SshConfig}; +use crate::ssh::{quote, Session}; + +/// The templated unit, installed once per host and instantiated per agent. +const UNIT_TEMPLATE: &str = include_str!("../assets/buzz-acp@.service"); + +/// Verbatim copy of the desktop's `env_vars::RESERVED_ENV_KEYS`. The desktop +/// already strips these from user env; re-checking here means a leak needs two +/// independent failures rather than one, and this binary ships and updates +/// separately from the desktop that fills the payload. +const RESERVED_ENV_KEYS: &[&str] = &[ + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", + "BUZZ_RELAY_URL", + "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", + "BUZZ_ACP_SETUP_PAYLOAD", + "BUZZ_MANAGED_AGENT", + "BUZZ_MANAGED_AGENT_START_NONCE", +]; + +/// The deploy payload, as `deploy_payload_json` serializes it. +/// +/// Deliberately not `Debug`. `Secret` redacts itself, but `env_vars` routinely +/// holds `ANTHROPIC_API_KEY` and friends in plain `String`s, so a derived +/// `Debug` would put provider credentials one `{:?}` away from a log line. +pub struct Agent { + pub name: String, + pub relay_url: String, + pub private_key_nsec: Secret, + pub auth_tag: Option, + /// The pinned harness command. See [`Agent::from_request`]. + pub agent_command: String, + pub agent_args: Vec, + pub system_prompt: Option, + pub model: Option, + pub provider: Option, + pub idle_timeout_seconds: Option, + pub max_turn_duration_seconds: Option, + pub parallelism: u64, + pub respond_to: String, + pub respond_to_allowlist: Vec, + pub env_vars: BTreeMap, + /// A path on the **desktop** machine to a Linux `buzz-acp` to install on + /// the host when the host resolves none. Optional, and absent it changes + /// nothing: deploy resolves `buzz-acp` on the host or fails with exit 90 + /// exactly as it always has. See [`crate::install`]. + pub buzz_acp_binary: Option, + /// The same, for the `buzz` CLI. A remote agent's own system prompt tells + /// it to answer with `buzz messages send --reply-to …`, and a local agent + /// gets that command because the desktop bundles the CLI and prepends + /// `~/.local/bin` to the spawned harness's `PATH`. This field is how the + /// remote side reaches the same parity. + /// + /// Unlike `buzz_acp_binary`, its absence on a host that has no CLI is a + /// warning rather than a failure: the harness runs without it. + pub buzz_cli_binary: Option, +} + +impl Agent { + pub fn from_request(request: &serde_json::Value) -> Result { + let agent = request.get("agent").ok_or("request is missing 'agent'")?; + let string = |key: &str| { + agent + .get(key) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) + }; + + let private_key_nsec: Secret = agent + .get("private_key_nsec") + .map(|v| serde_json::from_value(v.clone())) + .transpose() + .map_err(|_| "'private_key_nsec' must be a string".to_string())? + .unwrap_or_default(); + // Fail closed. A deploy that lets the host mint its own key produces an + // agent that looks deployed and is permanently unreachable: presence, + // mentions, `!shutdown`, badges and the NIP-OA auth tag all key off the + // pubkey the desktop minted. Mirrors the desktop's own + // `spawn_key_refusal`. + if private_key_nsec.is_empty() { + return Err( + "refusing to deploy without the agent's minted private key: the remote agent \ + would run under an identity no desktop surface recognizes" + .to_string(), + ); + } + + // The remote harness choice reaches the host ONLY as this pin — the + // desktop resolves it from the remote catalog at create time and ships + // it verbatim. A blank value means the pin was lost on the way, and the + // host would silently run `buzz-agent` instead of the harness the user + // picked, so refuse rather than substitute. + let agent_command = string("agent_command").ok_or( + "deploy payload carries no 'agent_command': the harness pin was lost before it \ + reached the host (see instanceInputForDefinition provider branch)", + )?; + + Ok(Self { + name: string("name").ok_or("'name' is required")?, + relay_url: string("relay_url").ok_or("'relay_url' is required")?, + private_key_nsec, + auth_tag: string("auth_tag"), + agent_command, + // `agent_args` must be the remote entry's default args. The + // desktop's local branch sends `[]` on purpose so spawn re-resolves + // them live, but a provider-backed record never spawns locally, so + // `[]` here would mean "no args" for any harness the local + // default-args table does not know. + agent_args: crate::discover::string_list(agent.get("agent_args")), + system_prompt: string("system_prompt"), + model: string("model"), + provider: string("provider"), + // `turn_timeout_seconds` is deliberately not read: the payload still + // carries it, but `BUZZ_ACP_TURN_TIMEOUT` is deprecated and ignored + // by the harness (`buzz-acp::config`), and local spawn does not + // write it either. `idle_timeout_seconds` and + // `max_turn_duration_seconds` are the live controls. + idle_timeout_seconds: agent.get("idle_timeout_seconds").and_then(|v| v.as_u64()), + max_turn_duration_seconds: agent + .get("max_turn_duration_seconds") + .and_then(|v| v.as_u64()), + parallelism: agent + .get("parallelism") + .and_then(|v| v.as_u64()) + .filter(|p| *p > 0) + .unwrap_or(1), + respond_to: string("respond_to").unwrap_or_else(|| "owner-only".to_string()), + respond_to_allowlist: crate::discover::string_list(agent.get("respond_to_allowlist")), + env_vars: env_map(agent.get("env_vars")), + // Read from the same `agent` block as everything else, but neither + // is agent configuration: nothing about them reaches the env file + // or the unit. They are the desktop handing the provider copies of + // the host-side tools to install if the host turns out not to have + // them. + buzz_acp_binary: string("buzz_acp_binary"), + buzz_cli_binary: string("buzz_cli_binary"), + }) + } + + /// The systemd instance name, and the `agent_id` the desktop persists in + /// `record.backend_agent_id`. + /// + /// It becomes both a filename and a unit instance name, so it follows the + /// desktop's own `util::slugify` rule. The hash suffix is not decoration: + /// the payload carries no stable agent identifier, and without it two + /// agents whose names differ only in punctuation would share one unit and + /// one env file. + pub fn slug(&self) -> String { + let sanitized: String = self + .name + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + // ASCII by construction, so a byte slice cannot split a character. + let stem = sanitized.trim_matches('-'); + let stem = &stem[..stem.len().min(32)]; + let stem = stem.trim_end_matches('-'); + let stem = if stem.is_empty() { "agent" } else { stem }; + format!("{stem}-{}", short_hash(&self.name)) + } + + pub fn agent_id(&self) -> String { + format!("buzz-acp@{}", self.slug()) + } +} + +/// FNV-1a, truncated. Only ever used to keep distinct names on distinct units. +fn short_hash(value: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in value.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{:08x}", hash as u32) +} + +pub fn env_map(value: Option<&serde_json::Value>) -> BTreeMap { + value + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(key, value)| Some((key.clone(), value.as_str()?.to_string()))) + .collect() + }) + .unwrap_or_default() +} + +/// A well-formed POSIX env var name. Mirrors the desktop's own boundary check; +/// a malformed key would let a value smuggle an extra assignment into the file, +/// or — on the left side of a shell `export`, where quoting cannot help — an +/// extra command. +pub fn is_well_formed_env_key(key: &str) -> bool { + !key.is_empty() + && !key.starts_with(|c: char| c.is_ascii_digit()) + && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// One `KEY="value"` line for a systemd `EnvironmentFile`. +/// +/// systemd unquotes C-style escapes inside double quotes, so `\` and `"` are +/// the two characters that must be escaped. Control characters are refused +/// outright: a newline would split the assignment into a second, attacker- +/// chosen line. +fn env_line(key: &str, value: &str) -> Result { + if value.chars().any(|c| c.is_control()) { + return Err(format!( + "env var '{key}' contains a control character and cannot be written to the unit's \ + environment file" + )); + } + let escaped = value.replace('\\', r"\\").replace('"', "\\\""); + Ok(format!("{key}=\"{escaped}\"\n")) +} + +/// The env file body: the local spawn contract from `runtime.rs`, transcribed. +/// +/// Values resolved on the host — the absolute harness path, `buzz-acp` itself, +/// `git-credential-nostr`, `PATH` — are appended by the remote script, not +/// here. Everything in this string is known locally. +fn env_file_body(agent: &Agent) -> Result { + let mut body = String::new(); + let mut push = |key: &str, value: &str| -> Result<(), String> { + body.push_str(&env_line(key, value)?); + Ok(()) + }; + + push("BUZZ_PRIVATE_KEY", agent.private_key_nsec.expose())?; + push("BUZZ_RELAY_URL", &agent.relay_url)?; + if let Some(auth_tag) = &agent.auth_tag { + push("BUZZ_AUTH_TAG", auth_tag)?; + } + push("BUZZ_ACP_AGENT_ARGS", &agent.agent_args.join(","))?; + // MCP does not reach the host yet: `mcp_command` is local catalog metadata, + // and mirroring that table here would drift. Empty rather than omitted, + // matching what local spawn writes when it does not apply. + push("BUZZ_ACP_MCP_COMMAND", "")?; + // Lazy defers the *pool warm*, not the process: buzz-acp connects, + // subscribes, and queues accepted work, then the first flushable event + // wakes all `BUZZ_ACP_AGENTS` slots. Nothing is dropped, and the one-shot + // cold start is cheaper than what eager costs here — a `Restart=always` + // unit re-pays N serial spawns on every restart, and a deployed-but-idle + // agent holds N harness subprocesses that are never reaped. That makes + // this the restore case (see `restore.rs`, "eager on restore buys + // nothing"), not the interactive-create case that spawns eager locally. + push("BUZZ_ACP_LAZY_POOL", "true")?; + push("BUZZ_ACP_AGENTS", &agent.parallelism.to_string())?; + push("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer")?; + push("BUZZ_ACP_DEDUP", "queue")?; + push("BUZZ_ACP_RELAY_OBSERVER", "true")?; + push("BUZZ_ACP_RESPOND_TO", &agent.respond_to)?; + if agent.respond_to == "allowlist" { + if agent.respond_to_allowlist.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist" + .to_string(), + ); + } + push( + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + &agent.respond_to_allowlist.join(","), + )?; + } + if let Some(prompt) = &agent.system_prompt { + push("BUZZ_ACP_SYSTEM_PROMPT", prompt)?; + } + if let Some(model) = &agent.model { + push("BUZZ_ACP_MODEL", model)?; + } + // The harness-native half of the same selection: `BUZZ_ACP_MODEL` is what + // buzz-acp reads, these are what the harness underneath it reads, and local + // spawn writes both. + for (key, value) in metadata_env(agent) { + push(key, value)?; + } + // Only when the user set them, so the harness's own defaults win otherwise. + if let Some(idle) = agent.idle_timeout_seconds { + push("BUZZ_ACP_IDLE_TIMEOUT", &idle.to_string())?; + } + if let Some(max_turn) = agent.max_turn_duration_seconds { + push("BUZZ_ACP_MAX_TURN_DURATION", &max_turn.to_string())?; + } + + // `BUZZ_MANAGED_AGENT` is deliberately absent: it is the desktop's marker + // for reclaiming orphaned local children, and systemd owns this lifecycle. + + // User env last, so it overrides everything above — systemd applies the + // later assignment for a repeated key, matching the local layering. + for (key, value) in &agent.env_vars { + if !is_well_formed_env_key(key) { + return Err(format!("env var name '{key}' is not a valid identifier")); + } + if RESERVED_ENV_KEYS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(key)) + { + return Err(format!( + "env var '{key}' is reserved and cannot be overridden" + )); + } + push(key, value)?; + } + Ok(body) +} + +/// The remote half of `runtime_metadata_env_vars` (`runtime.rs`). +/// +/// Local spawn writes the effective model and provider into each runtime's own +/// `model_env_var` / `provider_env_var`. Without this a remote Goose would see +/// `BUZZ_ACP_MODEL` but no `GOOSE_MODEL`, and fall back to whatever +/// `~/.config/goose/config.yaml` on the host says — the user's model pick +/// silently ignored. +/// +/// Keyed by command rather than harness id because the id is a create-time +/// desktop concept and the env file is written from the pin. Runtimes absent +/// here (Claude, Codex) declare no such vars in `KNOWN_ACP_RUNTIMES` either. +fn metadata_env(agent: &Agent) -> Vec<(&'static str, &str)> { + const RUNTIME_ENV: &[(&str, &str, &str)] = &[ + ("goose", "GOOSE_MODEL", "GOOSE_PROVIDER"), + ("buzz-agent", "BUZZ_AGENT_MODEL", "BUZZ_AGENT_PROVIDER"), + ]; + + // The pin may be a bare name or an absolute path; local spawn's + // `known_acp_runtime` matches on the file name either way. + let command = agent + .agent_command + .rsplit(['/', '\\']) + .next() + .unwrap_or(&agent.agent_command); + let Some((_, model_key, provider_key)) = + RUNTIME_ENV.iter().find(|(name, _, _)| *name == command) + else { + return Vec::new(); + }; + + [ + (*model_key, agent.model.as_deref()), + (*provider_key, agent.provider.as_deref()), + ] + .into_iter() + .filter_map(|(key, value)| Some((key, value?))) + .collect() +} + +/// `wss://relay` → `https://relay`, for the git credential helper's scope. +fn relay_http_base_url(relay_url: &str) -> String { + let trimmed = relay_url.trim().trim_end_matches('/'); + if let Some(rest) = trimmed.strip_prefix("wss://") { + format!("https://{rest}") + } else if let Some(rest) = trimmed.strip_prefix("ws://") { + format!("http://{rest}") + } else { + trimmed.to_string() + } +} + +/// The optional desktop-side copies of the host-side tools, already read and +/// encoded. Both default to absent, which is the case in which nothing about +/// the deploy changes. +#[derive(Default)] +struct Pushes { + acp: Option, + cli: Option, +} + +/// The `buzz-acp` command to resolve on the host: the operator's pin, or the +/// bare name. Shared by the probe and the deploy script so the two ask the same +/// question — see `install::resolve`. +fn acp_command(config: &SshConfig) -> String { + quote(config.buzz_acp_path.as_deref().unwrap_or(install::ACP.name)) +} + +/// The remote script. One round trip: resolve (or install), write, install, +/// start. +/// +/// Every secret reaches the host inside this script, which travels on the SSH +/// stdin channel. Nothing secret is ever an argument — not to `ssh`, and not to +/// any command the script runs — because the remote `ps` is world-readable. The +/// env file is written under `umask 077`, `chmod 600`, and moved into place +/// atomically. +/// +/// `push` carries the optional desktop-side copies of the two host-side tools. +/// `buzz-acp` is resolved *first*, so `$acp` — and therefore the unit's +/// `ExecStart` — names the copy this same pass installed. The pushed bytes are +/// not secret, but they share the stream with the minted nsec, so they travel +/// base64-encoded and never as raw bytes (`install`). +/// +/// The `buzz` CLI is resolved in the same pass and by the same machinery, with +/// one deliberate difference: a host that has neither the CLI nor a pushed copy +/// gets a `WARNING:` line and the deploy continues, because the harness does +/// not depend on the CLI (`install::Missing`). Nothing substitutes `$cli` into +/// the unit — the CLI is reached through the env file's `PATH`, which is what +/// makes the install destination resolvable for the harness's children. +fn deploy_script( + agent: &Agent, + config: &SshConfig, + unit: &str, + push: &Pushes, +) -> Result { + let slug = agent.slug(); + let acp = acp_command(config); + let command = quote(&agent.agent_command); + let relay_http = relay_http_base_url(&agent.relay_url); + let resolve_acp = install::resolve_or_install(install::ACP, &acp, push.acp.as_ref()); + let resolve_cli = + install::resolve_or_install(install::CLI, "e(install::CLI.name), push.cli.as_ref()); + + let mut script = String::from("set -eu\numask 077\n"); + // The harness name is bound once and thereafter referenced only as + // `"$harness_name"`. Interpolating it into the double-quoted error message + // would be a command-injection hole: `quote()` makes a value inert as an + // *argument*, but inside double quotes its single quotes are literal and a + // `$(...)` would still run. Expansion results are not re-scanned. + script.push_str(&format!( + 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" +units="$HOME/.config/systemd/user" +mkdir -p "$conf" "$units" +env_file="$conf/{slug}.env" +tmp="$env_file.new" +"# + )); + + // No body line can terminate the heredoc: every line is `KEY="..."` and + // `env_line` refuses control characters. The quoted delimiter suppresses + // expansion, so a value is never interpreted by the shell. + script.push_str("{\n"); + script.push_str("printf 'BUZZ_ACP_AGENT_COMMAND=\"%s\"\\n' \"$harness\"\n"); + // The harness's `PATH`, and the reason an installed `buzz` is a command the + // agent can actually run. + // + // This is the remote half of the desktop's own contract: local spawn + // prepends `~/.local/bin` to the spawned harness's `PATH` so the agent can + // run the CLI its system prompt tells it to reply with + // (`managed_agents::runtime::path::build_augmented_path`). The unit runs + // under `systemd --user`, whose `PATH` is the user manager's — no profile, + // no login shell, and on many distributions no `~/.local/bin` — so without + // this line the install destination is a directory the harness cannot name, + // and both tools would be installed and unreachable. + // + // It is composed HERE, by the host's shell, and not written into the unit + // as `Environment=PATH=$HOME/.local/bin:$PATH`: systemd expands no variable + // in `Environment=` or an `EnvironmentFile`, so that form would hand the + // harness the five literal characters `$PATH`. `$PATH` on the right is the + // non-interactive SSH shell's, captured at deploy time, which is the same + // `PATH` `install::resolve` just searched — so anything `command -v` found + // stays findable for the agent. + // + // `install::resolve` can only land on this `PATH` or on `~/.local/bin`, so + // these two segments cover every copy either tool can resolve to; there is + // nothing further to add from `$acp` or `$cli`. + script.push_str("printf 'PATH=\"%s\"\\n' \"$HOME/.local/bin:$PATH\"\n"); + 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"); + let use_http_path_key = format!("credential.{relay_http}/git.useHttpPath"); + let git_block: String = [ + ("NOSTR_PRIVATE_KEY", agent.private_key_nsec.expose()), + ("GIT_TERMINAL_PROMPT", "0"), + ("GIT_CONFIG_COUNT", "2"), + ("GIT_CONFIG_KEY_0", helper_key.as_str()), + ("GIT_CONFIG_KEY_1", use_http_path_key.as_str()), + ("GIT_CONFIG_VALUE_1", "true"), + ] + .into_iter() + .map(|(key, value)| env_line(key, value)) + .collect::>()?; + script.push_str(&format!( + r#"if [ -n "$cred" ]; then +printf 'GIT_CONFIG_VALUE_0="%s"\n' "$cred" +cat <<'BUZZ_GIT_EOF' +{git_block}BUZZ_GIT_EOF +fi +}} > "$tmp" +chmod 600 "$tmp" +mv "$tmp" "$env_file" +"# + )); + + // Without lingering the agent dies when this SSH session ends, which reads + // as a flaky agent rather than a configuration problem. It also creates + // `/run/user/$(id -u)`, so it must precede anything that talks to the user + // bus. Best-effort: some hosts gate it behind polkit, and failing it must + // not fail an otherwise good deploy. + // + // A non-interactive SSH command often gets no `XDG_RUNTIME_DIR`, without + // which every `systemctl --user` fails with "Failed to connect to bus". + script.push_str( + r#"loginctl enable-linger "$(id -un)" >/dev/null 2>&1 || true +if [ -z "${XDG_RUNTIME_DIR:-}" ]; then + XDG_RUNTIME_DIR="/run/user/$(id -u)" + export XDG_RUNTIME_DIR +fi +"#, + ); + + // Install the templated unit, reloading only when its content changed — a + // `daemon-reload` per start is noise, and a missing one after a change + // silently runs the old unit. + // + // `@BUZZ_ACP_BIN@` is substituted with parameter expansion rather than + // `sed`: `sed -i` is a GNU extension BSD and macOS hosts reject, and any + // `s///` would need a delimiter no resolved path can contain. + script.push_str(&format!( + r#"unit_file="$units/buzz-acp@.service" +template=$(cat <<'BUZZ_UNIT_EOF' +{unit}BUZZ_UNIT_EOF +) +printf '%s\n' "${{template%%@BUZZ_ACP_BIN@*}}$acp${{template#*@BUZZ_ACP_BIN@}}" > "$unit_file.new" +if cmp -s "$unit_file.new" "$unit_file"; then + rm -f "$unit_file.new" +else + mv "$unit_file.new" "$unit_file" + systemctl --user daemon-reload +fi +systemctl --user enable --now {service} >/dev/null +# Redeploy is also the start path, so an already-running unit must pick up the +# rewritten env file rather than be left on the old one. +systemctl --user restart {service} +"#, + service = quote(&format!("buzz-acp@{slug}.service")), + )); + Ok(script) +} + +/// The binaries to embed in this deploy's script, if any. +/// +/// Empty whenever the payload names no path — the default, and the case in +/// which nothing about deploy changes. Otherwise the host is asked first which +/// tools it already resolves, because **deploy is the start path**: without the +/// probe, a desktop with the seams engaged would encode and stream tens of +/// megabytes on every agent start, forever, to a host that has had the binaries +/// since the first deploy. Reading the files is skipped in that case too. +/// +/// One probe covers both tools, and it is skipped entirely when neither field +/// is set — so a payload with no push seams costs exactly the round trips it +/// always did. +/// +/// A probe that cannot be answered is not fatal: the binaries are embedded and +/// the script's own resolution makes the real decision on the host. +fn payloads_to_push( + agent: &Agent, + config: &SshConfig, + session: &Session, +) -> Result { + let candidates = [ + (install::ACP, acp_command(config), &agent.buzz_acp_binary), + ( + install::CLI, + quote(install::CLI.name), + &agent.buzz_cli_binary, + ), + ]; + let asked: Vec<(Tool, String)> = candidates + .iter() + .filter(|(_, _, path)| path.is_some()) + .map(|(tool, command, _)| (*tool, command.clone())) + .collect(); + if asked.is_empty() { + return Ok(Pushes::default()); + } + + let probe = session.run(&install::probe_script(&asked), Duration::from_secs(60))?; + // Read and validate before the deploy script is built: a bad path, a non-ELF + // file or an oversized one is the desktop's mistake, and it should be + // reported as that rather than as a remote failure mid-provisioning. That + // holds for the CLI too — the *absence* of a CLI is tolerable, but a + // desktop that pointed the seam at the wrong file has a bug worth naming. + let read = |tool: Tool, path: &Option| -> Result, String> { + match path.as_deref() { + Some(path) if !install::probe_found(&probe.stdout, tool) => { + Payload::read(tool, path).map(Some) + } + _ => Ok(None), + } + }; + Ok(Pushes { + acp: read(install::ACP, &agent.buzz_acp_binary)?, + cli: read(install::CLI, &agent.buzz_cli_binary)?, + }) +} + +pub fn deploy( + request: &serde_json::Value, + config: &SshConfig, + session: &Session, +) -> Result { + let agent = Agent::from_request(request)?; + let push = payloads_to_push(&agent, config, session)?; + let script = deploy_script(&agent, config, UNIT_TEMPLATE, &push)?; + let output = session.run(&script, Duration::from_secs(300))?; + if !output.ok() { + return Err(output.failure().into()); + } + // A successful deploy's remote stderr is otherwise dropped as host noise, + // so the script's non-fatal complaints — today, "this host has no buzz CLI" + // — would be invisible without this. They go to *this* process's stderr, + // which `invoke_provider` logs on success and shows in the error on + // failure, rather than into the response: the op succeeded, and a warning + // is not a result. + for warning in install::warnings(&output.stderr) { + eprintln!("buzz-backend-ssh: {warning}"); + } + Ok(serde_json::json!({ "ok": true, "agent_id": agent.agent_id() })) +} + +#[cfg(test)] +mod tests { + use super::*; + + const NSEC: &str = "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"; + + fn request() -> serde_json::Value { + serde_json::json!({ + "op": "deploy", + "provider_config": { "ssh_host": "vps", "ssh_user": "ubuntu" }, + "agent": { + "name": "Research Bot", + "relay_url": "wss://relay.example/ws", + "private_key_nsec": NSEC, + "auth_tag": "tag-abc", + "agent_command": "goose", + "agent_args": ["acp"], + "system_prompt": "be brief", + "model": "claude-sonnet-5", + "provider": "anthropic", + "parallelism": 3, + "respond_to": "owner-only", + "respond_to_allowlist": [], + "env_vars": { "ANTHROPIC_API_KEY": "sk-ant-secret" }, + }, + }) + } + + fn config() -> SshConfig { + SshConfig { + host: "vps".into(), + ..SshConfig::default() + } + } + + /// `Agent` is intentionally not `Debug` (see its doc comment), so tests + /// unwrap the error by hand rather than through `unwrap_err`. + fn rejection(request: &serde_json::Value) -> String { + match Agent::from_request(request) { + Err(error) => error, + Ok(agent) => panic!("expected a rejection, got agent {}", agent.agent_id()), + } + } + + #[test] + fn deploy_fails_closed_without_the_minted_key() { + let mut request = request(); + request["agent"]["private_key_nsec"] = serde_json::json!(""); + let error = rejection(&request); + assert!(error.contains("minted private key"), "{error}"); + + request["agent"] + .as_object_mut() + .unwrap() + .remove("private_key_nsec"); + assert!(rejection(&request).contains("minted private key")); + } + + #[test] + fn deploy_refuses_a_payload_whose_harness_pin_was_lost() { + // Without the pin the host would fall back to `buzz-agent` and the + // user's harness choice would vanish silently. + let mut request = request(); + request["agent"]["agent_command"] = serde_json::json!(""); + let error = rejection(&request); + assert!(error.contains("harness pin"), "{error}"); + } + + #[test] + fn the_pinned_harness_is_what_the_unit_runs() { + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + // Resolved to an absolute path on the HOST, and written into the env + // file systemd re-reads on every restart — that is what makes the pin + // durable rather than a one-shot argument. + assert!(script.contains("harness_name='goose'")); + assert!(script.contains(r#"harness=$(command -v "$harness_name""#)); + assert!(script.contains("printf 'BUZZ_ACP_AGENT_COMMAND=\"%s\"\\n' \"$harness\"")); + assert!(script.contains(r#"BUZZ_ACP_AGENT_ARGS="acp""#)); + // And a missing harness is a failure, never a substitution. + 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 + /// reach the host as ONE comma-joined `BUZZ_ACP_AGENT_ARGS` that `buzz-acp` + /// re-splits on `,` (`config.rs`, `value_delimiter`). That round trip is + /// only lossless because a profile name cannot contain a comma — which is + /// what `is_hermes_profile_name` guarantees — so pin the whole chain here + /// rather than trusting the two halves independently. + #[test] + fn a_hermes_profile_pin_reaches_the_host_intact() { + let mut request = request(); + request["agent"]["agent_command"] = serde_json::json!("hermes"); + request["agent"]["agent_args"] = + serde_json::json!(["--profile", "msig-web-analyst", "acp"]); + let agent = Agent::from_request(&request).unwrap(); + assert_eq!( + agent.agent_args, + ["--profile", "msig-web-analyst", "acp"], + "provider args are pinned verbatim, never re-resolved" + ); + + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + assert!(script.contains("harness_name='hermes'")); + assert!( + script.contains(r#"BUZZ_ACP_AGENT_ARGS="--profile,msig-web-analyst,acp""#), + "{script}" + ); + } + + #[test] + fn secrets_travel_in_the_script_body_and_never_on_an_argv() { + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + assert!(script.contains(&format!("BUZZ_PRIVATE_KEY=\"{NSEC}\""))); + assert!(script.contains(&format!("NOSTR_PRIVATE_KEY=\"{NSEC}\""))); + assert!(script.contains("ANTHROPIC_API_KEY=\"sk-ant-secret\"")); + + // Every secret-bearing line lives inside a quoted heredoc, so the + // remote shell never expands it and it never becomes an argument to + // anything. The commands the script *runs* carry no secret at all. + for line in script.lines() { + if line.contains(NSEC) || line.contains("sk-ant-secret") { + assert!( + line.starts_with("BUZZ_") + || line.starts_with("NOSTR_") + || line.starts_with("ANTHROPIC_"), + "secret escaped the heredoc body: {line}" + ); + } + } + assert!(script.contains("umask 077")); + assert!(script.contains("chmod 600 \"$tmp\"")); + } + + #[test] + fn the_env_file_transcribes_the_local_spawn_contract() { + let agent = Agent::from_request(&request()).unwrap(); + let body = env_file_body(&agent).unwrap(); + for expected in [ + r#"BUZZ_RELAY_URL="wss://relay.example/ws""#, + r#"BUZZ_AUTH_TAG="tag-abc""#, + r#"BUZZ_ACP_LAZY_POOL="true""#, + r#"BUZZ_ACP_AGENTS="3""#, + r#"BUZZ_ACP_MULTIPLE_EVENT_HANDLING="steer""#, + r#"BUZZ_ACP_DEDUP="queue""#, + r#"BUZZ_ACP_RELAY_OBSERVER="true""#, + r#"BUZZ_ACP_RESPOND_TO="owner-only""#, + r#"BUZZ_ACP_SYSTEM_PROMPT="be brief""#, + r#"BUZZ_ACP_MODEL="claude-sonnet-5""#, + r#"BUZZ_ACP_MCP_COMMAND="""#, + ] { + assert!(body.contains(expected), "missing {expected}"); + } + // Local process-ownership marker: meaningless where systemd owns the + // lifecycle, so it is never written. + assert!(!body.contains("BUZZ_MANAGED_AGENT")); + // Unset timeouts are omitted so the harness's own defaults win. + assert!(!body.contains("BUZZ_ACP_IDLE_TIMEOUT")); + assert!(!body.contains("BUZZ_ACP_MAX_TURN_DURATION")); + // No allowlist key unless the mode asks for one. + assert!(!body.contains("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + } + + #[test] + fn the_harness_sees_the_same_model_env_local_spawn_would_set() { + // `runtime_metadata_env_vars` parity: buzz-acp reads BUZZ_ACP_MODEL, + // but Goose itself reads GOOSE_MODEL/GOOSE_PROVIDER. Emitting only the + // former leaves the host's ~/.config/goose/config.yaml deciding the + // model, silently overriding the user's pick. + let body = env_file_body(&Agent::from_request(&request()).unwrap()).unwrap(); + assert!(body.contains(r#"GOOSE_MODEL="claude-sonnet-5""#), "{body}"); + assert!(body.contains(r#"GOOSE_PROVIDER="anthropic""#), "{body}"); + + // An absolute pin resolves to the same runtime — `known_acp_runtime` + // matches on the file name locally, so this must too. + let mut request = request(); + request["agent"]["agent_command"] = serde_json::json!("/home/ubuntu/.local/bin/goose"); + let body = env_file_body(&Agent::from_request(&request).unwrap()).unwrap(); + assert!(body.contains(r#"GOOSE_MODEL="claude-sonnet-5""#), "{body}"); + + // Runtimes that declare no model/provider env upstream get none here. + request["agent"]["agent_command"] = serde_json::json!("claude-code-acp"); + let body = env_file_body(&Agent::from_request(&request).unwrap()).unwrap(); + assert!(!body.contains("GOOSE_MODEL")); + assert!(body.contains(r#"BUZZ_ACP_MODEL="claude-sonnet-5""#)); + + // And an unset field writes no key at all, so the harness default wins. + request["agent"]["agent_command"] = serde_json::json!("goose"); + request["agent"]["provider"] = serde_json::json!(""); + let body = env_file_body(&Agent::from_request(&request).unwrap()).unwrap(); + assert!(body.contains("GOOSE_MODEL")); + assert!(!body.contains("GOOSE_PROVIDER")); + } + + #[test] + fn the_deprecated_turn_timeout_is_never_written() { + // The payload still carries `turn_timeout_seconds` (upstream + // `deploy_payload_json`), but `BUZZ_ACP_TURN_TIMEOUT` is deprecated and + // ignored by the harness, and local spawn does not write it either. + let mut request = request(); + request["agent"]["turn_timeout_seconds"] = serde_json::json!(320); + let body = env_file_body(&Agent::from_request(&request).unwrap()).unwrap(); + assert!(!body.contains("TURN_TIMEOUT"), "{body}"); + } + + #[test] + fn timeouts_are_emitted_only_when_set() { + let mut request = request(); + request["agent"]["idle_timeout_seconds"] = serde_json::json!(900); + request["agent"]["max_turn_duration_seconds"] = serde_json::json!(3600); + let body = env_file_body(&Agent::from_request(&request).unwrap()).unwrap(); + assert!(body.contains(r#"BUZZ_ACP_IDLE_TIMEOUT="900""#)); + assert!(body.contains(r#"BUZZ_ACP_MAX_TURN_DURATION="3600""#)); + } + + #[test] + fn user_env_is_written_last_so_it_overrides() { + let mut request = request(); + request["agent"]["env_vars"] = serde_json::json!({ "GOOSE_MODE": "auto" }); + let body = env_file_body(&Agent::from_request(&request).unwrap()).unwrap(); + let user = body.find("GOOSE_MODE").unwrap(); + assert!(body.find("BUZZ_ACP_MODEL").unwrap() < user); + } + + #[test] + fn reserved_and_malformed_env_keys_are_refused() { + 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(); + assert!(error.contains("reserved"), "{key}: {error}"); + } + let mut request = request(); + request["agent"]["env_vars"] = serde_json::json!({ "BAD KEY": "x" }); + assert!(env_file_body(&Agent::from_request(&request).unwrap()) + .unwrap_err() + .contains("not a valid identifier")); + } + + #[test] + fn env_values_cannot_forge_an_extra_assignment() { + // A newline would end the assignment and start a line of the value's + // own choosing — including a line that re-sets a reserved key. + let error = env_line("X", "a\nBUZZ_PRIVATE_KEY=nsec1evil").unwrap_err(); + assert!(error.contains("control character")); + // Quotes and backslashes are escaped rather than refused. + assert_eq!(env_line("X", r#"a"b\c"#).unwrap(), "X=\"a\\\"b\\\\c\"\n"); + } + + #[test] + fn allowlist_mode_requires_an_allowlist() { + let mut request = request(); + request["agent"]["respond_to"] = serde_json::json!("allowlist"); + assert!(env_file_body(&Agent::from_request(&request).unwrap()).is_err()); + + request["agent"]["respond_to_allowlist"] = serde_json::json!(["abc123"]); + let body = env_file_body(&Agent::from_request(&request).unwrap()).unwrap(); + assert!(body.contains(r#"BUZZ_ACP_RESPOND_TO_ALLOWLIST="abc123""#)); + } + + #[test] + fn slugs_are_unit_safe_and_stable_across_redeploys() { + let agent = Agent::from_request(&request()).unwrap(); + let slug = agent.slug(); + assert!( + slug.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'), + "{slug}" + ); + assert!(slug.starts_with("research-bot-")); + // Redeploy is the start path: the same name must yield the same unit + // and the same agent_id, or start would provision a duplicate. + assert_eq!(slug, Agent::from_request(&request()).unwrap().slug()); + assert_eq!(agent.agent_id(), format!("buzz-acp@{slug}")); + } + + #[test] + fn names_that_sanitize_alike_still_get_distinct_units() { + let named = |name: &str| { + let mut request = request(); + request["agent"]["name"] = serde_json::json!(name); + Agent::from_request(&request).unwrap().slug() + }; + assert_ne!(named("Bot!"), named("Bot?")); + // A name with nothing usable still produces a legal instance name. + let empty = named("!!!"); + assert!(empty.starts_with("agent-"), "{empty}"); + } + + #[test] + fn redeploy_is_idempotent() { + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + // One templated unit per host, reloaded only when its content changed. + assert!(script.contains(r#"unit_file="$units/buzz-acp@.service""#)); + assert!(script.contains(r#"if cmp -s "$unit_file.new" "$unit_file""#)); + assert_eq!(script.matches("daemon-reload").count(), 1); + // Enable is idempotent; restart makes an already-running unit adopt the + // rewritten env file. + assert!(script.contains("systemctl --user enable --now 'buzz-acp@research-bot-")); + assert!(script.contains("systemctl --user restart 'buzz-acp@research-bot-")); + // The env file is replaced atomically, so a failed write never leaves a + // half-written identity behind. + assert!(script.contains(r#"mv "$tmp" "$env_file""#)); + } + + #[test] + fn the_unit_template_substitutes_a_resolved_buzz_acp() { + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + assert!(UNIT_TEMPLATE.contains("ExecStart=@BUZZ_ACP_BIN@")); + assert!(UNIT_TEMPLATE.contains("EnvironmentFile=%h/.config/buzz-acp/%i.env")); + // Substitution is parameter expansion, not `sed`: `sed -i` is a GNU + // extension that BSD and macOS hosts reject. + assert!(!script.contains("sed ")); + assert!(script.contains("${template%%@BUZZ_ACP_BIN@*}$acp${template#*@BUZZ_ACP_BIN@}")); + // Lingering, or the agent dies when this SSH session ends. It also + // creates /run/user/$(id -u), so it must precede any bus traffic. + let linger = script.find("loginctl enable-linger").unwrap(); + assert!(linger < script.find("systemctl --user").unwrap()); + // A non-interactive SSH command often has no XDG_RUNTIME_DIR, and + // without it every `systemctl --user` fails to reach the bus. + assert!(script.contains(r#"if [ -z "${XDG_RUNTIME_DIR:-}" ]; then"#)); + } + + /// Run the generated script against a real `/bin/sh` in a sandbox, with + /// `systemctl`/`loginctl` stubbed out. + /// + /// Substring assertions prove the script *says* the right things; only + /// executing it proves it *is* a valid shell program that produces the + /// right files. Everything below — quoting, heredoc framing, the + /// `@BUZZ_ACP_BIN@` expansion, `set -eu` interactions — is the kind of + /// defect no `contains` check catches. + fn run_deploy_script( + sandbox: &str, + request: &serde_json::Value, + ) -> (std::process::Output, std::path::PathBuf) { + let root = sandbox_host(sandbox, HostAcp::Installed); + let agent = Agent::from_request(request).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + (run_in_sandbox(&root, &script), root) + } + + /// Whether the sandboxed "host" already has `buzz-acp` on its PATH. The + /// install path only engages on a host that does not. + #[derive(PartialEq)] + enum HostAcp { + Installed, + Missing, + } + + /// Build the fake host: a `$HOME` with a stubbed `bin` on its PATH. + fn sandbox_host(sandbox: &str, acp: HostAcp) -> std::path::PathBuf { + // Named per test rather than keyed on the thread id, which the test + // harness recycles once a thread finishes. + let root = + std::env::temp_dir().join(format!("buzz-deploy-{}-{sandbox}", std::process::id())); + let bin = root.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + // Stub every host binary the script resolves, so the run is hermetic. + let mut stubs = vec![ + ("goose", "#!/bin/sh\nexit 0\n"), + ("git-credential-nostr", "#!/bin/sh\nexit 0\n"), + // Record the systemd calls instead of making them. + ( + "systemctl", + "#!/bin/sh\nprintf 'systemctl %s\\n' \"$*\" >> \"$HOME/systemd.log\"\n", + ), + ( + "loginctl", + "#!/bin/sh\nprintf 'loginctl %s\\n' \"$*\" >> \"$HOME/systemd.log\"\n", + ), + ]; + if acp == HostAcp::Installed { + stubs.push(("buzz-acp", "#!/bin/sh\nexit 0\n")); + } + // Note `buzz` is NOT stubbed: the sandbox host has no CLI unless a test + // seeds one with `seed_stub`, so every run through here also exercises + // the degradation path — a warning, and a deploy that still succeeds. + for (name, body) in stubs { + seed_stub(&bin, name, body); + } + root + } + + /// Drop an executable stub into `dir`. + fn seed_stub(dir: &std::path::Path, name: &str, body: &str) -> std::path::PathBuf { + std::fs::create_dir_all(dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, body).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + path + } + + /// Feed `script` to a real `/bin/sh` exactly as `ssh` feeds it to the + /// remote one: on stdin, with the sandbox as `$HOME`. + fn run_in_sandbox(root: &std::path::Path, script: &str) -> std::process::Output { + let bin = root.join("bin"); + std::process::Command::new("/bin/sh") + .arg("-s") + .env_clear() + .env("HOME", root) + .env("PATH", format!("{}:/usr/bin:/bin", bin.display())) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(script.as_bytes()) + .unwrap(); + child.wait_with_output() + }) + .unwrap() + } + + #[cfg(unix)] + #[test] + fn the_generated_script_actually_runs_and_provisions_the_host() { + let (output, root) = run_deploy_script("provision", &request()); + assert!( + output.status.success(), + "deploy script failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let slug = Agent::from_request(&request()).unwrap().slug(); + let env_file = root.join(".config/buzz-acp").join(format!("{slug}.env")); + let written = std::fs::read_to_string(&env_file).unwrap(); + + // The harness pin, resolved to an absolute path on the host, and + // written where systemd re-reads it on every restart. + assert!( + written.contains(&format!( + "BUZZ_ACP_AGENT_COMMAND=\"{}\"", + root.join("bin/goose").display() + )), + "{written}" + ); + 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!( + "GIT_CONFIG_VALUE_0=\"{}\"", + root.join("bin/git-credential-nostr").display() + ))); + assert!(written.contains(&format!("NOSTR_PRIVATE_KEY=\"{NSEC}\""))); + // Every line is a well-formed assignment: no heredoc marker leaked in, + // and no value split across lines. + for line in written.lines() { + assert!( + line.split_once('=') + .is_some_and(|(_, v)| v.starts_with('"') && v.ends_with('"') && v.len() >= 2), + "malformed env line: {line}" + ); + } + + // Only the owner can read the minted key. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&env_file).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "env file is group/world accessible"); + } + + // The unit landed with a real path in ExecStart, and no placeholder. + let unit = + std::fs::read_to_string(root.join(".config/systemd/user/buzz-acp@.service")).unwrap(); + assert!(unit.contains(&format!( + "ExecStart={}", + root.join("bin/buzz-acp").display() + ))); + assert!(!unit.contains("@BUZZ_ACP_BIN@")); + assert!(!root + .join(".config/systemd/user/buzz-acp@.service.new") + .exists()); + + let calls = std::fs::read_to_string(root.join("systemd.log")).unwrap(); + assert!(calls.contains("loginctl enable-linger")); + assert!(calls.contains("systemctl --user daemon-reload")); + assert!(calls.contains(&format!( + "systemctl --user enable --now buzz-acp@{slug}.service" + ))); + assert!(calls.contains(&format!("systemctl --user restart buzz-acp@{slug}.service"))); + } + + #[cfg(unix)] + #[test] + fn a_second_deploy_reuses_the_unit_and_skips_the_reload() { + let (first, root) = run_deploy_script("redeploy", &request()); + assert!(first.status.success()); + std::fs::remove_file(root.join("systemd.log")).unwrap(); + + // Redeploy is the start path, so this is what `start_managed_agent` + // does on every start. The unit content is unchanged, so systemd must + // not be reloaded — but the env file must still be rewritten and the + // service restarted onto it. + let (second, _) = run_deploy_script("redeploy", &request()); + assert!( + second.status.success(), + "redeploy failed: {}", + String::from_utf8_lossy(&second.stderr) + ); + let calls = std::fs::read_to_string(root.join("systemd.log")).unwrap(); + assert!(!calls.contains("daemon-reload"), "{calls}"); + assert!(calls.contains("restart"), "{calls}"); + } + + #[cfg(unix)] + #[test] + fn a_missing_harness_stops_the_deploy_before_anything_is_written() { + let mut request = request(); + request["agent"]["agent_command"] = serde_json::json!("not-installed-anywhere"); + let (output, root) = run_deploy_script("missing-harness", &request); + assert_eq!(output.status.code(), Some(91)); + assert!(String::from_utf8_lossy(&output.stderr).contains("not-installed-anywhere")); + // `set -e` plus ordering: nothing is provisioned on a failed resolve. + assert!(!root.join(".config/buzz-acp").exists()); + } + + #[cfg(unix)] + #[test] + fn shell_metacharacters_in_the_payload_stay_inert() { + // Regression: `quote()` makes a value inert as an *argument*, but + // interpolating that quoted form into a double-quoted string (an error + // message, say) leaves its single quotes literal and lets a `$(...)` + // in the payload execute. Every field below is attacker-influenced, so + // this test runs the script for real and checks that none of the + // command substitutions fired. + let canary = std::env::temp_dir().join(format!("buzz-pwned-{}", std::process::id())); + let _ = std::fs::remove_file(&canary); + let payload = format!("$(touch {})", canary.display()); + + let mut request = request(); + request["agent"]["name"] = serde_json::json!(format!("bot {payload}")); + request["agent"]["agent_command"] = serde_json::json!(payload); + request["agent"]["relay_url"] = serde_json::json!(format!("wss://relay/{payload}")); + request["agent"]["model"] = serde_json::json!(payload.clone()); + request["agent"]["env_vars"] = serde_json::json!({ "EVIL": payload.clone() }); + + let (output, _) = run_deploy_script("injection", &request); + // The harness does not exist, so the deploy stops — the point is that + // it stops without having executed the payload. + assert_eq!(output.status.code(), Some(91)); + assert!( + !canary.exists(), + "payload executed on the host: command injection in the deploy script" + ); + + // And the slug stays a legal systemd instance name regardless. + let agent = Agent::from_request(&request).unwrap(); + assert!(agent + .slug() + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')); + } + + /// A binary to push: a legal ELF header followed by every byte sequence + /// that would end a heredoc, escape the script, or run a command if the + /// transport were anything other than base64. Both tools' delimiters are in + /// there, so the same bytes are hostile to whichever tool carries them. + fn canary_binary(canary: &std::path::Path) -> Vec { + let mut bytes = b"\x7fELF\x02\x01\x01\x00".to_vec(); + bytes.extend_from_slice(format!("$(touch {})\n", canary.display()).as_bytes()); + bytes.extend_from_slice(format!("`touch {}`\n", canary.display()).as_bytes()); + bytes.extend_from_slice(b"BUZZ_ACP_B64_EOF\nrm -rf \"$HOME\"\n"); + bytes.extend_from_slice(b"BUZZ_CLI_B64_EOF\nrm -rf \"$HOME\"\n"); + bytes.extend_from_slice(b"\0'\"\r\n$HOME ${HOME}\n"); + bytes.extend_from_slice(&(0u8..=255).collect::>()); + bytes + } + + fn push_payload(tool: Tool, name: &str, bytes: &[u8]) -> Payload { + let path = std::env::temp_dir().join(format!( + "buzz-push-{}-{}-{name}", + tool.name, + std::process::id() + )); + std::fs::write(&path, bytes).unwrap(); + Payload::read(tool, &path.display().to_string()).unwrap() + } + + /// The `Pushes` a desktop that set only `BUZZ_ACP_PUSH_BINARY` produces. + fn acp_push(payload: Payload) -> Pushes { + Pushes { + acp: Some(payload), + cli: None, + } + } + + #[test] + fn the_pushed_binaries_are_optional_fields_that_change_nothing_when_absent() { + // The seams must be invisible: a payload without the fields produces + // the script the crate produced before they existed. + let mut request = request(); + let agent = Agent::from_request(&request).unwrap(); + assert!(agent.buzz_acp_binary.is_none()); + assert!(agent.buzz_cli_binary.is_none()); + let without = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + assert!(without.contains("exit 90")); + assert!(!without.contains("base64 -d")); + + // A blank string is "absent", not "push nothing". + for field in ["buzz_acp_binary", "buzz_cli_binary"] { + request["agent"][field] = serde_json::json!(" "); + } + let agent = Agent::from_request(&request).unwrap(); + assert!(agent.buzz_acp_binary.is_none()); + assert!(agent.buzz_cli_binary.is_none()); + + request["agent"]["buzz_acp_binary"] = serde_json::json!("/opt/buzz-acp"); + request["agent"]["buzz_cli_binary"] = serde_json::json!("/opt/buzz"); + let agent = Agent::from_request(&request).unwrap(); + assert_eq!(agent.buzz_acp_binary, Some("/opt/buzz-acp".to_string())); + assert_eq!(agent.buzz_cli_binary, Some("/opt/buzz".to_string())); + } + + /// The exact-equality pin: with both fields absent the script is *byte* + /// identical to the one the crate emitted before either seam existed — + /// modulo the CLI resolution block, which is unconditional and therefore + /// spelled out here in full rather than asserted about. + /// + /// Substring assertions cannot see an accidental extra line; this can. + #[test] + fn the_script_for_a_payload_with_neither_field_is_pinned_exactly() { + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + let slug = agent.slug(); + + let expected = format!( + r#"set -eu +umask 077 +harness_name='goose' +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 +cred=$(command -v git-credential-nostr 2>/dev/null || true) +conf="$HOME/.config/buzz-acp" +units="$HOME/.config/systemd/user" +mkdir -p "$conf" "$units" +env_file="$conf/{slug}.env" +tmp="$env_file.new" +{{ +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' +NOSTR_PRIVATE_KEY="{NSEC}" +GIT_TERMINAL_PROMPT="0" +GIT_CONFIG_COUNT="2" +GIT_CONFIG_KEY_0="credential.https://relay.example/ws/git.helper" +GIT_CONFIG_KEY_1="credential.https://relay.example/ws/git.useHttpPath" +GIT_CONFIG_VALUE_1="true" +BUZZ_GIT_EOF +fi +}} > "$tmp" +chmod 600 "$tmp" +mv "$tmp" "$env_file" +loginctl enable-linger "$(id -un)" >/dev/null 2>&1 || true +if [ -z "${{XDG_RUNTIME_DIR:-}}" ]; then + XDG_RUNTIME_DIR="/run/user/$(id -u)" + export XDG_RUNTIME_DIR +fi +unit_file="$units/buzz-acp@.service" +template=$(cat <<'BUZZ_UNIT_EOF' +{unit}BUZZ_UNIT_EOF +) +printf '%s\n' "${{template%%@BUZZ_ACP_BIN@*}}$acp${{template#*@BUZZ_ACP_BIN@}}" > "$unit_file.new" +if cmp -s "$unit_file.new" "$unit_file"; then + rm -f "$unit_file.new" +else + mv "$unit_file.new" "$unit_file" + systemctl --user daemon-reload +fi +systemctl --user enable --now 'buzz-acp@{slug}.service' >/dev/null +# Redeploy is also the start path, so an already-running unit must pick up the +# rewritten env file rather than be left on the old one. +systemctl --user restart 'buzz-acp@{slug}.service' +"#, + env = env_file_body(&agent).unwrap(), + unit = UNIT_TEMPLATE, + ); + assert_eq!(script, expected); + } + + #[test] + fn a_pushed_binary_never_displaces_the_secret_discipline() { + let canary = std::env::temp_dir().join("buzz-never"); + let payload = push_payload(install::ACP, "discipline", &canary_binary(&canary)); + let sha256 = payload.sha256().to_string(); + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &acp_push(payload)).unwrap(); + + // The install block is additive: everything the secret path relies on + // is still exactly where it was. + assert!(script.starts_with("set -eu\numask 077\n")); + assert!(script.contains("chmod 600 \"$tmp\"")); + assert!(script.contains(&format!("BUZZ_PRIVATE_KEY=\"{NSEC}\""))); + // The binary is resolved/installed BEFORE the unit is templated, so + // `$acp` — and therefore ExecStart — names the copy just installed. + let install = script.find("base64 -d").unwrap(); + assert!(install < script.find("unit_file=").unwrap()); + // The hash travels in the clear (it is a fingerprint, not a secret) and + // the encoded bytes carry nothing the shell reads as syntax. + assert!(script.contains(&sha256)); + } + + #[cfg(unix)] + #[test] + fn a_pushed_binary_installs_atomically_and_only_after_it_verifies() { + let canary = std::env::temp_dir().join(format!("buzz-push-pwned-{}", std::process::id())); + let _ = std::fs::remove_file(&canary); + let bytes = canary_binary(&canary); + let payload = push_payload(install::ACP, "install", &bytes); + + // A host with no `buzz-acp` at all — the only case the push engages. + let root = sandbox_host("install", HostAcp::Missing); + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &acp_push(payload)).unwrap(); + let output = run_in_sandbox(&root, &script); + assert!( + output.status.success(), + "install deploy failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Byte-identical after a round trip through base64, a heredoc, and a + // real `/bin/sh` — including the NULs, quotes, `$(...)` and the literal + // heredoc delimiter embedded in the payload. + let installed = root.join(".local/bin/buzz-acp"); + assert_eq!(std::fs::read(&installed).unwrap(), bytes); + assert!( + !canary.exists(), + "the pushed binary's contents executed on the host" + ); + + // Executable, and no temp file left behind. + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&installed).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o755, "installed binary is not 755"); + assert!(!leftover_temp_files(&root.join(".local/bin"), install::ACP)); + + // And the unit points at the copy this pass installed, in the same + // deploy — install first, resolve second. + let unit = + std::fs::read_to_string(root.join(".config/systemd/user/buzz-acp@.service")).unwrap(); + assert!( + unit.contains(&format!("ExecStart={}", installed.display())), + "{unit}" + ); + } + + #[cfg(unix)] + #[test] + fn a_corrupted_push_aborts_before_the_mv_and_leaves_nothing_runnable() { + let canary = std::env::temp_dir().join("buzz-never"); + let payload = push_payload(install::ACP, "mismatch", &canary_binary(&canary)); + let sha256 = payload.sha256().to_string(); + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &acp_push(payload)).unwrap(); + // Stand in for a payload damaged in flight: the host is told to expect + // a digest the decoded bytes cannot produce. + let script = script.replace(&sha256, &"a".repeat(64)); + + let root = sandbox_host("mismatch", HostAcp::Missing); + let output = run_in_sandbox(&root, &script); + assert_eq!(output.status.code(), Some(94)); + assert!(String::from_utf8_lossy(&output.stderr).contains("sha256")); + + // Nothing installed, and — the property that matters — no half-written + // executable left in the directory systemd's ExecStart would name. + assert!(!root.join(".local/bin/buzz-acp").exists()); + assert!(!leftover_temp_files(&root.join(".local/bin"), install::ACP)); + // The deploy stopped there: no env file, no unit. + assert!(!root.join(".config/buzz-acp").exists()); + assert!(!root.join(".config/systemd").exists()); + } + + #[cfg(unix)] + #[test] + fn a_host_without_buzz_acp_and_no_pushed_binary_still_fails_with_todays_guidance() { + // The un-pushed path is unchanged: exit 90 and the same message, so a + // user who never sets the seam sees exactly what they saw before. + let root = sandbox_host("no-acp", HostAcp::Missing); + 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(90)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("buzz-acp not found on the server's PATH"), + "{stderr}" + ); + assert!(!root.join(".local/bin/buzz-acp").exists()); + assert!(!root.join(".config/buzz-acp").exists()); + } + + #[cfg(unix)] + #[test] + fn an_existing_host_binary_is_never_replaced_by_the_pushed_one() { + // Staleness rule: push-when-missing only. Deploy is the start path, so + // a version-comparing rule would reinstall underneath a running fleet + // on every start — and a desktop pinned to an older artifact would + // downgrade the host. + let canary = std::env::temp_dir().join("buzz-never"); + let payload = push_payload(install::ACP, "keep", &canary_binary(&canary)); + let root = sandbox_host("keep", HostAcp::Installed); + let existing = std::fs::read(root.join("bin/buzz-acp")).unwrap(); + + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &acp_push(payload)).unwrap(); + let output = run_in_sandbox(&root, &script); + assert!( + output.status.success(), + "deploy failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + assert_eq!(std::fs::read(root.join("bin/buzz-acp")).unwrap(), existing); + assert!( + !root.join(".local/bin/buzz-acp").exists(), + "a host that already had buzz-acp got a second copy installed" + ); + let unit = + std::fs::read_to_string(root.join(".config/systemd/user/buzz-acp@.service")).unwrap(); + assert!(unit.contains(&format!( + "ExecStart={}", + root.join("bin/buzz-acp").display() + ))); + } + + #[cfg(unix)] + #[test] + fn a_second_deploy_keeps_the_binary_the_first_one_installed() { + // The install destination — `~/.local/bin` — is NOT on a + // non-interactive SSH PATH, which is exactly why the env file below + // pins `PATH="$HOME/.local/bin:$PATH"` itself. Resolution has to say so + // too: with a bare `command -v`, the probe answered "missing" forever + // and every deploy re-streamed and replaced the binary. Deploy is the + // start path, so that is every agent start, underneath a running fleet. + // + // `an_existing_host_binary_is_never_replaced_by_the_pushed_one` cannot + // see this: it seeds the stub into the sandbox's `bin`, which IS on the + // sandbox PATH. + use std::os::unix::fs::MetadataExt; + + let canary = std::env::temp_dir().join("buzz-never"); + let payload = push_payload(install::ACP, "twice", &canary_binary(&canary)); + let root = sandbox_host("twice", HostAcp::Missing); + let agent = Agent::from_request(&request()).unwrap(); + let installed = root.join(".local/bin/buzz-acp"); + + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &acp_push(payload)).unwrap(); + let first = run_in_sandbox(&root, &script); + assert!( + first.status.success(), + "first deploy failed: {}", + String::from_utf8_lossy(&first.stderr) + ); + let inode = std::fs::metadata(&installed).unwrap().ino(); + + // What the desktop asks before deploy #2. It must now answer "the host + // has it", which is what keeps the payload off the wire — the file is + // not even read, let alone encoded and streamed. + let probe = run_in_sandbox( + &root, + &install::probe_script(&[(install::ACP, acp_command(&config()))]), + ); + assert!( + install::probe_found(&String::from_utf8_lossy(&probe.stdout), install::ACP), + "the probe did not see the binary the previous deploy installed" + ); + + // And even the worst case — a script that still carries the payload — + // resolves to the installed copy instead of replacing it. + let second = run_in_sandbox(&root, &script); + assert!( + second.status.success(), + "second deploy failed: {}", + String::from_utf8_lossy(&second.stderr) + ); + assert_eq!( + std::fs::metadata(&installed).unwrap().ino(), + inode, + "the second deploy replaced the binary the first one installed" + ); + + // The unit still names it, so idempotence is real and not just quiet. + let unit = + std::fs::read_to_string(root.join(".config/systemd/user/buzz-acp@.service")).unwrap(); + assert!( + unit.contains(&format!("ExecStart={}", installed.display())), + "{unit}" + ); + } + + #[cfg(unix)] + #[test] + fn a_configured_absolute_path_still_resolves_the_copy_deploy_installed() { + // `buzz_acp_path` is an absolute path the operator picked, but an + // install always lands in `~/.local/bin`. Resolving only what was + // configured would never find it, so the host would re-install on every + // single start, forever. + use std::os::unix::fs::MetadataExt; + + let canary = std::env::temp_dir().join("buzz-never"); + let payload = push_payload(install::ACP, "configured", &canary_binary(&canary)); + let root = sandbox_host("configured", HostAcp::Missing); + let config = SshConfig { + buzz_acp_path: Some("/opt/buzz-acp".into()), + ..config() + }; + let agent = Agent::from_request(&request()).unwrap(); + let installed = root.join(".local/bin/buzz-acp"); + + let script = deploy_script(&agent, &config, UNIT_TEMPLATE, &acp_push(payload)).unwrap(); + assert!(run_in_sandbox(&root, &script).status.success()); + let inode = std::fs::metadata(&installed).unwrap().ino(); + + let probe = run_in_sandbox( + &root, + &install::probe_script(&[(install::ACP, acp_command(&config))]), + ); + assert!( + install::probe_found(&String::from_utf8_lossy(&probe.stdout), install::ACP), + "the probe missed the install because the configured path is elsewhere" + ); + assert!(run_in_sandbox(&root, &script).status.success()); + assert_eq!(std::fs::metadata(&installed).unwrap().ino(), inode); + } + + #[cfg(unix)] + #[test] + fn a_payload_that_decodes_to_garbage_aborts_before_anything_is_installed() { + let canary = std::env::temp_dir().join("buzz-never"); + let payload = push_payload(install::ACP, "decode", &canary_binary(&canary)); + let head = payload.encoded()[..8].to_string(); + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &acp_push(payload)).unwrap(); + // Stand in for a stream truncated in flight. `!` is outside the base64 + // alphabet, so `base64 -d` rejects the body outright — the exit-93 + // branch, which the sha256 test cannot reach because a payload that + // fails to decode never gets as far as being hashed. + let corrupt = script.replacen(&head, "!!!!!!!!", 1); + assert_ne!(corrupt, script, "the encoded body was not corrupted"); + + let root = sandbox_host("decode", HostAcp::Missing); + let output = run_in_sandbox(&root, &corrupt); + assert_eq!(output.status.code(), Some(93)); + assert!(String::from_utf8_lossy(&output.stderr).contains("decode")); + assert!(!root.join(".local/bin/buzz-acp").exists()); + assert!(!leftover_temp_files(&root.join(".local/bin"), install::ACP)); + // The `|| { ... }` really does bind to the heredoc-fed command: the + // deploy stopped here rather than running on with a corrupt file. + assert!(!root.join(".config/systemd").exists()); + } + + #[test] + fn a_payload_that_names_both_binaries_carries_both() { + // The two tools share one script and one pass. They must not share a + // heredoc delimiter, a temp file or a shell variable, or the second + // body would terminate the first and the host would be handed a + // half-decoded binary as commands. + let canary = std::env::temp_dir().join("buzz-never"); + let bytes = canary_binary(&canary); + let acp = push_payload(install::ACP, "both-acp", &bytes); + let cli = push_payload(install::CLI, "both-cli", &bytes); + let agent = Agent::from_request(&request()).unwrap(); + let push = Pushes { + acp: Some(acp), + cli: Some(cli), + }; + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &push).unwrap(); + + for delimiter in ["BUZZ_ACP_B64_EOF", "BUZZ_CLI_B64_EOF"] { + assert_eq!( + script.matches(&format!("<<'{delimiter}'")).count(), + 1, + "one heredoc opener per tool" + ); + } + assert!(script.contains(r#"acp_tmp="$acp_dir/.buzz-acp.tmp.$$""#)); + assert!(script.contains(r#"cli_tmp="$cli_dir/.buzz.tmp.$$""#)); + assert!(script.contains(r#"mv "$acp_tmp" "$acp_dir/buzz-acp""#)); + assert!(script.contains(r#"mv "$cli_tmp" "$cli_dir/buzz""#)); + // Order still holds: the harness is resolved (or installed) before the + // unit is templated from `$acp`, and the CLI never reaches the unit. + let acp_install = script.find(r#"mv "$acp_tmp""#).unwrap(); + let cli_install = script.find(r#"mv "$cli_tmp""#).unwrap(); + let unit = script.find("unit_file=").unwrap(); + assert!(acp_install < cli_install); + assert!(cli_install < unit); + // Nothing substitutes the CLI into the unit — it is reached through the + // env file's PATH, not through `ExecStart`. + assert!(!script.contains("@BUZZ_CLI_BIN@")); + assert!(!script[unit..].contains("$cli")); + } + + #[cfg(unix)] + #[test] + fn a_pushed_cli_lands_where_the_harness_can_run_it() { + // The gap this whole change exists to close: a remote agent is told by + // its own system prompt to answer with `buzz messages send`, and the + // SSH deploy shipped only `buzz-acp`. Install it, and — the half that + // makes the install worth anything — leave it on the `PATH` the unit + // hands the harness. + let canary = std::env::temp_dir().join(format!("buzz-cli-pwned-{}", std::process::id())); + let _ = std::fs::remove_file(&canary); + let bytes = canary_binary(&canary); + let payload = push_payload(install::CLI, "cli-install", &bytes); + + // A host that HAS buzz-acp: the CLI install is the only thing under + // test, and the deploy must run all the way through to the restart. + let root = sandbox_host("cli-install", HostAcp::Installed); + let agent = Agent::from_request(&request()).unwrap(); + // The `Pushes` a desktop that set only `BUZZ_CLI_PUSH_BINARY` produces. + let pushes = Pushes { + acp: None, + cli: Some(payload), + }; + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &pushes).unwrap(); + let output = run_in_sandbox(&root, &script); + assert!( + output.status.success(), + "cli deploy failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + // Nothing to warn about: the CLI is there now. + assert!( + install::warnings(&String::from_utf8_lossy(&output.stderr)).is_empty(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + + // Byte-identical through base64, a heredoc and a real `/bin/sh` — + // NULs, quotes, `$(...)` and both delimiters included. + let installed = root.join(".local/bin/buzz"); + assert_eq!(std::fs::read(&installed).unwrap(), bytes); + assert!( + !canary.exists(), + "the pushed CLI's contents executed on the host" + ); + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&installed).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o755, "the installed CLI is not executable"); + assert!(!leftover_temp_files(&root.join(".local/bin"), install::CLI)); + + // The CLI is reached through the env file, never through the unit: the + // harness's `PATH` starts at the install destination. + let slug = agent.slug(); + let env_file = root.join(".config/buzz-acp").join(format!("{slug}.env")); + let written = std::fs::read_to_string(&env_file).unwrap(); + let path_line = written + .lines() + .find(|line| line.starts_with("PATH=")) + .unwrap_or_else(|| panic!("no PATH line in the env file:\n{written}")); + assert!( + path_line.starts_with(&format!("PATH=\"{}", root.join(".local/bin").display())), + "{path_line}" + ); + let unit = + std::fs::read_to_string(root.join(".config/systemd/user/buzz-acp@.service")).unwrap(); + assert!(!unit.contains("/.local/bin/buzz\""), "{unit}"); + } + + #[cfg(unix)] + #[test] + fn the_env_file_path_is_the_install_destination_ahead_of_the_hosts_own() { + // Local spawn prepends `~/.local/bin` to the harness's PATH + // (`managed_agents::runtime::path::build_augmented_path`); this is the + // remote half of that contract, and it is the only reason an installed + // tool is a command the agent can name. `systemd --user` expands + // nothing in an `EnvironmentFile`, so the line has to be composed by + // the host's shell at deploy time — which is what this proves: the + // value is literal, resolved, and still carries the host's own PATH. + let (output, root) = run_deploy_script("path-line", &request()); + assert!( + output.status.success(), + "deploy failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let slug = Agent::from_request(&request()).unwrap().slug(); + let written = + std::fs::read_to_string(root.join(".config/buzz-acp").join(format!("{slug}.env"))) + .unwrap(); + let path_line = written + .lines() + .find(|line| line.starts_with("PATH=")) + .unwrap_or_else(|| panic!("no PATH line in the env file:\n{written}")); + assert_eq!( + path_line, + format!( + "PATH=\"{}:{}:/usr/bin:/bin\"", + root.join(".local/bin").display(), + root.join("bin").display() + ), + "{written}" + ); + // Not the five literal characters systemd would hand through verbatim. + assert!(!written.contains("$PATH")); + assert!(!written.contains("$HOME")); + } + + #[cfg(unix)] + #[test] + fn a_host_without_the_cli_deploys_anyway_and_says_what_was_lost() { + // The asymmetry, end to end. `buzz-acp` missing stops the deploy; the + // CLI missing must not — the harness does not depend on it — but it + // cannot be silent either, or the operator learns about it the way this + // change was discovered: by watching an agent hunt the filesystem. + let root = sandbox_host("no-cli", HostAcp::Installed); + 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(), + "a missing CLI stopped the deploy: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Provisioned in full: the agent runs, it just replies the slow way. + let slug = agent.slug(); + assert!(root + .join(".config/buzz-acp") + .join(format!("{slug}.env")) + .exists()); + let calls = std::fs::read_to_string(root.join("systemd.log")).unwrap(); + assert!(calls.contains(&format!("systemctl --user restart buzz-acp@{slug}.service"))); + + // And the one line `deploy` forwards to the desktop names both what is + // missing and how to fix it. + let stderr = String::from_utf8_lossy(&output.stderr); + let warnings = install::warnings(&stderr); + assert_eq!(warnings.len(), 1, "{stderr}"); + assert!(warnings[0].contains("buzz messages send"), "{stderr}"); + assert!(warnings[0].contains("BUZZ_CLI_PUSH_BINARY"), "{stderr}"); + assert!(!root.join(".local/bin/buzz").exists()); + } + + /// Any `..tmp.*` still sitting in `dir`. A half-written binary that + /// survives a failed install is the failure mode the temp-file dance exists + /// to prevent, and it is per-tool because the two install into the same + /// directory. + fn leftover_temp_files(dir: &std::path::Path, tool: Tool) -> bool { + let prefix = format!(".{}.tmp.", tool.name); + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + entries + .filter_map(Result::ok) + .any(|entry| entry.file_name().to_string_lossy().starts_with(&prefix)) + } + + #[test] + fn relay_urls_map_to_their_http_origin_for_git_auth() { + assert_eq!( + relay_http_base_url("wss://relay.example/"), + "https://relay.example" + ); + assert_eq!( + relay_http_base_url("ws://localhost:8080"), + "http://localhost:8080" + ); + assert_eq!( + relay_http_base_url("https://relay.example"), + "https://relay.example" + ); + } + + #[test] + fn git_credential_env_is_emitted_only_when_the_helper_exists() { + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + assert!(script.contains(r#"cred=$(command -v git-credential-nostr 2>/dev/null || true)"#)); + assert!(script.contains(r#"if [ -n "$cred" ]; then"#)); + assert!( + script.contains(r#"GIT_CONFIG_KEY_0="credential.https://relay.example/ws/git.helper""#) + ); + } +} diff --git a/crates/buzz-backend-ssh/src/discover.rs b/crates/buzz-backend-ssh/src/discover.rs new file mode 100644 index 00000000000..da9700b74b7 --- /dev/null +++ b/crates/buzz-backend-ssh/src/discover.rs @@ -0,0 +1,1266 @@ +//! `check`, `discover_harnesses` and `probe_models`: everything that reads the +//! remote host without changing it. +//! +//! All three are **one SSH round trip**. `discover_harnesses` in particular +//! probes every candidate harness from a single generated script — N sequential +//! `ssh` invocations would spend the whole 45s budget on handshakes over a +//! 200 ms link, and the harness picker would visibly hang. + +use std::time::Duration; + +use crate::protocol::{snippet, Failure, SshConfig}; +use crate::ssh::{quote, Session}; + +/// Harnesses the desktop knows how to render, in the same vocabulary its local +/// catalog uses (`KNOWN_ACP_RUNTIMES` + `PRESET_HARNESSES` in +/// `managed_agents/discovery.rs`). Ids must satisfy `[a-z0-9_][a-z0-9_-]*` or +/// `validate_harness_definition` drops the entry desktop-side. +struct Candidate { + id: &'static str, + label: &'static str, + /// Accepted command names, most preferred first. + commands: &'static [&'static str], + args: &'static [&'static str], + /// The runtime's `default_env` (`discovery.rs`). Locally these are applied + /// at spawn time from the catalog; a remote agent never spawns locally, so + /// they must ride along in the `HarnessDefinition` the desktop pins, or + /// they are simply lost. + env: &'static [(&'static str, &'static str)], +} + +const CANDIDATES: &[Candidate] = &[ + Candidate { + id: "buzz-agent", + label: "Buzz Agent", + commands: &["buzz-agent"], + args: &[], + env: &[], + }, + Candidate { + id: "goose", + label: "Goose", + commands: &["goose"], + args: &["acp"], + // Without this a remote Goose blocks on tool approvals that nobody is + // present to answer, and the agent silently stops making progress. + env: &[("GOOSE_MODE", "auto")], + }, + Candidate { + id: "claude", + label: "Claude Code", + commands: &["claude-agent-acp", "claude-code-acp"], + args: &[], + env: &[], + }, + Candidate { + id: "codex", + label: "Codex", + commands: &["codex-acp"], + args: &[], + env: &[], + }, + Candidate { + id: "cursor", + label: "Cursor", + commands: &["cursor-agent"], + args: &["acp"], + env: &[], + }, + Candidate { + id: "omp", + label: "Oh My Pi", + commands: &["omp"], + args: &["acp"], + env: &[], + }, + Candidate { + id: "grok", + label: "Grok Build", + commands: &["grok"], + args: &["agent", "--always-approve", "stdio"], + env: &[], + }, + Candidate { + id: "opencode", + label: "OpenCode", + commands: &["opencode"], + args: &["acp"], + env: &[], + }, + Candidate { + id: "kimi", + label: "Kimi Code", + commands: &["kimi"], + args: &["acp"], + env: &[], + }, + Candidate { + id: "amp", + label: "Amp", + commands: &["amp-acp"], + args: &[], + env: &[], + }, + Candidate { + id: "hermes", + label: "Hermes Agent", + commands: &["hermes-acp"], + args: &[], + env: &[], + }, + Candidate { + id: "openclaw", + label: "OpenClaw", + commands: &["openclaw"], + args: &["acp"], + env: &[], + }, +]; + +/// Preamble shared by every remote script. +/// +/// `probe` writes one tab-separated record per resolved command, rather than +/// JSON assembled in `sh`: quoting arbitrary `--version` output into valid JSON +/// from a POSIX shell is a bug farm, and the parsing belongs on the Rust side +/// where it is testable. +/// +/// Two details are load-bearing. `/dev/null 2>&1; then _t="timeout 5"; else _t=""; fi +probe() { + _p=$(command -v "$2" 2>/dev/null) || return 0 + [ -n "$_p" ] || return 0 + _v=$($_t "$2" --version /dev/null | head -n 1 | tr -d '\t\r') || _v="" + printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$_p" "$_v" +} +"#; + +/// Probe key for the Hermes CLI itself, as opposed to the `hermes-acp` shim the +/// `hermes` [`Candidate`] resolves. Deliberately not any candidate's id, so the +/// twelve catalog entries are unaffected by its presence or absence. +const HERMES_CLI_KEY: &str = "hermes-cli"; + +/// Prefix of a profile record: `hermes-profile`. +/// +/// Two fields, not the four a `probe` record carries, so [`parse_probes`] drops +/// these on its own (`path` is `None`) and the two streams cannot be confused. +const HERMES_PROFILE_PREFIX: &str = "hermes-profile\t"; + +/// Per-profile entries emitted for one host. A pathological (or hostile) host +/// with thousands of directories under `profiles/` must not turn the harness +/// picker into an unusable wall, and `Output` caps the whole response at 1 MB +/// regardless — which would fail the op rather than truncate it. +/// +/// Enforced twice on purpose: the script stops early so the bytes are never +/// sent, and [`hermes_profiles`] re-applies it because remote stdout is +/// untrusted input and a host is free to ignore the script it was handed. +const MAX_HERMES_PROFILES: usize = 32; + +/// Hermes profile enumeration, appended to the probe script. +/// +/// Hermes runs N isolated instances out of one install — a profile is a whole +/// `HERMES_HOME`, selected by the **global** pre-subcommand flag +/// (`hermes --profile matt acp`). The operator's fleet is one profile per +/// teammate, so a host has to advertise one catalog entry per profile or nine +/// of the ten agents on it are unreachable from the picker. +/// +/// The profile store is read from the filesystem rather than from +/// `hermes profile list`: the directory layout **is** what Hermes resolves a +/// profile against (`hermes_cli/profiles.py`: `get_profile_dir` → +/// `/profiles/`), while the CLI output is a human table with a +/// unicode default marker and no `--json`. Parsing that table would be reading +/// a rendering of the truth instead of the truth. +/// +/// `` is normally `~/.hermes`. `HERMES_HOME` overrides it for Docker and +/// custom deployments, and may itself already point *at* a profile — hence the +/// `*/profiles/*` trim, which recovers the root from both layouts exactly as +/// `hermes_constants.get_default_hermes_root` does. +/// +/// The `default` profile is the root directory itself and never appears under +/// `profiles/`, so it is emitted separately. It earns its own entry because the +/// plain `hermes-acp` entry runs whatever profile is *sticky*: once the operator +/// runs `hermes profile use matt`, nothing else can pin the built-in profile. +/// +/// The shell never *evaluates* a name (only `printf '%s'`), and +/// [`is_hermes_profile_name`] re-checks every name that survives — but the +/// `case` charset arms are **not** merely a prefilter. They are the only thing +/// that stops a name containing a newline from printing a second, unlabeled +/// line that [`parse_probes`] accepts as a four-field probe record for any +/// candidate it names: such a line carries no `hermes-profile\t` prefix, so +/// [`hermes_profiles`] never sees it and no Rust-side check applies. Removing +/// them lets a hostile directory name pin an arbitrary +/// `BUZZ_ACP_AGENT_COMMAND`. Pinned by +/// `a_profile_directory_name_cannot_forge_a_probe_record`. +fn hermes_profiles_block() -> String { + format!( + r#"if _hb=$(command -v hermes 2>/dev/null) && [ -n "$_hb" ]; then + _hr=${{HERMES_HOME:-${{HOME:-}}/.hermes}} + case "$_hr" in */profiles/*) _hr=${{_hr%/profiles/*}} ;; esac + _hc=0 + if [ -d "$_hr" ]; then + printf 'hermes-profile\tdefault\n' + _hc=1 + fi + for _hd in "$_hr"/profiles/*/; do + [ "$_hc" -lt {cap} ] || break + [ -d "$_hd" ] || continue + _hn=${{_hd%/}} + _hn=${{_hn##*/}} + case "$_hn" in + default) continue ;; + *[!abcdefghijklmnopqrstuvwxyz0123456789_-]*) continue ;; + [!abcdefghijklmnopqrstuvwxyz0123456789]*) continue ;; + esac + _hc=$((_hc + 1)) + printf 'hermes-profile\t%s\n' "$_hn" + done +fi +: +"#, + cap = MAX_HERMES_PROFILES, + ) +} + +/// The one probe script: `buzz-acp`, every harness candidate, and — only where +/// `hermes` resolves — that host's Hermes profiles. +fn discover_script(config: &SshConfig) -> String { + let mut script = String::from(PROBE_PREAMBLE); + let acp = config.buzz_acp_path.as_deref().unwrap_or("buzz-acp"); + script.push_str(&format!("probe 'buzz-acp' {}\n", quote(acp))); + for candidate in CANDIDATES { + for command in candidate.commands { + script.push_str(&format!( + "probe {} {}\n", + quote(candidate.id), + quote(command) + )); + } + } + // The Hermes CLI, which is what a per-profile entry runs — the `hermes-acp` + // shim takes no arguments of its own, so it cannot carry `--profile`. + // + // `hermes --version` costs ~0.7s against the 40s budget. Fine for one, but + // the probes are sequential: further CLI probes need to be weighed against + // that budget rather than simply appended. + script.push_str(&format!("probe {} 'hermes'\n", quote(HERMES_CLI_KEY))); + script.push_str(&hermes_profiles_block()); + script +} + +/// A profile name this crate is willing to put in a catalog id and in +/// `agent_args`. +/// +/// The authority for the rule the script only prefilters. Names arrive from a +/// directory listing on a machine the desktop does not control, so they are +/// untrusted input on their way into a JSON document and then into an argument +/// vector — the exact shape of a name is the whole security surface. +/// +/// The charset is Hermes's own `_PROFILE_ID_RE` (`^[a-z0-9][a-z0-9_-]{0,63}$`), +/// which is also, not by coincidence, a subset of the desktop's harness-id rule +/// `[a-z0-9_][a-z0-9_-]*` — so `hermes-` is always a legal id and +/// `validate_harness_definition` cannot silently drop the entry. Anything else +/// is skipped rather than sanitized: a mangled name would name a profile that +/// does not exist, and deploy an agent pointing at nothing. +fn is_hermes_profile_name(name: &str) -> bool { + if name.len() > 64 { + return false; + } + let mut chars = name.chars(); + // `let-else` also covers the empty name: no first character, no match. + let Some(first) = chars.next() else { + return false; + }; + if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { + return false; + } + chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') +} + +/// The accepted profile names from one probe run, in host order, deduplicated +/// and capped. +/// +/// Returns `(names, skipped)` — `skipped` counts records the charset rule +/// refused, which is worth surfacing rather than swallowing: it is the only +/// signal that a host has profiles the picker deliberately did not offer. +fn hermes_profiles(stdout: &str) -> (Vec<&str>, usize) { + let mut names: Vec<&str> = Vec::new(); + let mut skipped = 0usize; + for line in stdout.lines() { + let Some(name) = line.strip_prefix(HERMES_PROFILE_PREFIX) else { + continue; + }; + if !is_hermes_profile_name(name) { + skipped += 1; + continue; + } + if names.contains(&name) { + continue; + } + if names.len() >= MAX_HERMES_PROFILES { + skipped += 1; + continue; + } + names.push(name); + } + (names, skipped) +} + +/// One `probe` record: `keycommandpathversion`. +struct Probe<'a> { + key: &'a str, + command: &'a str, + path: &'a str, + version: &'a str, +} + +fn parse_probes(stdout: &str) -> Vec> { + stdout + .lines() + .filter_map(|line| { + let mut fields = line.splitn(4, '\t'); + let probe = Probe { + key: fields.next()?, + command: fields.next()?, + path: fields.next()?, + version: fields.next().unwrap_or(""), + }; + (!probe.key.is_empty() && !probe.path.is_empty()).then_some(probe) + }) + .collect() +} + +/// Shape the probe records into the `discover_harnesses` response. +/// +/// Every element is a `HarnessDefinition` (camelCase, exactly the desktop's +/// own wire type) plus `available` / `binaryPath` / `version`. Unresolved +/// candidates are still reported, with `available: false`, so the picker can +/// say "install this on the host" instead of hiding the option. +fn harnesses_response(stdout: &str) -> serde_json::Value { + let probes = parse_probes(stdout); + let buzz_acp = probes + .iter() + .find(|probe| probe.key == "buzz-acp") + .map(|probe| serde_json::json!({ "path": probe.path, "version": probe.version })) + .unwrap_or(serde_json::Value::Null); + + let mut harnesses: Vec = CANDIDATES + .iter() + .map(|candidate| { + let found = probes.iter().find(|probe| probe.key == candidate.id); + serde_json::json!({ + "id": candidate.id, + "label": candidate.label, + // The remote command name. This is what the desktop pins as the + // create-time harness override, so it must name a binary on the + // HOST, never one resolved locally. + "command": found.map_or(candidate.commands[0], |probe| probe.command), + "args": candidate.args, + "env": candidate + .env + .iter() + .map(|(key, value)| ((*key).to_string(), serde_json::Value::from(*value))) + .collect::>(), + "installInstructionsUrl": "", + "installHint": "", + "available": found.is_some(), + "binaryPath": found.map(|probe| probe.path), + "version": found.map(|probe| probe.version).filter(|v| !v.is_empty()), + }) + }) + .collect(); + + harnesses.extend(hermes_profile_harnesses(&probes, stdout)); + + // `buzz_acp: null` with `ok: true` is deliberate: the UI can then render an + // actionable "install buzz-acp on this host" instead of a bare failure. + serde_json::json!({ "ok": true, "buzz_acp": buzz_acp, "harnesses": harnesses }) +} + +/// One extra catalog entry per Hermes profile on the host. +/// +/// A profile is a separate `HERMES_HOME` — its own SOUL, memory, skills, +/// credentials and gateway — so "Hermes (matt)" and "Hermes (paul)" are two +/// different agents, not one agent configured twice. The operator's whole fleet +/// is one profile per teammate, and without these entries the picker can only +/// ever pin the *sticky* profile, leaving the rest unreachable through the +/// normal create flow. +/// +/// The command is `hermes` with `["--profile", , "acp"]` rather than the +/// `hermes-acp` shim: `--profile` is a global pre-subcommand flag, and the shim +/// forwards no arguments of its own. The pin therefore has to name the CLI +/// directly, which is also why the entries only appear when `hermes` itself +/// resolved — a host with only the shim gets exactly the plain entry. +/// +/// The plain `hermes` candidate stays as-is and remains the default option: it +/// runs whichever profile is sticky, which is what a single-profile host wants. +/// +/// These are the only entries that carry `exclusive: true`. `claude`, `codex` +/// and the plain `hermes-acp` shim are ephemeral runners — deploying one of +/// them N times against a host is the normal, intended shape. A profile is the +/// opposite: it is a persistent IDENTITY (its own memory, sessions, credentials +/// and nostr history), so two Buzz agents pinned to the same profile are two +/// puppeteers driving one body — they interleave turns into the same session +/// store. The flag is what lets the desktop refuse the second one; the provider +/// only states the fact, and says nothing about how it is rendered. +fn hermes_profile_harnesses(probes: &[Probe<'_>], stdout: &str) -> Vec { + // No Hermes CLI on the host means no way to pass `--profile`, so no + // per-profile entries — regardless of what the profile records claim. + let Some(cli) = probes.iter().find(|probe| probe.key == HERMES_CLI_KEY) else { + return Vec::new(); + }; + let (names, skipped) = hermes_profiles(stdout); + if skipped > 0 { + // stderr, never stdout: stdout is this provider's single JSON response. + eprintln!( + "buzz-backend-ssh: skipped {skipped} Hermes profile(s) whose names are not \ + [a-z0-9][a-z0-9_-]* or that exceeded the {MAX_HERMES_PROFILES}-profile cap" + ); + } + + names + .into_iter() + .map(|name| { + serde_json::json!({ + // `hermes-` + a validated name, so this always satisfies the + // desktop's `[a-z0-9_][a-z0-9_-]*` harness-id rule. + "id": format!("hermes-{name}"), + "label": format!("Hermes ({name})"), + "command": cli.command, + "args": ["--profile", name, "acp"], + "env": serde_json::Map::new(), + "installInstructionsUrl": "", + "installHint": "", + // A persistent identity, not an ephemeral runner: at most one + // agent may be pinned to this exact command+args. Emitted ONLY + // here — every other entry omits the key, and an absent key + // means "deploy as many as you like". + "exclusive": true, + // The profile directory was listed and the CLI resolved this + // pass, so the entry is as available as the plain one. + "available": true, + "binaryPath": cli.path, + "version": Some(cli.version).filter(|v| !v.is_empty()), + }) + }) + .collect() +} + +pub fn discover_harnesses( + config: &SshConfig, + session: &Session, +) -> Result { + let output = session.run(&discover_script(config), Duration::from_secs(40))?; + if !output.ok() { + return Err(output.failure().into()); + } + Ok(harnesses_response(&output.stdout)) +} + +/// `check`: the preflight the create dialog runs before Deploy goes live. +pub fn check(session: &Session) -> Result { + let output = session.run("echo buzz-ok\n", Duration::from_secs(8))?; + if output.stdout.trim() == "buzz-ok" { + return Ok(serde_json::json!({ "ok": true, "detail": "Connected" })); + } + Err(guidance(&output.failure()).into()) +} + +/// Turn ssh's own diagnosis into something the user can act on. The classified +/// causes are the ones that actually happen; everything else passes through +/// verbatim rather than being flattened into a generic message. +/// +/// Deliberately no entry for the Tailscale re-auth prompt: `run` returns that +/// as a typed [`Failure`] carrying the URL, so it never reaches this classifier +/// — which is the point of the typed carrier. +fn guidance(failure: &str) -> String { + const GUIDANCE: &[(&str, &str)] = &[ + ("permission denied", "add your public key to ~/.ssh/authorized_keys on the server, or run `tailscale set --ssh` there."), + ("host key verification failed", "the server's host key is not in your known_hosts. Connect once with `ssh` to review and accept it."), + ("could not resolve hostname", "check the address, or confirm the device is on your tailnet."), + ("connection refused", "confirm the server is reachable and running an SSH daemon."), + ("connection timed out", "confirm the server is reachable and running an SSH daemon."), + ]; + + let lower = failure.to_lowercase(); + match GUIDANCE.iter().find(|(cause, _)| lower.contains(cause)) { + Some((_, advice)) => format!("{failure} — {advice}"), + None => failure.to_string(), + } +} + +/// `probe_models`: run `buzz-acp models --json` on the host and hand the raw +/// document back untouched. +/// +/// Verbatim is the point: the desktop feeds it straight into the same +/// `normalize_agent_models` the local path uses, so the model picker needs no +/// remote-specific code at all. +pub fn probe_models( + request: &serde_json::Value, + config: &SshConfig, + session: &Session, +) -> Result { + let output = session.run(&models_script(request, config)?, Duration::from_secs(110))?; + if !output.ok() { + return Err(output.failure().into()); + } + let models_raw: serde_json::Value = + serde_json::from_str(output.stdout.trim()).map_err(|e| { + format!( + "`buzz-acp models --json` did not return JSON ({e}): {}", + snippet(&output.stdout) + ) + })?; + Ok(serde_json::json!({ "ok": true, "models_raw": models_raw })) +} + +fn models_script(request: &serde_json::Value, config: &SshConfig) -> Result { + let harness = request + .get("harness") + .ok_or("probe_models request is missing 'harness'")?; + let command = harness + .get("command") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or("probe_models harness is missing 'command'")?; + let args = string_list(harness.get("args")); + + // `agent.env_vars` is the only place `env_secrets_from_request` + // (backend.rs) looks when scrubbing these values out of an error surface, + // so a top-level `model_env` would travel unredacted through any failure + // message. Still accepted, since the transport is safe either way; it just + // loses that second layer. + let model_env = request + .get("agent") + .and_then(|agent| agent.get("env_vars")) + .or_else(|| request.get("model_env")); + + let mut script = String::from("set -u\n"); + // Model-probe env carries provider API keys, set inside the + // stdin-delivered script so they never appear in the remote argv. + // + // Names are validated rather than quoted: on the left of an assignment + // quoting has no effect, so an unchecked name is a straight command + // injection (`X=1; touch /tmp/pwn`). Quoting is sufficient for values. + for (key, value) in crate::deploy::env_map(model_env) { + if !crate::deploy::is_well_formed_env_key(&key) { + return Err(format!("env var name '{key}' is not a valid identifier")); + } + script.push_str(&format!("export {}={}\n", key, quote(&value))); + } + script.push_str(&format!( + "export BUZZ_ACP_AGENT_COMMAND={}\nexport BUZZ_ACP_AGENT_ARGS={}\n", + quote(command), + quote(&args.join(",")) + )); + script.push_str(&format!( + "exec {} models --json ) -> Vec { + value + .and_then(|v| v.as_array()) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str()) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> SshConfig { + SshConfig { + host: "vps".into(), + ..SshConfig::default() + } + } + + #[test] + fn discover_is_one_script_covering_every_candidate() { + let script = discover_script(&config()); + assert!(script.contains("probe 'buzz-acp' 'buzz-acp'")); + for candidate in CANDIDATES { + for command in candidate.commands { + assert!( + script.contains(&format!("probe '{}' '{command}'", candidate.id)), + "missing probe for {command}" + ); + } + } + // Children must not read the script off the shell's own stdin. + assert!(script.contains(" String { + let mut stdout = String::from( + "hermes\thermes-acp\t/home/ubuntu/.local/bin/hermes-acp\t0.19.0\n\ + hermes-cli\thermes\t/home/ubuntu/.local/bin/hermes\tHermes Agent v0.19.0\n", + ); + for profile in profiles { + stdout.push_str(&format!("hermes-profile\t{profile}\n")); + } + stdout + } + + fn entry<'a>(response: &'a serde_json::Value, id: &str) -> &'a serde_json::Value { + response["harnesses"] + .as_array() + .unwrap() + .iter() + .find(|h| h["id"] == id) + .unwrap_or_else(|| panic!("no harness entry {id}")) + } + + #[test] + fn each_hermes_profile_becomes_its_own_catalog_entry() { + let response = harnesses_response(&hermes_stdout(&["default", "matt", "paul"])); + let harnesses = response["harnesses"].as_array().unwrap(); + assert_eq!(harnesses.len(), CANDIDATES.len() + 3); + + let matt = entry(&response, "hermes-matt"); + assert_eq!(matt["label"], "Hermes (matt)"); + // The CLI, not the shim: `--profile` is a global pre-subcommand flag + // and the shim forwards no arguments of its own. + assert_eq!(matt["command"], "hermes"); + assert_eq!( + matt["args"], + serde_json::json!(["--profile", "matt", "acp"]) + ); + assert_eq!(matt["available"], true); + assert_eq!(matt["binaryPath"], "/home/ubuntu/.local/bin/hermes"); + assert_eq!(matt["version"], "Hermes Agent v0.19.0"); + assert_eq!(matt["env"], serde_json::json!({})); + + // The sticky default keeps its own entry — once `hermes profile use` + // moves the sticky pointer, nothing else can pin the built-in profile. + assert_eq!( + entry(&response, "hermes-default")["args"], + serde_json::json!(["--profile", "default", "acp"]) + ); + // And the plain shim entry is untouched, still the default option. + let plain = entry(&response, "hermes"); + assert_eq!(plain["command"], "hermes-acp"); + assert_eq!(plain["args"], serde_json::json!([])); + } + + #[test] + fn only_per_profile_entries_are_marked_exclusive() { + // A profile is a persistent identity: at most one agent may be pinned + // to it. Every other entry is an ephemeral runner and must OMIT the key + // entirely — an absent field is what the desktop reads as "no limit", + // so emitting `false` would be a different (and needless) contract. + let response = harnesses_response(&hermes_stdout(&["default", "matt"])); + for id in ["hermes-default", "hermes-matt"] { + assert_eq!(entry(&response, id)["exclusive"], true, "{id}"); + } + for entry_value in response["harnesses"].as_array().unwrap() { + let id = entry_value["id"].as_str().unwrap(); + if id.starts_with("hermes-") { + continue; + } + assert!( + entry_value.get("exclusive").is_none(), + "{id} must not advertise 'exclusive'" + ); + } + } + + #[test] + fn a_host_without_hermes_gets_byte_identical_todays_catalog() { + // The degradation contract: absent Hermes, the response is exactly what + // it was before per-profile entries existed. + let stdout = "buzz-acp\tbuzz-acp\t/usr/local/bin/buzz-acp\t0.4.26\n\ + goose\tgoose\t/usr/bin/goose\tgoose 1.9.0\n"; + let response = harnesses_response(stdout); + assert_eq!( + response["harnesses"].as_array().unwrap().len(), + CANDIDATES.len() + ); + let expected: Vec = CANDIDATES + .iter() + .map(|candidate| { + let available = candidate.id == "goose"; + serde_json::json!({ + "id": candidate.id, + "label": candidate.label, + "command": candidate.commands[0], + "args": candidate.args, + "env": candidate + .env + .iter() + .map(|(k, v)| ((*k).to_string(), serde_json::Value::from(*v))) + .collect::>(), + "installInstructionsUrl": "", + "installHint": "", + "available": available, + "binaryPath": available.then_some("/usr/bin/goose"), + "version": available.then_some("goose 1.9.0"), + }) + }) + .collect(); + assert_eq!(response["harnesses"], serde_json::Value::from(expected)); + } + + #[test] + fn hermes_with_no_profile_records_is_just_the_plain_entry() { + // A stdout carrying no profile records at all — what a *missing* Hermes + // root produces — is not an error: the shim entry still runs the sticky + // profile. A root that exists without a `profiles/` store is a + // different stdout, pinned by + // `a_hermes_root_without_a_profiles_store_still_advertises_default`. + let response = harnesses_response(&hermes_stdout(&[])); + assert_eq!(response["ok"], true); + assert_eq!( + response["harnesses"].as_array().unwrap().len(), + CANDIDATES.len() + ); + assert_eq!(entry(&response, "hermes")["available"], true); + } + + #[test] + fn profile_records_without_the_hermes_cli_produce_nothing() { + // Only the shim resolved, so there is no binary that accepts + // `--profile` — pinning one would deploy an agent that cannot start. + let stdout = "hermes\thermes-acp\t/usr/bin/hermes-acp\t0.19.0\n\ + hermes-profile\tmatt\n"; + let response = harnesses_response(stdout); + assert_eq!( + response["harnesses"].as_array().unwrap().len(), + CANDIDATES.len() + ); + } + + #[test] + fn hostile_profile_names_are_skipped_never_sanitized() { + // A mangled name would pin a profile that does not exist. Every one of + // these must be dropped whole. + for hostile in [ + "a b", + "a'b", + "$(touch /tmp/pwn)", + "`id`", + "a;b", + "../escape", + ".hidden", + "-leading-dash", + "_leading-underscore", + "UPPER", + "naïve", + "a/b", + "a\\b", + "", + &"x".repeat(65), + ] { + assert!( + !is_hermes_profile_name(hostile), + "{hostile:?} must be refused" + ); + } + for ok in ["default", "matt", "msig-web-analyst", "a_b", "x9", "9x"] { + assert!(is_hermes_profile_name(ok), "{ok:?} must be accepted"); + } + } + + #[test] + fn a_hostile_name_that_reaches_stdout_is_dropped_from_the_catalog() { + // Belt and braces: even if the script's own prefilter were bypassed, + // nothing hostile reaches the JSON. + let response = harnesses_response(&hermes_stdout(&["matt", "$(id)", "Bad", "paul"])); + let ids: Vec<&str> = response["harnesses"] + .as_array() + .unwrap() + .iter() + .map(|h| h["id"].as_str().unwrap()) + .collect(); + assert!(ids.contains(&"hermes-matt") && ids.contains(&"hermes-paul")); + assert!(!ids.iter().any(|id| id.contains("$(") || id.contains("Bad"))); + // No mangled survivor either — the count is exactly the two good ones. + assert_eq!(ids.len(), CANDIDATES.len() + 2); + } + + #[test] + fn the_profile_count_is_capped_against_a_pathological_host() { + let many: Vec = (0..200).map(|i| format!("p{i}")).collect(); + let refs: Vec<&str> = many.iter().map(String::as_str).collect(); + let response = harnesses_response(&hermes_stdout(&refs)); + assert_eq!( + response["harnesses"].as_array().unwrap().len(), + CANDIDATES.len() + MAX_HERMES_PROFILES + ); + } + + #[test] + fn a_repeated_profile_name_yields_one_entry() { + // Duplicate ids would collide in the desktop's catalog. + let response = harnesses_response(&hermes_stdout(&["matt", "matt"])); + assert_eq!( + response["harnesses"].as_array().unwrap().len(), + CANDIDATES.len() + 1 + ); + } + + #[test] + fn every_profile_entry_id_satisfies_the_desktop_harness_id_rule() { + // `validate_harness_definition` drops any entry whose id does not match + // `[a-z0-9_][a-z0-9_-]*`, so a legal profile name must always produce a + // legal id — otherwise the entry silently vanishes desktop-side. + for name in ["default", "matt", "msig-web-analyst", "a_b", "9x"] { + let id = format!("hermes-{name}"); + let mut chars = id.chars(); + let first = chars.next().unwrap(); + assert!(first.is_ascii_lowercase() || first.is_ascii_digit() || first == '_'); + assert!( + chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + ); + } + } + + #[test] + fn profile_records_never_masquerade_as_probe_records() { + // Two fields, not four, so `parse_probes` drops them on its own and a + // profile can never be mistaken for a resolved binary. + let stdout = hermes_stdout(&["matt"]); + let probes = parse_probes(&stdout); + assert_eq!(probes.len(), 2); + assert!(probes.iter().all(|p| p.key != "hermes-profile")); + } + + /// The script must only enumerate profiles on a host that has `hermes`, + /// and must never let a name be evaluated by the shell. + #[test] + fn the_script_gates_profile_enumeration_on_hermes_being_present() { + let script = discover_script(&config()); + assert!(script.contains("probe 'hermes-cli' 'hermes'")); + assert!(script.contains(r#"if _hb=$(command -v hermes 2>/dev/null)"#)); + // Names are printed as data, never expanded or executed. + assert!(script.contains(r#"printf 'hermes-profile\t%s\n' "$_hn""#)); + // The shell-side cap tracks the Rust constant. + assert!(script.contains(&format!(r#"[ "$_hc" -lt {MAX_HERMES_PROFILES} ] || break"#))); + } + + /// Run the real generated script through `/bin/sh` against a fake host + /// rooted at `root`, with `HERMES_HOME` set to `hermes_home`. Returns the + /// script's stdout. + #[cfg(unix)] + fn run_discover_script(root: &std::path::Path, hermes_home: &std::path::Path) -> String { + // Stub `hermes` so `command -v hermes` resolves on the fake host. + let bin = root.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let hermes = bin.join("hermes"); + std::fs::write(&hermes, "#!/bin/sh\nexit 0\n").unwrap(); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&hermes, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let output = std::process::Command::new("/bin/sh") + .arg("-s") + .env_clear() + .env("HOME", root) + .env("HERMES_HOME", hermes_home) + .env("PATH", format!("{}:/usr/bin:/bin", bin.display())) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(discover_script(&config()).as_bytes()) + .unwrap(); + child.wait_with_output() + }) + .unwrap(); + assert!( + output.status.success(), + "script failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() + } + + /// Every probe key the script is allowed to emit. Anything else on stdout + /// in four-field shape is a forged record. + #[cfg(unix)] + fn expected_probe_keys() -> Vec<&'static str> { + let mut keys = vec!["buzz-acp", HERMES_CLI_KEY]; + keys.extend(CANDIDATES.iter().map(|candidate| candidate.id)); + keys + } + + /// Execute the real generated script against `/bin/sh` over a fake host + /// layout. Substring assertions prove the script *says* the right things; + /// only running it proves the `case` globs, the `${_hd%/}` trimming and the + /// `HERMES_HOME` recovery actually behave — and that a directory named + /// `$(touch …)` stays inert rather than being evaluated. + #[cfg(unix)] + #[test] + fn the_generated_script_enumerates_a_real_profile_directory_safely() { + let root = + std::env::temp_dir().join(format!("buzz-hermes-profiles-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let profiles = root.join("hermes/profiles"); + std::fs::create_dir_all(&profiles).unwrap(); + + let canary = root.join("pwn"); + for name in [ + // Normal names, including the operator's real fleet shapes. + "matt", + "paul", + "msig-web-analyst", + "codex_worker", + // Hostile: spaces, quotes, and a command substitution that must + // stay a literal directory name. + "evil name", + "ev'il", + &format!("$(touch {})", canary.display()), + // A dotfile, which is not a profile and is not matched by `*/`. + ".hidden", + // Uppercase, which Hermes itself would refuse. + "Upper", + ] { + std::fs::create_dir_all(profiles.join(name)).unwrap(); + } + // A plain file under profiles/ is not a profile. + std::fs::write(profiles.join("notes.md"), "x").unwrap(); + + // Exercises the Docker/custom layout AND the `*/profiles/*` trim by + // pointing HERMES_HOME at a profile rather than at the root. + let stdout = run_discover_script(&root, &root.join("hermes/profiles/matt")); + // Nothing under `profiles/` was ever executed. + assert!( + !canary.exists(), + "a directory name was evaluated by the shell" + ); + + let (names, _) = hermes_profiles(&stdout); + assert_eq!( + names, + vec![ + "default", + "codex_worker", + "matt", + "msig-web-analyst", + "paul" + ], + "stdout was: {stdout}" + ); + + // And the response built from that real stdout parses, with the args + // arrays the deploy pin will carry. + let response: serde_json::Value = + serde_json::from_str(&harnesses_response(&stdout).to_string()).unwrap(); + assert_eq!( + entry(&response, "hermes-msig-web-analyst")["args"], + serde_json::json!(["--profile", "msig-web-analyst", "acp"]) + ); + assert_eq!(entry(&response, "hermes-matt")["command"], "hermes"); + + std::fs::remove_dir_all(&root).unwrap(); + } + + /// The forging path, which the labeled `hermes-profile` stream never + /// exercises: a directory name carrying a newline plus three tab-separated + /// fields prints a *second*, unlabeled line that [`parse_probes`] would + /// accept as a four-field probe record for any candidate it names. Nothing + /// downstream can catch it — `hermes_profiles` only ever sees the labeled + /// prefix — so the script's charset `case` arms are the whole defense, and + /// this is what pins them. + #[cfg(unix)] + #[test] + fn a_profile_directory_name_cannot_forge_a_probe_record() { + let root = std::env::temp_dir().join(format!("buzz-hermes-forge-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let profiles = root.join("hermes/profiles"); + std::fs::create_dir_all(&profiles).unwrap(); + std::fs::create_dir_all(profiles.join("matt")).unwrap(); + // Reads on stdout as `hermes-profilex` followed by a complete + // `claudeevil-acptmp-evil-acp9.9.9` record. + std::fs::create_dir_all(profiles.join("x\nclaude\tevil-acp\ttmp-evil-acp\t9.9.9")).unwrap(); + + let stdout = run_discover_script(&root, &root.join("hermes")); + + let keys: Vec<&str> = parse_probes(&stdout).iter().map(|p| p.key).collect(); + let expected = expected_probe_keys(); + assert!( + keys.iter().all(|key| expected.contains(key)), + "a directory name forged a probe record; keys were {keys:?}, stdout was: {stdout:?}" + ); + + // The concrete consequence the record would have had: `claude` claiming + // to be installed, pinned as the deploy's BUZZ_ACP_AGENT_COMMAND. + let response = harnesses_response(&stdout); + let claude = entry(&response, "claude"); + assert_eq!(claude["available"], false, "stdout was: {stdout:?}"); + assert_eq!(claude["command"], "claude-agent-acp"); + assert!(claude["binaryPath"].is_null()); + + // The name is not a legal profile either, so it adds no entry. + let (names, _) = hermes_profiles(&stdout); + assert_eq!(names, vec!["default", "matt"], "stdout was: {stdout:?}"); + + std::fs::remove_dir_all(&root).unwrap(); + } + + /// A Hermes root that exists but has no `profiles/` store: the `default` + /// entry still ships, because the root directory *is* the default profile. + /// Distinct from a missing root, which emits nothing at all. + #[cfg(unix)] + #[test] + fn a_hermes_root_without_a_profiles_store_still_advertises_default() { + let root = + std::env::temp_dir().join(format!("buzz-hermes-noprofiles-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("hermes")).unwrap(); + + let stdout = run_discover_script(&root, &root.join("hermes")); + let (names, _) = hermes_profiles(&stdout); + assert_eq!(names, vec!["default"], "stdout was: {stdout:?}"); + + let response = harnesses_response(&stdout); + assert_eq!( + response["harnesses"].as_array().unwrap().len(), + CANDIDATES.len() + 1 + ); + assert_eq!( + entry(&response, "hermes-default")["args"], + serde_json::json!(["--profile", "default", "acp"]) + ); + + std::fs::remove_dir_all(&root).unwrap(); + } + + /// A missing Hermes root — the case `hermes_stdout(&[])` models — emits no + /// profile records at all, leaving only the plain shim entry. + #[cfg(unix)] + #[test] + fn a_missing_hermes_root_advertises_no_profiles() { + let root = std::env::temp_dir().join(format!("buzz-hermes-noroot-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + + let stdout = run_discover_script(&root, &root.join("hermes")); + let (names, _) = hermes_profiles(&stdout); + assert!(names.is_empty(), "stdout was: {stdout:?}"); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn guidance_is_actionable_for_the_cases_that_happen() { + assert!( + guidance("ssh failed (exit 255): Permission denied (publickey).") + .contains("authorized_keys") + ); + assert!( + guidance("ssh failed (exit 255): Host key verification failed.") + .contains("known_hosts") + ); + assert!( + guidance("ssh failed (exit 255): ssh: Could not resolve hostname vps") + .contains("tailnet") + ); + // Unclassified failures survive verbatim rather than being flattened. + assert_eq!( + guidance("ssh failed (exit 1): weird"), + "ssh failed (exit 1): weird" + ); + } + + #[test] + fn model_env_is_exported_inside_the_script_never_on_the_argv() { + // Nested under `agent.env_vars`, the one shape the desktop's + // `env_secrets_from_request` scrubber knows how to find. + let request = serde_json::json!({ + "harness": { "command": "goose", "args": ["acp"] }, + "agent": { "env_vars": { "ANTHROPIC_API_KEY": "sk-ant-secret" } }, + }); + let script = models_script(&request, &config()).unwrap(); + assert!(script.contains("export ANTHROPIC_API_KEY='sk-ant-secret'")); + assert!(script.contains("export BUZZ_ACP_AGENT_COMMAND='goose'")); + assert!(script.contains("export BUZZ_ACP_AGENT_ARGS='acp'")); + // The value is only ever in the script body, which travels on stdin — + // the remote argv is fixed at `sh -s` by `Session`. + assert!(script.contains("exec 'buzz-acp' models --json `,** never `PATH` alone. +//! A non-interactive SSH command reads no profile, so the install destination +//! is not on the ambient `PATH` — which is precisely why the unit's env file +//! pins `PATH="$HOME/.local/bin:$PATH"` itself. A `command -v`-only rule would +//! therefore never find the copy a previous deploy installed, and because +//! deploy is the start path, every start would re-stream the binary and swap it +//! underneath a running fleet. [`resolve`] is that rule, and the probe asks the +//! same question so the two can never disagree. +//! +//! **Staleness rule: push-when-missing only.** A host that already resolves a +//! tool keeps the copy it has, whatever its version. Deploy is also the start +//! path — `start_managed_agent` re-enters it — so a version-comparing rule +//! would silently reinstall underneath a running fleet on every start, and a +//! desktop pinned to an older artifact would *downgrade* the host. Refreshing +//! an existing install is a deliberate act and belongs to a follow-up that +//! fetches release artifacts by version; see `docs/remote-agents.md`. + +use base64::Engine as _; +use sha2::{Digest, Sha256}; + +/// Refuse to embed anything larger than this. A release `buzz-acp` is 10-30 MB +/// and the `buzz` CLI is smaller; base64 inflates either by a third and the +/// result travels as one script on the SSH stdin channel, so a wrong path (a +/// disk image, a core dump, a directory of them) must fail here rather than +/// stream for minutes and then fail on the host. +const MAX_BINARY_BYTES: usize = 200 * 1024 * 1024; + +/// base64 line width. GNU `base64` wraps at 76 by default and `-d` ignores +/// newlines; one 40 MB line is legal but pathological for anything that reads +/// the script line-wise — including this crate's own tests. +const LINE_WIDTH: usize = 76; + +/// Where an installed tool lands. Unexpanded on purpose: it is emitted into the +/// script and expanded by the *host's* shell, whose `$HOME` is the only one +/// that matters. +const INSTALL_DIR: &str = "$HOME/.local/bin"; + +/// The marker every non-fatal host-side complaint carries, so `deploy` can +/// forward exactly those lines and nothing else from a successful run's stderr +/// (`deploy::deploy`). Structural, not decorative: a successful deploy's remote +/// stderr is otherwise discarded, and a warning nobody sees is not a warning. +pub const WARNING_PREFIX: &str = "WARNING: "; + +/// What it means for the host to resolve no copy of a tool while the desktop +/// pushed none either. +/// +/// This is the *whole* difference between `buzz-acp` and the `buzz` CLI. See +/// the module docs: the harness cannot run without the former and runs fine +/// (just slower and blinder) without the latter. +#[derive(Clone, Copy)] +enum Missing { + /// Stop the deploy: `code` is the script's exit status. + Fatal { code: u16, message: &'static str }, + /// Say so on stderr and carry on. + Warn { message: &'static str }, +} + +impl Missing { + /// The shell that reacts to an empty `$var` after [`resolve`] ran. + fn block(self, var: &str) -> String { + match self { + Self::Fatal { code, message } => { + format!(r#"if [ -z "${var}" ]; then echo "{message}" >&2; exit {code}; fi"#) + } + Self::Warn { message } => { + format!(r#"if [ -z "${var}" ]; then echo "{WARNING_PREFIX}{message}" >&2; fi"#) + } + } + } +} + +/// One host-side tool this crate can verify or install. +/// +/// Constructible only through the two constants below: everything else in the +/// crate names [`ACP`] or [`CLI`] rather than describing a tool of its own, so +/// there is exactly one place where a tool's name, delimiter and +/// missing-on-host policy are decided together. +#[derive(Clone, Copy)] +pub struct Tool { + /// The binary's name — the `command -v` argument in the default case, and + /// the file name under [`INSTALL_DIR`]. + pub name: &'static str, + /// The heredoc delimiter for this tool's encoded payload. Contains `_`, + /// which is not in the base64 alphabet, so no data line can ever terminate + /// the heredoc early. `delimiters_cannot_appear_in_encoded_data` pins that. + delimiter: &'static str, + /// The shell variable the resolution block leaves the absolute path in. + var: &'static str, + /// What an unresolved, un-pushed tool means for the deploy. + missing: Missing, +} + +/// The harness itself. Fail-closed: an agent without it cannot exist. +pub const ACP: Tool = Tool { + name: "buzz-acp", + delimiter: "BUZZ_ACP_B64_EOF", + var: "acp", + missing: Missing::Fatal { + code: 90, + message: "buzz-acp not found on the server's PATH or in ~/.local/bin — install it, or set \ + 'buzz-acp path on the server'", + }, +}; + +/// The agent-facing CLI. An enhancement, never load-bearing — hence +/// [`Missing::Warn`]. The message avoids backticks and `$` on purpose: it is +/// interpolated into a double-quoted `echo` on the host, where either would be +/// a command substitution. +pub const CLI: Tool = Tool { + name: "buzz", + delimiter: "BUZZ_CLI_B64_EOF", + var: "cli", + missing: Missing::Warn { + message: "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", + }, +}; + +/// Where an install of `tool` lands, and the second half of the resolution rule +/// — `~/.local/bin` is the documented convention and the destination below, but +/// it is **not** on a non-interactive SSH `PATH`: the remote shell reads no +/// profile, which is exactly why the unit's env file has to pin +/// `PATH="$HOME/.local/bin:$PATH"` itself. Resolving by `PATH` alone would +/// therefore never see the copy the previous deploy installed, and since deploy +/// is the start path, every agent start would re-stream tens of megabytes and +/// swap the binary underneath a running fleet. +fn install_path(tool: Tool) -> String { + format!("{INSTALL_DIR}/{}", tool.name) +} + +/// A binary read from the desktop's filesystem, encoded for the script and +/// fingerprinted for the host to check. +/// +/// The bytes are not secret — but they must not corrupt the script stream that +/// *is* carrying secrets, which is why only the encoded form is kept. +/// +/// Deliberately not `Debug`, like `deploy::Agent` and `ssh::Output`: a derived +/// one would put tens of megabytes of base64 one `{:?}` away from a log line. +pub struct Payload { + /// base64, wrapped to [`LINE_WIDTH`], one trailing newline per line. + encoded: String, + /// Lowercase hex SHA-256 of the raw bytes. Travels in the script in the + /// clear: it is a fingerprint, not a credential. + sha256: String, +} + +/// The size rejection, or `None` when `len` is within the cap. +/// +/// Split out so the boundary is testable without materializing a 200 MB file, +/// and applied twice in [`Payload::read`] — once to the metadata, once to the +/// bytes actually read. +fn oversized(tool: Tool, len: u64, path: &str) -> Option { + (len > MAX_BINARY_BYTES as u64).then(|| { + format!( + "the {} binary to push is {len} bytes, over the {MAX_BINARY_BYTES}-byte limit: {path}", + tool.name + ) + }) +} + +impl Payload { + /// Read, validate and encode the binary at `path` on the **desktop**. + /// + /// Every rejection here is a failure the host could only report as + /// something far less legible: an `Exec format error` from systemd five + /// seconds after a deploy that looked successful, or a multi-minute stream + /// of a file that was never a binary. `tool` names the offender in each + /// message — the desktop can push two, and "the binary to push" would leave + /// the reader guessing which env var to fix. + pub fn read(tool: Tool, path: &str) -> Result { + let name = tool.name; + let metadata = std::fs::metadata(path) + .map_err(|e| format!("cannot read the {name} binary to push ({path}): {e}"))?; + if !metadata.is_file() { + return Err(format!("the {name} binary to push is not a file: {path}")); + } + // Checked before the read, so a wrong path costs a `stat` rather than + // pulling a disk image into memory. + if let Some(error) = oversized(tool, metadata.len(), path) { + return Err(error); + } + + let bytes = std::fs::read(path) + .map_err(|e| format!("cannot read the {name} binary to push ({path}): {e}"))?; + // Re-checked against the bytes actually read: the metadata above is a + // separate syscall, and the file may have grown between the two. + if let Some(error) = oversized(tool, bytes.len() as u64, path) { + return Err(error); + } + if bytes.is_empty() { + return Err(format!("the {name} binary to push is empty: {path}")); + } + // Deploy targets are Linux + `systemd --user` throughout, and the + // desktop pushing the binary is routinely macOS or Windows. Without + // this check a Mach-O or PE binary installs cleanly and the unit then + // restart-loops on `Exec format error` every five seconds, with the + // deploy having reported success. + if !bytes.starts_with(b"\x7fELF") { + return Err(format!( + "the {name} binary to push is not a Linux (ELF) executable: {path}" + )); + } + + let sha256 = Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + Ok(Self { + encoded: wrap(&base64::engine::general_purpose::STANDARD.encode(&bytes)), + sha256, + }) + } + + /// The fingerprint the host checks the decoded file against. Tests assert + /// against it; the script embeds it through [`resolve_or_install`]. + #[cfg(test)] + pub fn sha256(&self) -> &str { + &self.sha256 + } + + /// The encoded body, so tests can corrupt it the way a truncated stream + /// would. The script embeds it through [`resolve_or_install`]. + #[cfg(test)] + pub fn encoded(&self) -> &str { + &self.encoded + } +} + +/// base64 output is ASCII, so every byte offset is a character boundary and +/// the line breaks can be taken on the `str` directly — no byte round trip, +/// and nothing to unwrap. +fn wrap(encoded: &str) -> String { + let mut out = String::with_capacity(encoded.len() + encoded.len() / LINE_WIDTH + 1); + let mut rest = encoded; + while !rest.is_empty() { + let (line, tail) = rest.split_at(LINE_WIDTH.min(rest.len())); + out.push_str(line); + out.push('\n'); + rest = tail; + } + out +} + +/// The script that asks the host which of `tools` it already resolves. +/// +/// Deploy is also the *start* path, so without this a desktop with the push +/// seams engaged would stream tens of megabytes of base64 on every single agent +/// start, to a host that has had the binaries since the first one. The probe is +/// one cheap round trip — one, whatever the number of tools — that keeps the +/// payloads off the wire in that case. +/// +/// It answers by *printing the name* of each tool it resolved rather than by an +/// exit status, because two tools cannot share one boolean. Each line applies +/// exactly the rule [`resolve_or_install`] applies on the host ([`resolve`]) — +/// `PATH` *or* [`install_path`] — because a probe that only consulted `PATH` +/// would answer "missing" forever for a binary this crate itself installed, and +/// the payload would ride along on every start. +/// +/// Each pair is `(tool, already-quoted command)`; the command differs from the +/// tool's own name only for `buzz-acp`, which the operator may pin to an +/// absolute path. Names are compile-time constants, so nothing attacker-shaped +/// reaches the `echo`. +/// +/// It remains an optimization and never the decision: the deploy script +/// re-checks on the host and installs only into an empty `$var`, so a host that +/// gains or loses a tool between the two round trips still ends up correct. +pub fn probe_script(tools: &[(Tool, String)]) -> String { + tools + .iter() + .map(|(tool, command)| { + format!( + "if command -v {command} >/dev/null 2>&1 || [ -x \"{path}\" ]; then echo {name}; \ + fi\n", + path = install_path(*tool), + name = tool.name, + ) + }) + .collect() +} + +/// Whether [`probe_script`]'s output says the host already has `tool`. +pub fn probe_found(stdout: &str, tool: Tool) -> bool { + stdout.lines().any(|line| line.trim() == tool.name) +} + +/// The `WARNING:` lines a successful deploy's remote stderr carries, scrubbed. +/// +/// A deploy that succeeded discards the rest of that stderr (it is host noise), +/// so this is the one channel by which the script can tell a human something +/// short of a failure — today, that the host has no `buzz` CLI. +pub fn warnings(stderr: &str) -> Vec { + stderr + .lines() + .map(str::trim) + .filter(|line| line.starts_with(WARNING_PREFIX)) + .map(crate::protocol::redact) + .collect() +} + +/// The host-side resolution rule, shared by every caller so the probe, the +/// push path and the un-pushed path can never disagree. +/// +/// Leaves the tool's `$var` holding an absolute path, or empty when the host +/// has none. `command -v` covers a `PATH` install and an absolute configured +/// path; [`install_path`] covers the documented `~/.local/bin` convention, +/// which a non-interactive SSH `PATH` does not contain. +fn resolve(tool: Tool, command: &str) -> String { + let var = tool.var; + let install_path = install_path(tool); + format!( + r#"{var}=$(command -v {command} 2>/dev/null || true) +if [ -z "${var}" ] && [ -x "{install_path}" ]; then {var}="{install_path}"; fi"# + ) +} + +/// The deploy script's resolution block for one tool. +/// +/// `command` is the already-`quote()`d command or path to resolve. Resolution +/// is [`resolve`] in both cases — `PATH`, then the `~/.local/bin` convention — +/// so a deploy that carries no binary still finds one an earlier deploy (or the +/// operator, following the documented convention) put there. +/// +/// With a payload it becomes resolve-or-install, in that order: an installed +/// copy is never replaced, and a host that had none ends the block with `$var` +/// holding the absolute path of the copy just installed — which, for [`ACP`], +/// is what the unit's `ExecStart` is substituted from later in the same pass. +/// +/// Without one, the tool's [`Missing`] policy decides: exit for the harness, +/// a warning for the CLI. +pub fn resolve_or_install(tool: Tool, command: &str, push: Option<&Payload>) -> String { + let resolve = resolve(tool, command); + let var = tool.var; + + let Some(payload) = push else { + let missing = tool.missing.block(var); + return format!( + r#"{resolve} +{missing}"# + ); + }; + + // Every failure below removes the temp file before exiting, and the file is + // only made executable *after* the digest matches, so no path through this + // block can leave a runnable half-written binary in `~/.local/bin`. + // + // The `|| { ... }` on the `base64` line binds to the whole redirected + // command; the heredoc body begins on the following line either way, so the + // decode is guarded rather than left to `set -e`, which would exit before + // the temp file could be removed. + // + // Integrity failures exit for BOTH tools, including the non-load-bearing + // CLI: a payload that arrives damaged says the stream is damaged, and that + // same stream carries the minted nsec and the unit. "Nothing is installed + // unverified" stays one rule; only the *absent-payload* case is asymmetric. + format!( + r#"{resolve} +if [ -z "${var}" ]; then +command -v base64 >/dev/null 2>&1 || {{ echo "the server has no 'base64' (coreutils), so the desktop cannot install {name} on it" >&2; exit 92; }} +command -v sha256sum >/dev/null 2>&1 || {{ echo "the server has no 'sha256sum' (coreutils), and {name} is never installed unverified" >&2; exit 92; }} +{var}_dir="{INSTALL_DIR}" +mkdir -p "${var}_dir" +{var}_tmp="${var}_dir/.{name}.tmp.$$" +base64 -d > "${var}_tmp" <<'{delimiter}' || {{ rm -f "${var}_tmp"; echo "the pushed {name} did not decode on the server" >&2; exit 93; }} +{encoded}{delimiter} +printf '%s %s\n' '{sha256}' "${var}_tmp" | sha256sum -c - >/dev/null 2>&1 || {{ rm -f "${var}_tmp"; echo "the pushed {name} failed its sha256 check on the server — refusing to install it" >&2; exit 94; }} +chmod 755 "${var}_tmp" +mv "${var}_tmp" "${var}_dir/{name}" +{var}="${var}_dir/{name}" +fi"#, + name = tool.name, + delimiter = tool.delimiter, + encoded = payload.encoded, + sha256 = payload.sha256, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A file that is a legal ELF header followed by everything that would + /// break a shell script if it ever reached one unencoded — including both + /// tools' heredoc delimiters. + fn hostile_binary() -> Vec { + let mut bytes = b"\x7fELF\x02\x01\x01\x00".to_vec(); + bytes.extend_from_slice(b"\0\0'\"$(touch /tmp/buzz-should-not-exist)`id`\r\n"); + bytes.extend_from_slice(format!("{}\n", ACP.delimiter).as_bytes()); + bytes.extend_from_slice(format!("{}\n", CLI.delimiter).as_bytes()); + bytes.extend_from_slice(b"\\x00 \x00 ${HOME} $(id -u)\n"); + bytes.extend_from_slice(&(0u8..=255).collect::>()); + bytes + } + + fn write_temp(name: &str, bytes: &[u8]) -> String { + let path = std::env::temp_dir().join(format!("buzz-push-{}-{name}", std::process::id())); + std::fs::write(&path, bytes).unwrap(); + path.display().to_string() + } + + /// `Payload` is intentionally not `Debug` (see its doc comment), so the + /// rejection comes out by hand — the same pattern `deploy::tests` uses for + /// `Agent`. + fn rejection(tool: Tool, path: &str) -> String { + match Payload::read(tool, path) { + Err(error) => error, + Ok(_) => panic!("expected {path} to be rejected, it was accepted"), + } + } + + #[test] + fn encoding_round_trips_bytes_that_would_break_a_shell_script() { + let bytes = hostile_binary(); + let payload = Payload::read(ACP, &write_temp("hostile", &bytes)).unwrap(); + + // The encoded form carries nothing a shell reads as syntax, which is + // the whole reason a binary can travel inside the script at all. + for line in payload.encoded.lines() { + assert!( + line.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'='), + "encoded line left the base64 alphabet: {line}" + ); + assert!(line.len() <= LINE_WIDTH, "unwrapped line: {}", line.len()); + } + + let decoded = base64::engine::general_purpose::STANDARD + .decode(payload.encoded.replace('\n', "")) + .unwrap(); + assert_eq!(decoded, bytes, "base64 round trip lost bytes"); + } + + #[test] + fn delimiters_cannot_appear_in_encoded_data() { + // The heredocs are only safe because `_` is outside the base64 + // alphabet: a payload that could emit its own terminator would end the + // heredoc early and hand the rest of the binary to the shell as + // commands. Both tools' delimiters must hold that property, and they + // must differ so one script can carry both bodies unambiguously. + assert!(ACP.delimiter.contains('_')); + assert!(CLI.delimiter.contains('_')); + assert_ne!(ACP.delimiter, CLI.delimiter); + let payload = Payload::read(CLI, &write_temp("delimiter", &hostile_binary())).unwrap(); + // Even though the *source bytes* literally contain both delimiters. + assert!(!payload.encoded.contains(ACP.delimiter)); + assert!(!payload.encoded.contains(CLI.delimiter)); + } + + #[test] + fn the_digest_is_the_sha256_of_the_raw_bytes() { + let bytes = hostile_binary(); + let payload = Payload::read(ACP, &write_temp("digest", &bytes)).unwrap(); + let expected: String = Sha256::digest(&bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + assert_eq!(payload.sha256(), expected); + assert_eq!(payload.sha256().len(), 64); + } + + #[test] + fn only_a_linux_executable_is_accepted() { + // A Mach-O binary from the desktop installs cleanly and then + // restart-loops on the host with `Exec format error`, five seconds at a + // time, after a deploy that reported success. + let error = rejection(ACP, &write_temp("macho", b"\xcf\xfa\xed\xfe rest")); + assert!(error.contains("ELF"), "{error}"); + + let error = rejection(ACP, &write_temp("empty", b"")); + assert!(error.contains("empty"), "{error}"); + + let missing = std::env::temp_dir().join("buzz-push-does-not-exist"); + let error = rejection(ACP, &missing.display().to_string()); + assert!(error.contains("cannot read"), "{error}"); + + // A directory `stat`s fine and `read` would fail with something far + // less legible, so it is refused by shape rather than by errno. + let error = rejection(ACP, &std::env::temp_dir().display().to_string()); + assert!(error.contains("not a file"), "{error}"); + } + + #[test] + fn every_rejection_names_the_tool_it_is_about() { + // The desktop can push two binaries from two env vars. "the binary to + // push is not a Linux (ELF) executable" would leave the reader guessing + // which one to fix. + let macho = write_temp("macho-named", b"\xcf\xfa\xed\xfe rest"); + assert!(rejection(ACP, &macho).contains("the buzz-acp binary")); + assert!(rejection(CLI, &macho).contains("the buzz binary")); + + let error = oversized(CLI, u64::MAX, "/tmp/wrong").unwrap(); + assert!(error.contains("the buzz binary to push"), "{error}"); + } + + #[test] + fn the_size_cap_rejects_at_the_boundary_and_names_the_path() { + // Exercised through `oversized` rather than by writing a 200 MB file: + // the boundary is the whole content of the rule, and a real artifact + // (10-30 MB) must pass it untouched. + assert_eq!(oversized(ACP, MAX_BINARY_BYTES as u64, "/x"), None); + assert_eq!(oversized(ACP, 30 * 1024 * 1024, "/x"), None); + let error = oversized(ACP, MAX_BINARY_BYTES as u64 + 1, "/tmp/wrong-file").unwrap(); + assert!(error.contains("limit"), "{error}"); + assert!(error.contains("/tmp/wrong-file"), "{error}"); + // A `u64` length from a huge file must not wrap on the way to the + // comparison, which an `as usize` on a 32-bit target would do. + assert!(oversized(ACP, u64::MAX, "/x").is_some()); + } + + #[test] + fn no_payload_still_resolves_the_install_destination_and_then_fails_with_exit_90() { + let resolved = resolve_or_install(ACP, "'buzz-acp'", None); + assert_eq!( + resolved, + r#"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"# + ); + } + + #[test] + fn a_missing_cli_with_no_payload_warns_and_lets_the_deploy_continue() { + // The asymmetry, pinned to the byte. `buzz-acp` missing is exit 90; + // the CLI missing is a stderr line and nothing else, because an agent + // without it still runs — it just cannot answer with the CLI its own + // system prompt tells it to use. + let resolved = resolve_or_install(CLI, "'buzz'", None); + assert_eq!( + resolved, + r#"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"# + ); + // No exit, no `set -e` trip: the deploy carries on past this block. + assert!(!resolved.contains("exit")); + } + + #[cfg(unix)] + #[test] + fn a_warning_never_stops_a_script_running_under_set_e() { + // `echo … >&2` returns 0, but the surrounding `if` is what makes that + // true of the whole block. Prove it against a real shell rather than by + // reading it: a non-zero last command here would abort every deploy to + // a host without the CLI. + let script = format!( + "set -eu\n{}\necho reached\n", + resolve_or_install(CLI, "'buzz'", None) + ); + let output = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(&script) + .env("HOME", std::env::temp_dir().join("buzz-no-such-home")) + .env("PATH", "/nonexistent") + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "reached"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.starts_with(WARNING_PREFIX), "{stderr}"); + } + + #[test] + fn warnings_are_lifted_out_of_remote_stderr_and_scrubbed() { + let stderr = format!( + "some host noise\n{WARNING_PREFIX}no 'buzz' CLI\nfailed with nsec1leakedleaked\n" + ); + let lifted = warnings(&stderr); + assert_eq!(lifted, vec![format!("{WARNING_PREFIX}no 'buzz' CLI")]); + // Anything that reaches the desktop goes through the same scrubber the + // failure path uses — a warning is not an exemption. + let leaky = format!("{WARNING_PREFIX}key nsec1leakedleaked rejected"); + assert!(!warnings(&leaky)[0].contains("nsec1leakedleaked")); + assert!(warnings("").is_empty()); + } + + #[cfg(unix)] + #[test] + fn resolution_finds_the_install_destination_that_is_not_on_a_non_interactive_path() { + // The bug this pins: `~/.local/bin` is where every install lands and is + // NOT on a non-interactive SSH PATH, so a `command -v`-only rule + // reported "missing" for a binary this crate itself installed — and + // since deploy is the start path, re-streamed and re-installed it on + // every single agent start. + let home = std::env::temp_dir().join(format!("buzz-resolve-{}", std::process::id())); + let local_bin = home.join(".local/bin"); + std::fs::create_dir_all(&local_bin).unwrap(); + let installed = local_bin.join("buzz-acp"); + std::fs::write(&installed, "#!/bin/sh\nexit 0\n").unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&installed, std::fs::Permissions::from_mode(0o755)).unwrap(); + + // `sh -c` with an EMPTY PATH: nothing but the explicit check can find + // it, which is exactly the remote shell's situation. + let run = |script: &str| { + let output = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(format!("{script}\nprintf '%s' \"$acp\"\n")) + .env("HOME", &home) + .env("PATH", "/nonexistent") + .output() + .unwrap(); + ( + output.status.success(), + String::from_utf8_lossy(&output.stdout).to_string(), + ) + }; + + // Both the un-pushed path and the shared rule land on the installed + // copy rather than exiting 90. + let (ok, acp) = run(&resolve_or_install(ACP, "'buzz-acp'", None)); + assert!(ok, "resolution failed on a host that has the binary"); + assert_eq!(acp, installed.display().to_string()); + let (ok, acp) = run(&resolve(ACP, "'buzz-acp'")); + assert!(ok); + assert_eq!(acp, installed.display().to_string()); + + // And it is still a real answer, not an unconditional one: remove the + // file and the same block exits 90. + std::fs::remove_file(&installed).unwrap(); + let (ok, _) = run(&resolve_or_install(ACP, "'buzz-acp'", None)); + assert!(!ok, "resolution succeeded on a host with no binary at all"); + } + + #[cfg(unix)] + #[test] + fn the_probe_answers_which_tools_the_host_already_has() { + // The probe is what keeps megabytes-large payloads off the wire on + // every start of every agent, so its answer has to be right in both + // directions — and for two tools it has to be per-tool, which is why it + // prints names rather than exiting 0/1. Run against a real `/bin/sh`, + // since the whole content of the script is shell. + // + // `$HOME` is pinned to an empty sandbox: the probe also consults + // `$HOME/.local/bin`, and the developer running these tests may well + // have a `buzz` there. + let home = std::env::temp_dir().join(format!("buzz-probe-home-{}", std::process::id())); + let local_bin = home.join(".local/bin"); + std::fs::create_dir_all(&local_bin).unwrap(); + let run = |tools: &[(Tool, String)], path: &str| { + let output = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(probe_script(tools)) + .env("HOME", &home) + .env("PATH", path) + .output() + .unwrap(); + String::from_utf8_lossy(&output.stdout).to_string() + }; + + // `sh` itself is in /bin on every unix, so it stands in for an + // installed binary without creating one. + let both = [(ACP, "'sh'".to_string()), (CLI, "'buzz'".to_string())]; + let answer = run(&both, "/bin:/usr/bin"); + assert!(probe_found(&answer, ACP), "{answer}"); + assert!(!probe_found(&answer, CLI), "{answer}"); + + // An absolute configured path is answered by existence, not by PATH. + let answer = run(&[(ACP, "'/bin/sh'".to_string())], "/nonexistent"); + assert!(probe_found(&answer, ACP), "{answer}"); + + // The install destination answers too, with nothing on PATH — the case + // every host is in after its first deploy, and the one a `command -v` + // probe got wrong forever. + use std::os::unix::fs::PermissionsExt; + let installed = local_bin.join("buzz"); + std::fs::write(&installed, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&installed, std::fs::Permissions::from_mode(0o755)).unwrap(); + let answer = run(&both, "/nonexistent"); + assert!(probe_found(&answer, CLI), "{answer}"); + assert!(!probe_found(&answer, ACP), "{answer}"); + // A non-executable leftover is not an install: `-x`, not `-e`. + std::fs::set_permissions(&installed, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(!probe_found(&run(&both, "/nonexistent"), CLI)); + std::fs::remove_file(&installed).unwrap(); + + // The command is interpolated already-quoted, so a hostile configured + // path is inert rather than executed. + let canary = std::env::temp_dir().join(format!("buzz-probe-{}", std::process::id())); + let _ = std::fs::remove_file(&canary); + let hostile = crate::ssh::quote(&format!("$(touch {})", canary.display())); + let answer = run(&[(ACP, hostile)], "/bin:/usr/bin"); + assert!(!probe_found(&answer, ACP), "{answer}"); + assert!(!canary.exists(), "the probe executed its own argument"); + + // No tools to ask about is no script at all, not an empty round trip + // that still says something. + assert!(probe_script(&[]).is_empty()); + } + + #[test] + fn the_install_block_verifies_before_it_installs() { + // Both tools share this code path, so both are checked: the CLI is the + // enhancement, but "nothing becomes executable before it verifies" is + // not something an enhancement gets to opt out of. + for tool in [ACP, CLI] { + let payload = Payload::read(tool, &write_temp("order", &hostile_binary())).unwrap(); + let script = resolve_or_install(tool, "'x'", Some(&payload)); + let name = tool.name; + let var = tool.var; + + let decode = script.find("base64 -d").unwrap(); + let verify = script.find("sha256sum -c").unwrap(); + let chmod = script.find("chmod 755").unwrap(); + let install = script.find(&format!(r#"mv "${var}_tmp""#)).unwrap(); + assert!(decode < verify, "decode must precede verification"); + assert!( + verify < chmod, + "nothing becomes executable before it verifies" + ); + assert!(chmod < install, "the file is executable before it is moved"); + + // Same directory as the target, so the `mv` is a rename and never a + // cross-device copy that could be observed half-written. + assert!(script.contains(&format!(r#"{var}_tmp="${var}_dir/.{name}.tmp.$$""#))); + assert!(script.contains(&format!(r#"mv "${var}_tmp" "${var}_dir/{name}""#))); + // Resolution wins over installation, so an existing binary is kept. + assert!(script.contains(&format!(r#"if [ -z "${var}" ]; then"#))); + // And the freshly installed path is what the rest of the deploy uses. + assert!(script.contains(&format!(r#"{var}="${var}_dir/{name}""#))); + // Missing coreutils is a clear message, never a silent skip. + assert!(script.contains("command -v base64")); + assert!(script.contains("command -v sha256sum")); + // Every host-side message names the tool it is about. + assert!(script.contains(&format!("the pushed {name} did not decode"))); + assert!(script.contains(&format!("the pushed {name} failed its sha256 check"))); + + // Where the install lands and where resolution looks are two + // expressions, so they can drift apart — and if they ever do, every + // deploy silently re-installs forever. Pin them together. + assert_eq!(install_path(tool), format!("{INSTALL_DIR}/{name}")); + assert!(script.contains(&format!(r#"{var}_dir="{INSTALL_DIR}""#))); + assert!(script.contains(&format!(r#"[ -x "{}" ]"#, install_path(tool)))); + + // The heredoc delimiter is QUOTED, so the remote shell performs no + // expansion on the body. The base64 alphabet already contains + // nothing expandable, so this is the crate's usual second + // independent failure rather than the only one — but an unquoted + // delimiter would make the payload's inertness depend entirely on + // the encoder, and no behavioural test could see the difference. + assert!(script.contains(&format!("<<'{}'", tool.delimiter))); + assert!(!script.contains(&format!("<<{}", tool.delimiter))); + } + } +} diff --git a/crates/buzz-backend-ssh/src/main.rs b/crates/buzz-backend-ssh/src/main.rs new file mode 100644 index 00000000000..2f53facba81 --- /dev/null +++ b/crates/buzz-backend-ssh/src/main.rs @@ -0,0 +1,161 @@ +//! `buzz-backend-ssh` — run managed agents on a remote host over SSH. +//! +//! A backend provider: the desktop spawns it, writes one JSON request to its +//! stdin, and reads one JSON response from its stdout (`managed_agents::backend +//! ::invoke_provider`). One process per op, no daemon, no state. +//! +//! It is deliberately **not** bundled with the desktop app. It installs to +//! `~/.local/bin` and is found on PATH by `discover_provider_candidates`, so +//! remote execution ships and updates independently of the desktop release. +//! +//! Three invariants hold across every op in this crate: +//! +//! 1. Secrets never reach a log, an error string, a `Debug` rendering, or a +//! process argument list — locally or on the host. They travel only inside +//! the script body on the SSH stdin channel (`ssh.rs`), and the one type +//! that holds one renders as `[REDACTED]` (`protocol::Secret`). +//! 2. A deploy without the desktop-minted `private_key_nsec` fails closed +//! (`deploy::Agent::from_request`). +//! 3. Tailscale is an enhancement, never a dependency: when it is absent, +//! logged out, or empty, the `info` schema is byte-identical to the plain +//! one (`protocol::info_response`). + +mod deploy; +mod discover; +mod install; +mod protocol; +mod ssh; +mod tailscale; + +use std::io::Read; + +use protocol::{Failure, SshConfig}; +use ssh::Session; + +fn main() { + let response = match read_request() + .map_err(Failure::from) + .and_then(|request| run(&request)) + { + Ok(response) => response, + Err(error) => { + // Human detail on stderr, machine-readable failure on stdout. The + // desktop needs the second; a developer reading a terminal needs + // the first. `error` is already credential-scrubbed by whoever + // produced it, and the desktop scrubs it again on the way in. + eprintln!("buzz-backend-ssh: {error}"); + let mut response = serde_json::json!({ "ok": false, "error": error.message }); + // An optional key, in both directions: a desktop that does not know + // it still renders `error`, which names the problem and carries the + // URL as text, and a desktop that does know it finds nothing here + // from an older provider. No negotiation, no flag. + if let Some(url) = error.auth_url { + response["recovery"] = serde_json::json!({ "action": "open_url", "url": url }); + } + response + } + }; + println!("{response}"); + // Always exit 0. A non-zero exit makes `invoke_provider` discard stdout + // entirely and report raw stderr, which would throw away the structured + // error above. + std::process::exit(0); +} + +fn read_request() -> Result { + let mut input = String::new(); + std::io::stdin() + .read_to_string(&mut input) + .map_err(|e| format!("failed to read the request from stdin: {e}"))?; + serde_json::from_str(input.trim()).map_err(|e| format!("request is not valid JSON: {e}")) +} + +fn run(request: &serde_json::Value) -> Result { + let op = request + .get("op") + .and_then(|v| v.as_str()) + .ok_or("request is missing 'op'")?; + + // `info` is the only op that runs before a host is configured — it is what + // *produces* the host field — so it never opens a session. An unknown op is + // rejected here too, so a typo costs a parse rather than an SSH handshake. + match op { + "info" => return Ok(protocol::info_response()), + "check" | "discover_harnesses" | "probe_models" | "deploy" => {} + _ => return Err(format!("unsupported op '{op}'").into()), + } + + let config = SshConfig::from_request(request)?; + let session = Session::new(&config, use_tailnet_host_key_policy(&config))?; + + match op { + "check" => discover::check(&session), + "discover_harnesses" => discover::discover_harnesses(&config, &session), + "probe_models" => discover::probe_models(request, &config, &session), + _ => deploy::deploy(request, &config, &session), + } +} + +/// Trust-on-first-use is allowed for exactly one class of address: a device +/// this machine's own Tailscale daemon lists as a peer. Reaching it already +/// required a WireGuard-authenticated tunnel, so `accept-new` adds no exposure +/// and removes the "paste the host, get `Host key verification failed`" cliff. +/// +/// For anything the user typed, `accept-new` would silently make the trust +/// decision on their behalf — a genuine MITM window — so the answer is no. +fn use_tailnet_host_key_policy(config: &SshConfig) -> bool { + tailscale::Tailnet::detect().contains(config.bare_host()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn info_needs_no_host_and_opens_no_session() { + // `info` is what the desktop calls to *build* the host field, so it + // must succeed with no configuration at all. + let response = run(&serde_json::json!({ "op": "info", "request_id": "abc" })).unwrap(); + assert_eq!(response["ok"], true); + assert_eq!(response["name"], "SSH"); + assert!(response["config_schema"]["properties"]["ssh_host"].is_object()); + } + + #[test] + fn an_unknown_op_fails_before_any_connection_is_attempted() { + let error = run(&serde_json::json!({ + "op": "teleport", + "provider_config": { "ssh_host": "vps.invalid" }, + })) + .unwrap_err(); + assert!(error.message.contains("teleport"), "{error}"); + } + + #[test] + fn a_request_without_an_op_is_a_structured_error() { + assert!(run(&serde_json::json!({})).is_err()); + } + + #[test] + fn ops_that_touch_the_host_require_a_host() { + // Validated before the session opens, so a misconfigured provider + // reports the missing field instead of a connection failure. + for op in ["check", "discover_harnesses", "probe_models", "deploy"] { + let error = run(&serde_json::json!({ "op": op })).unwrap_err(); + assert!(error.message.contains("provider_config"), "{op}: {error}"); + } + } + + #[test] + fn a_tailnet_host_is_the_only_thing_that_relaxes_host_key_checking() { + // Nothing in a test environment advertises a tailnet, so the policy + // must come back strict for every address. + let config = SshConfig { + host: "vps.example.com".into(), + ..SshConfig::default() + }; + if tailscale::Tailnet::detect().schema_options().is_empty() { + assert!(!use_tailnet_host_key_policy(&config)); + } + } +} diff --git a/crates/buzz-backend-ssh/src/protocol.rs b/crates/buzz-backend-ssh/src/protocol.rs new file mode 100644 index 00000000000..4c262df4173 --- /dev/null +++ b/crates/buzz-backend-ssh/src/protocol.rs @@ -0,0 +1,369 @@ +//! Wire types shared by every op: the provider config, the `info` schema, and +//! the secret wrapper that keeps credentials out of logs. + +use std::fmt; + +use serde::Deserialize; +use zeroize::Zeroizing; + +/// A credential that must never reach a log, an error string, or a process +/// argument list. +/// +/// `Debug`/`Display` render `[REDACTED]` — the inner value is reachable only +/// through [`Secret::expose`], which every call site must name explicitly. The +/// backing buffer is zeroized on drop. +#[derive(Clone, Default)] +pub struct Secret(Zeroizing); + +impl<'de> Deserialize<'de> for Secret { + fn deserialize>(deserializer: D) -> Result { + Ok(Self(Zeroizing::new(String::deserialize(deserializer)?))) + } +} + +impl Secret { + /// Yield the raw credential. Only legal on the path that writes it to the + /// SSH stdin channel. + pub fn expose(&self) -> &str { + &self.0 + } + + pub fn is_empty(&self) -> bool { + self.0.trim().is_empty() + } +} + +impl fmt::Debug for Secret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("[REDACTED]") + } +} + +impl fmt::Display for Secret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("[REDACTED]") + } +} + +/// An op failure, plus the optional machine-readable recovery the desktop needs +/// to offer the user a way out. +/// +/// A struct rather than an error enum: exactly one failure in this crate has a +/// recovery, and an enum would make every unremarkable `format!` in five files +/// name a variant. The `From`/`From<&str>` conversions keep every +/// existing `?` compiling; there is deliberately **no** `From for +/// String`, which would silently drop `auth_url` at the first caller that +/// propagated one. +#[derive(Debug)] +pub struct Failure { + pub message: String, + /// A `https://login.tailscale.com/a/…` URL built by + /// [`crate::tailscale::auth_url_in`]. Never anything else. + pub auth_url: Option, +} + +impl Failure { + /// The tailnet's ACL asks for a browser re-auth (Tailscale SSH's `check` + /// action), which `BatchMode` cannot answer. + /// + /// The message stands alone: a desktop too old to read `recovery` still + /// tells the user what happened and where to go. + pub fn tailscale_auth(url: String) -> Self { + Self { + message: format!("this host requires Tailscale SSH authentication in a browser: {url}"), + auth_url: Some(url), + } + } +} + +impl From for Failure { + fn from(message: String) -> Self { + Self { + message, + auth_url: None, + } + } +} + +impl From<&str> for Failure { + fn from(message: &str) -> Self { + Self::from(message.to_string()) + } +} + +impl fmt::Display for Failure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +/// Strip credential-shaped tokens out of text that came from somewhere we do +/// not control (remote stderr, mostly). Mirrors the desktop's own prefix rule +/// in `managed_agents::backend::redact_secrets_with` so a leak needs two +/// independent failures rather than one. +pub fn redact(text: &str) -> String { + let mut out = text.to_string(); + for prefix in ["nsec1", "sprt_tok_"] { + while let Some(start) = out.find(prefix) { + let end = out[start..] + .find(|c: char| c.is_whitespace() || c == '"' || c == '\'') + .map(|offset| start + offset) + .unwrap_or(out.len()); + out.replace_range(start..end, "[REDACTED]"); + } + } + out +} + +/// Truncate remote output to something an error string can carry. +pub fn snippet(text: &str) -> String { + let redacted = redact(text.trim()); + match redacted.char_indices().nth(2048) { + Some((cut, _)) => format!("{}…", &redacted[..cut]), + None => redacted, + } +} + +/// The provider's `provider_config` block. +/// +/// Field names are constrained by the desktop's `validate_provider_config`, +/// which rejects any key whose word-split contains `secret`/`password`/ +/// `token`/`key`/`credential`. That is why the identity field is +/// `ssh_identity_file` and not `ssh_key_path`: the latter would be dropped on +/// the way in, silently. `schema_keys_are_accepted_by_the_desktop_validator` +/// pins this. +#[derive(Debug, Clone, Default)] +pub struct SshConfig { + pub host: String, + pub user: Option, + pub port: Option, + pub identity_file: Option, + pub buzz_acp_path: Option, +} + +impl SshConfig { + pub fn from_request(request: &serde_json::Value) -> Result { + let config = request + .get("provider_config") + .ok_or("request is missing 'provider_config'")?; + + let string = |key: &str| { + config + .get(key) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) + }; + + let host = string("ssh_host").ok_or("'ssh_host' is required")?; + // `ssh` reads a leading dash as an option, and whitespace would split + // the argument. Neither can appear in a real hostname. + if host.starts_with('-') || host.chars().any(|c| c.is_whitespace() || c.is_control()) { + return Err(format!("'ssh_host' is not a valid host: {host:?}")); + } + + let port = match config.get("ssh_port") { + Some(serde_json::Value::Number(n)) => n.as_u64(), + Some(serde_json::Value::String(s)) if !s.trim().is_empty() => Some( + s.trim() + .parse::() + .map_err(|_| format!("'ssh_port' is not a port number: {:?}", s.trim()))?, + ), + _ => None, + }; + let port = match port { + Some(p) if (1..=65535).contains(&p) => Some(p as u16), + Some(p) => return Err(format!("'ssh_port' is out of range: {p}")), + None => None, + }; + + Ok(Self { + host, + user: string("ssh_user"), + port, + identity_file: string("ssh_identity_file"), + buzz_acp_path: string("buzz_acp_path"), + }) + } + + /// The `[user@]host` argument handed to `ssh`. + pub fn target(&self) -> String { + match &self.user { + Some(user) if !self.host.contains('@') => format!("{user}@{}", self.host), + _ => self.host.clone(), + } + } + + /// The host without any `user@` prefix, for tailnet membership lookups. + pub fn bare_host(&self) -> &str { + self.host + .rsplit_once('@') + .map_or(self.host.as_str(), |(_, host)| host) + } +} + +/// The `info` response, including the Tailscale-decorated config schema. +/// +/// When Tailscale is absent, logged out, or has no usable peers, `ssh_host` +/// carries no `oneOf` and the desktop renders exactly the plain text field it +/// renders today. That degradation is structural, not a feature flag. +pub fn info_response() -> serde_json::Value { + let mut ssh_host = serde_json::json!({ + "type": "string", + "title": "Server", + "description": "hostname, IP, or user@host", + }); + let devices = crate::tailscale::Tailnet::detect().schema_options(); + if !devices.is_empty() { + ssh_host["oneOf"] = serde_json::Value::Array(devices); + } + + serde_json::json!({ + "ok": true, + "name": "SSH", + "version": env!("CARGO_PKG_VERSION"), + "description": "Run agents on a remote host over SSH, supervised by systemd --user.", + "config_schema": { + "type": "object", + "required": ["ssh_host"], + "properties": { + "ssh_host": ssh_host, + "ssh_user": { "type": "string", "title": "User" }, + "ssh_port": { "type": "integer", "title": "Port", "default": 22 }, + "ssh_identity_file": { + "type": "string", + "title": "SSH identity file (optional)", + "description": "Defaults to your ~/.ssh/config and agent", + }, + "buzz_acp_path": { + "type": "string", + "title": "buzz-acp path on the server (optional)", + "description": "Defaults to whatever is on the server's PATH", + }, + }, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mirrors `validate_provider_config` (`backend.rs`): a config key whose + /// word-split contains a forbidden word is rejected *by the desktop*, which + /// would silently drop the field. Reimplemented here so the schema cannot + /// drift into a name the desktop refuses to forward. + fn desktop_rejects_key(key: &str) -> bool { + let mut words = Vec::new(); + let mut current = String::new(); + let chars: Vec = key.chars().collect(); + for (i, &ch) in chars.iter().enumerate() { + if ch == '_' || ch == '-' || ch == '.' { + if !current.is_empty() { + words.push(current.to_lowercase()); + current.clear(); + } + } else if ch.is_uppercase() { + let prev_lower = current.chars().last().is_some_and(char::is_lowercase); + let acronym_end = current.chars().last().is_some_and(char::is_uppercase) + && chars.get(i + 1).is_some_and(|c| c.is_lowercase()); + if prev_lower || acronym_end { + words.push(current.to_lowercase()); + current.clear(); + } + current.push(ch); + } else { + current.push(ch); + } + } + if !current.is_empty() { + words.push(current.to_lowercase()); + } + ["secret", "password", "token", "key", "credential"] + .iter() + .any(|forbidden| words.iter().any(|word| word == forbidden)) + } + + #[test] + fn schema_keys_are_accepted_by_the_desktop_validator() { + let info = info_response(); + let properties = info["config_schema"]["properties"].as_object().unwrap(); + assert!(properties.contains_key("ssh_identity_file")); + for key in properties.keys() { + assert!( + !desktop_rejects_key(key), + "config key {key:?} would be rejected by validate_provider_config" + ); + } + // The name this field must never have. + assert!(desktop_rejects_key("ssh_key_path")); + } + + #[test] + fn info_schema_has_no_one_of_without_tailscale_devices() { + // Nothing in this test environment advertises a tailnet, so the schema + // must degrade to the plain text field. + let info = info_response(); + let host = &info["config_schema"]["properties"]["ssh_host"]; + if crate::tailscale::Tailnet::detect() + .schema_options() + .is_empty() + { + assert!(host.get("oneOf").is_none()); + } + } + + #[test] + fn secret_never_renders_its_value() { + let secret: Secret = serde_json::from_str("\"nsec1verysecretvalue\"").unwrap(); + assert_eq!(format!("{secret:?}"), "[REDACTED]"); + assert_eq!(format!("{secret}"), "[REDACTED]"); + assert_eq!(secret.expose(), "nsec1verysecretvalue"); + assert!(!secret.is_empty()); + } + + #[test] + fn redact_strips_credential_prefixes() { + let out = redact("failed: nsec1abc123 and sprt_tok_xyz789 rejected"); + assert!(!out.contains("nsec1abc123")); + assert!(!out.contains("sprt_tok_xyz789")); + assert_eq!(out, "failed: [REDACTED] and [REDACTED] rejected"); + } + + #[test] + fn config_parses_port_from_number_or_string() { + let request = serde_json::json!({ + "provider_config": { "ssh_host": "vps", "ssh_user": "ubuntu", "ssh_port": 2222 } + }); + let config = SshConfig::from_request(&request).unwrap(); + assert_eq!(config.port, Some(2222)); + assert_eq!(config.target(), "ubuntu@vps"); + + let request = serde_json::json!({ + "provider_config": { "ssh_host": "vps", "ssh_port": "2222" } + }); + assert_eq!(SshConfig::from_request(&request).unwrap().port, Some(2222)); + } + + #[test] + fn config_rejects_hosts_ssh_would_read_as_options() { + for host in ["-oProxyCommand=touch /tmp/pwn", "vps example", ""] { + let request = serde_json::json!({ "provider_config": { "ssh_host": host } }); + assert!( + SshConfig::from_request(&request).is_err(), + "accepted {host:?}" + ); + } + } + + #[test] + fn config_keeps_an_inline_user_over_the_user_field() { + let request = serde_json::json!({ + "provider_config": { "ssh_host": "root@vps", "ssh_user": "ubuntu" } + }); + let config = SshConfig::from_request(&request).unwrap(); + assert_eq!(config.target(), "root@vps"); + assert_eq!(config.bare_host(), "vps"); + } +} diff --git a/crates/buzz-backend-ssh/src/ssh.rs b/crates/buzz-backend-ssh/src/ssh.rs new file mode 100644 index 00000000000..b869a07afe2 --- /dev/null +++ b/crates/buzz-backend-ssh/src/ssh.rs @@ -0,0 +1,478 @@ +//! Transport: the system `ssh` binary, driven with a generated script on stdin. +//! +//! `ssh` rather than a Rust SSH library, because the alternative is +//! reimplementing `~/.ssh/config`, `ProxyJump`, agent forwarding, known-hosts +//! policy and Tailscale's `ProxyCommand` — badly, in a security-sensitive +//! place. The system client already has all of it, configured the way the user +//! configured it. +//! +//! **Every op sends its script over stdin to a remote `sh -s`.** That is what +//! makes the crate's central invariant true by construction: the remote `ps` is +//! world-readable and the desktop's redaction has no reach there, so a secret +//! on the remote argv would leak the agent identity to every user on the box. +//! With the script on stdin the remote argv is the literal string `sh -s` and +//! the local argv is the ssh options — neither ever carries a credential. + +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +use crate::protocol::{Failure, SshConfig}; + +/// Remote output is wrapped into this provider's own stdout, which the desktop +/// caps at 1 MB. Refusing to buffer more than that here makes the failure a +/// clear message instead of an OOM. +const OUTPUT_CAP: usize = 1_048_576; + +/// Deliberately not `Debug`: `stderr` is raw remote output, and only +/// [`Output::failure`] runs it through the credential scrubber. +pub struct Output { + pub status: Option, + pub stdout: String, + pub stderr: String, +} + +impl Output { + pub fn ok(&self) -> bool { + self.status == Some(0) + } + + /// A one-line failure description, credential-scrubbed. + pub fn failure(&self) -> String { + let detail = crate::protocol::snippet(&self.stderr); + let code = self + .status + .map(|c| format!("exit {c}")) + .unwrap_or_else(|| "killed by signal".to_string()); + if detail.is_empty() { + format!("ssh failed ({code})") + } else { + format!("ssh failed ({code}): {detail}") + } + } +} + +/// A configured `ssh` invocation. Holds no connection — each `run` is one +/// process, and each op is one `run`. +pub struct Session { + binary: PathBuf, + args: Vec, +} + +impl Session { + /// `accept_new_host_key` must be true only for addresses that came from the + /// Tailscale device list. Those are reached over an already + /// WireGuard-authenticated transport, so trust-on-first-use adds nothing. + /// For a manually typed host it would convert the user's own known-hosts + /// decision into a silent default, which is a real MITM window. + pub fn new(config: &SshConfig, accept_new_host_key: bool) -> Result { + let binary = resolve_ssh().ok_or("ssh client not found on PATH")?; + let mut args = vec![ + // Removes every interactive prompt structurally, which is what + // makes "this provider never asks for, transmits, or stores a + // password" a property of the code rather than a promise. + "-o".into(), + "BatchMode=yes".into(), + "-o".into(), + "ConnectTimeout=10".into(), + "-o".into(), + format!( + "StrictHostKeyChecking={}", + if accept_new_host_key { + "accept-new" + } else { + "ask" + } + ), + // With BatchMode, `ask` cannot prompt — it declines. Keep ssh's own + // diagnosis on stderr and nothing else. + "-o".into(), + "LogLevel=ERROR".into(), + ]; + if let Some(port) = config.port { + args.push("-p".into()); + args.push(port.to_string()); + } + if let Some(identity) = &config.identity_file { + args.push("-i".into()); + args.push(identity.clone()); + } + args.push("--".into()); + args.push(config.target()); + // The remote argv, in full. Everything else arrives on stdin. + args.push("sh -s".into()); + Ok(Self { binary, args }) + } + + /// Feed `script` to the remote shell and collect its output. + /// + /// A tailnet ACL asking for a browser re-auth is classified here rather + /// than at the five call sites: `ssh` prints the URL and then blocks, so + /// every op would otherwise burn its whole budget — 8s to 300s — and report + /// a bare timeout for something one click fixes. Owning it here also keeps + /// the marker out of the callers, so no one is tempted to match on the + /// string. + pub fn run(&self, script: &str, timeout: Duration) -> Result { + let mut command = Command::new(&self.binary); + command + .args(&self.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + configure_no_window(&mut command); + let mut child = command + .spawn() + .map_err(|e| format!("failed to run {}: {e}", self.binary.display()))?; + + // Write on a thread: a script larger than the pipe buffer would + // otherwise deadlock against a remote that is still starting up. + let payload = script.to_string(); + let mut stdin = child.stdin.take(); + let writer = std::thread::spawn(move || { + if let Some(stdin) = stdin.as_mut() { + let _ = stdin.write_all(payload.as_bytes()); + } + drop(stdin); + }); + + let stdout = Drain::start(child.stdout.take()); + let stderr = Drain::start(child.stderr.take()); + + // Poll to a deadline rather than blocking on `wait`, the repo's standard + // pattern (`discovery::probe_codex_acp_major_version`). + let deadline = Instant::now() + timeout; + let outcome = loop { + match child.try_wait() { + Ok(Some(status)) => break Ok(status.code()), + Ok(None) => { + // Scanning what has arrived, rather than waiting for EOF, + // is what turns the whole budget into one poll interval: + // the auth prompt is printed and *then* ssh blocks. Checked + // before the deadline so the timeout path cannot discard an + // answer already sitting in the buffer. + if let Some(url) = stderr.with_bytes(crate::tailscale::auth_url_in) { + break Err(Failure::tailscale_auth(url)); + } + if Instant::now() >= deadline { + break Err(stderr.with_bytes(|buffered| timed_out(timeout, buffered))); + } + std::thread::sleep(Duration::from_millis(25)); + } + Err(e) => break Err(format!("ssh wait failed: {e}").into()), + } + }; + if outcome.is_err() { + let _ = child.kill(); + let _ = child.wait(); + } + // Joined before the error is returned either way: the writer holds the + // stdin handle, and a live handle keeps a killed child's pipe open. + let _ = writer.join(); + + let status = outcome?; + let stderr = stderr.finish(); + // The same classification for a host that printed the URL and then + // exited on its own, so both shapes reach the caller as one failure. + if let Some(url) = crate::tailscale::auth_url_in(stderr.as_bytes()) { + return Err(Failure::tailscale_auth(url)); + } + Ok(Output { + status, + stdout: stdout.finish(), + stderr, + }) + } +} + +/// A pipe being read to EOF on its own thread, capped at [`OUTPUT_CAP`]. Both +/// pipes must be drained concurrently with the wait or a chatty remote fills +/// one and blocks. +/// +/// The buffer is shared rather than returned by the thread so the poll loop can +/// read what has arrived so far without waiting on the child — which is what +/// makes the Tailscale check detectable, and is also the only safe shape on the +/// timeout path, where a descendant holding the pipe open would leave a +/// `join()` blocked forever. +struct Drain { + buffer: Arc>>, + thread: std::thread::JoinHandle<()>, +} + +impl Drain { + fn start(pipe: Option) -> Self { + let buffer = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&buffer); + let thread = std::thread::spawn(move || { + let Some(mut pipe) = pipe else { return }; + let mut chunk = [0u8; 8192]; + loop { + // Read outside the lock: holding it across a blocking read + // would stall every peek for as long as the remote is quiet. + let Ok(read @ 1..) = pipe.read(&mut chunk) else { + return; + }; + let mut buffer = lock(&sink); + if buffer.len() >= OUTPUT_CAP { + return; + } + buffer.extend_from_slice(&chunk[..read]); + buffer.truncate(OUTPUT_CAP); + } + }); + Self { buffer, thread } + } + + /// Read what has arrived so far, in place. Never waits on the child, so it + /// is safe on the timeout path. + fn with_bytes(&self, f: impl FnOnce(&[u8]) -> T) -> T { + f(&lock(&self.buffer)) + } + + /// Everything, once the pipe closes. + fn finish(self) -> String { + let _ = self.thread.join(); + String::from_utf8_lossy(&lock(&self.buffer)).into_owned() + } +} + +/// A timeout that still reports whatever the child managed to say. The drained +/// stderr used to be dropped on this path — exactly when it is most wanted, on +/// a host that printed a diagnosis and then hung. Scrubbed through the same +/// `snippet` as [`Output::failure`], because it is raw remote output. +fn timed_out(timeout: Duration, buffered: &[u8]) -> Failure { + let detail = crate::protocol::snippet(&String::from_utf8_lossy(buffered)); + let seconds = timeout.as_secs(); + if detail.is_empty() { + format!("ssh timed out after {seconds}s").into() + } else { + format!("ssh timed out after {seconds}s: {detail}").into() + } +} + +/// The drain thread cannot panic while holding the buffer, so poisoning is +/// unreachable; recovering rather than unwrapping keeps that from ever becoming +/// a way to take the provider down. +fn lock(buffer: &Mutex>) -> std::sync::MutexGuard<'_, Vec> { + buffer.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Windows ships OpenSSH in System32 but does not always put it on a GUI +/// process's PATH, so look there first. +fn resolve_ssh() -> Option { + let exe = if cfg!(windows) { "ssh.exe" } else { "ssh" }; + let mut candidates = Vec::new(); + if cfg!(windows) { + let system_root = std::env::var_os("SystemRoot") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Windows")); + candidates.push(system_root.join("System32").join("OpenSSH").join(exe)); + } + if let Some(path) = std::env::var_os("PATH") { + candidates.extend(std::env::split_paths(&path).map(|dir| dir.join(exe))); + } + candidates.into_iter().find(|path| path.is_file()) +} + +/// The desktop's `util::configure_no_window`, transcribed. The desktop applies +/// it to its own spawn of this provider, but `CREATE_NO_WINDOW` does not +/// inherit, so every child spawned here must set it again or Windows flashes a +/// console window per op. +pub fn configure_no_window(command: &mut Command) { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + #[cfg(not(windows))] + let _ = command; +} + +/// Quote a value for POSIX `sh`. Single quotes suppress every expansion; the +/// only character needing care is `'` itself. +pub fn quote(value: &str) -> String { + format!("'{}'", value.replace('\'', r"'\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(host: &str) -> SshConfig { + SshConfig { + host: host.into(), + user: Some("ubuntu".into()), + port: Some(2222), + identity_file: Some("/home/me/.ssh/id_ed25519".into()), + buzz_acp_path: None, + } + } + + #[test] + fn every_invocation_is_batch_mode_with_the_script_on_stdin() { + let Ok(session) = Session::new(&config("vps"), false) else { + return; // no ssh client in this environment + }; + assert!(session + .args + .windows(2) + .any(|w| w == ["-o", "BatchMode=yes"])); + // The remote argv is exactly `sh -s`; nothing op-specific, and so + // nothing secret, is ever visible in the remote process table. + assert_eq!(session.args.last().unwrap(), "sh -s"); + assert!(session.args.contains(&"--".to_string())); + assert!(session.args.windows(2).any(|w| w == ["-p", "2222"])); + assert!(session + .args + .windows(2) + .any(|w| w == ["-i", "/home/me/.ssh/id_ed25519"])); + } + + #[test] + fn accept_new_host_key_is_scoped_to_tailnet_addresses() { + let Ok(manual) = Session::new(&config("vps.example.com"), false) else { + return; + }; + assert!(manual + .args + .contains(&"StrictHostKeyChecking=ask".to_string())); + assert!(!manual + .args + .contains(&"StrictHostKeyChecking=accept-new".to_string())); + + let tailnet = Session::new(&config("vps.tailcfd703.ts.net"), true).unwrap(); + assert!(tailnet + .args + .contains(&"StrictHostKeyChecking=accept-new".to_string())); + } + + #[test] + fn quote_neutralizes_shell_metacharacters() { + assert_eq!(quote("plain"), "'plain'"); + assert_eq!(quote("a b"), "'a b'"); + assert_eq!(quote("$(touch /tmp/pwn)"), "'$(touch /tmp/pwn)'"); + assert_eq!(quote("it's"), r"'it'\''s'"); + } + + // The two tests below stand a local `/bin/sh` in for the remote host to + // exercise the write/drain/wait loop without a network. Everything else in + // this file is platform-neutral and runs on the Windows CI job too. + #[cfg(unix)] + #[test] + fn run_reports_the_command_output() { + let Ok(session) = Session::new(&config("vps"), false) else { + return; + }; + // Point at a shell instead of a real host: `run` is transport + // plumbing, and this exercises the write/drain/wait loop without a + // network. `sh` ignores the ssh options it does not know. + let local = Session { + binary: PathBuf::from("/bin/sh"), + args: vec!["-s".into()], + }; + let _ = session; + let out = local + .run( + "printf hello; printf oops >&2; exit 3", + Duration::from_secs(10), + ) + .unwrap(); + assert_eq!(out.stdout, "hello"); + assert_eq!(out.stderr, "oops"); + assert_eq!(out.status, Some(3)); + assert!(!out.ok()); + assert!(out.failure().contains("exit 3")); + } + + #[cfg(unix)] + #[test] + fn run_kills_a_command_that_outlives_its_budget() { + let session = Session { + binary: PathBuf::from("/bin/sh"), + args: vec!["-s".into()], + }; + // `Output` is intentionally not `Debug` — it carries raw remote stderr, + // which only `failure()` scrubs — so the error comes out by hand. + let Err(error) = session.run("sleep 30", Duration::from_millis(300)) else { + panic!("a command past its deadline must be killed, not awaited"); + }; + assert!(error.message.contains("timed out"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn a_timeout_still_reports_what_the_host_managed_to_say() { + // The stderr of a host that diagnoses itself and *then* hangs is + // exactly the output worth keeping, and it used to be dropped. + let session = Session { + binary: PathBuf::from("/bin/sh"), + args: vec!["-s".into()], + }; + let Err(error) = session.run( + "printf 'disk is full\\n' >&2; sleep 30", + Duration::from_millis(300), + ) else { + panic!("a command past its deadline must be killed, not awaited"); + }; + assert!(error.message.contains("disk is full"), "{error}"); + assert!(error.auth_url.is_none(), "{error}"); + } + + #[cfg(unix)] + #[test] + fn a_tailscale_auth_prompt_fails_fast_instead_of_burning_the_budget() { + let session = Session { + binary: PathBuf::from("/bin/sh"), + args: vec!["-s".into()], + }; + let started = Instant::now(); + // The shape ssh prints under a `check`-action tailnet ACL: the URL, + // then a wait for a human who is not there. + let Err(error) = session.run( + "printf '# To authenticate, visit: https://login.tailscale.com/a/abc123\\n' >&2\nsleep 30\n", + Duration::from_secs(10), + ) else { + panic!("a host asking for browser auth must fail, not hang"); + }; + assert_eq!( + error.auth_url.as_deref(), + Some("https://login.tailscale.com/a/abc123") + ); + // The fail-fast IS the feature: waiting out the budget is the bug. + assert!(started.elapsed() < Duration::from_secs(5), "{error}"); + } + + #[cfg(unix)] + #[test] + fn a_tailscale_auth_prompt_on_a_failing_exit_is_classified_the_same_way() { + // ssh can also print the URL and give up on its own, which lands past + // the poll loop entirely. + let session = Session { + binary: PathBuf::from("/bin/sh"), + args: vec!["-s".into()], + }; + let Err(error) = session.run( + "printf 'visit: https://login.tailscale.com/a/xyz789\\n' >&2; exit 255", + Duration::from_secs(10), + ) else { + panic!("a host asking for browser auth must fail, not succeed"); + }; + assert_eq!( + error.auth_url.as_deref(), + Some("https://login.tailscale.com/a/xyz789") + ); + } + + #[test] + fn failure_text_scrubs_credentials_from_remote_stderr() { + let output = Output { + status: Some(1), + stdout: String::new(), + stderr: "refused key nsec1leakedleaked".into(), + }; + assert!(!output.failure().contains("nsec1leakedleaked")); + assert!(output.failure().contains("[REDACTED]")); + } +} diff --git a/crates/buzz-backend-ssh/src/tailscale.rs b/crates/buzz-backend-ssh/src/tailscale.rs new file mode 100644 index 00000000000..c365f37fa6b --- /dev/null +++ b/crates/buzz-backend-ssh/src/tailscale.rs @@ -0,0 +1,473 @@ +//! Tailscale device enumeration, via the `tailscale` CLI. +//! +//! The CLI rather than the LocalAPI: LocalAPI means three transports (unix +//! socket on Linux, named pipe on Windows, and a localhost TCP port plus a +//! `sameuserproof` token scavenged from `/Library/Tailscale` on macOS GUI +//! builds) plus a Host-header gate that 403s on the obvious guesses. The CLI is +//! one `Command::new`, one JSON parse, one code path. +//! +//! Everything here degrades silently: Tailscale absent, logged out, or stopped +//! must leave the remote flow exactly as good as it is without Tailscale, so +//! every failure maps to "no devices" and never to an error. `tailscale status +//! --help` warns that the `--json` "format [is] subject to change", so every +//! field is optional and a parse failure is just as quiet. + +use std::path::PathBuf; +use std::process::Command; + +use serde::Deserialize; + +/// `tailscale status --json`, reduced to the fields we consume. +#[derive(Deserialize)] +struct StatusDoc { + #[serde(rename = "BackendState")] + backend_state: Option, + #[serde(rename = "CurrentTailnet")] + current_tailnet: Option, + #[serde(rename = "Peer")] + peer: Option>, +} + +#[derive(Deserialize)] +struct CurrentTailnet { + #[serde(rename = "MagicDNSEnabled")] + magic_dns_enabled: Option, +} + +#[derive(Deserialize)] +struct Peer { + #[serde(rename = "HostName")] + host_name: Option, + #[serde(rename = "DNSName")] + dns_name: Option, + #[serde(rename = "OS")] + os: Option, + #[serde(rename = "Online")] + online: Option, + #[serde(rename = "TailscaleIPs")] + tailscale_ips: Option>, + /// Present only when the node advertises Tailscale SSH. Absence is the + /// negative signal: it means "not SSH-ready", not "unknown". + #[serde(rename = "sshHostKeys")] + ssh_host_keys: Option>, +} + +/// A peer that could plausibly host an agent. +pub struct Device { + /// The address to hand to `ssh`: MagicDNS FQDN when available, else the + /// first Tailscale IP. + pub address: String, + /// What the picker shows. Tailscale-SSH readiness is folded in here rather + /// than carried as a separate field: the schema's `oneOf` entries are + /// `{const, title}` pairs, so the label is the only channel to the user. + pub label: String, + /// Ordering only — the label already says it. Kept separate so sorting + /// never has to parse its own rendering. + online: bool, +} + +/// The set of tailnet peers usable as SSH targets. Empty whenever Tailscale is +/// missing, logged out, stopped, or has nothing that can host an agent. +#[derive(Default)] +pub struct Tailnet { + devices: Vec, +} + +impl Tailnet { + /// Run `tailscale status --json` and parse it. Never fails. + pub fn detect() -> Self { + let Some(binary) = cli_candidates().into_iter().find(|path| path.is_file()) else { + return Self::default(); + }; + let mut command = Command::new(binary); + command.arg("status").arg("--json"); + crate::ssh::configure_no_window(&mut command); + // We never branch on the exit code: a logged-out daemon exits 0 with + // `BackendState: "NeedsLogin"` and `Peer: null`, while a missing daemon + // socket exits 1. `parse` handles both by looking at the document. + match command.output() { + Ok(output) => Self::parse(&String::from_utf8_lossy(&output.stdout)), + Err(_) => Self::default(), + } + } + + /// Pure half of [`Tailnet::detect`], over the raw `--json` document. + pub fn parse(stdout: &str) -> Self { + let Ok(doc) = serde_json::from_str::(stdout) else { + return Self::default(); + }; + if doc.backend_state.as_deref() != Some("Running") { + return Self::default(); + } + let magic_dns = doc + .current_tailnet + .as_ref() + .and_then(|t| t.magic_dns_enabled) + .unwrap_or(false); + + // `Self` is deliberately not in `Peer`, so "this computer" never shows + // up as a remote host. + let mut devices: Vec = doc + .peer + .unwrap_or_default() + .into_values() + .filter_map(|peer| device_from_peer(&peer, magic_dns)) + .collect(); + // Online first, then by label, so the list opens on what is usable now. + // Sorting on the flag rather than the rendered label: a host actually + // named "· offline" would otherwise sort itself last. + devices.sort_by(|a, b| { + a.online + .cmp(&b.online) + .reverse() + .then_with(|| a.label.cmp(&b.label)) + }); + Self { devices } + } + + #[cfg(test)] + fn devices(&self) -> &[Device] { + &self.devices + } + + /// True when `host` is one of the enumerated tailnet addresses. + /// + /// This gates `StrictHostKeyChecking=accept-new`: a tailnet address is + /// reached over an already-WireGuard-authenticated transport, so TOFU adds + /// nothing there. A manually typed host keeps the user's own known-hosts + /// semantics, where an unknown key is a decision rather than a default. + pub fn contains(&self, host: &str) -> bool { + self.devices + .iter() + .any(|device| device.address.eq_ignore_ascii_case(host)) + } + + /// The `oneOf` decoration for the `ssh_host` schema property. Empty when + /// there is nothing to offer, which drops the key entirely and leaves the + /// desktop rendering today's plain text field. + pub fn schema_options(&self) -> Vec { + self.devices + .iter() + .map(|device| serde_json::json!({ "const": device.address, "title": device.label })) + .collect() + } +} + +/// The Tailscale SSH re-auth URL in a subprocess's stderr, or `None`. +/// +/// A tailnet ACL with the `check` action makes `ssh` print +/// `To authenticate, visit: https://login.tailscale.com/a/` and then +/// block until a human clicks it — including under `BatchMode`, which is why +/// this is detectable at all. +/// +/// The result is **constructed, never parsed**: the prefix is matched +/// byte-exactly and the remainder is constrained to an unreserved-character +/// token, so no host, userinfo, scheme, query or fragment from the subprocess's +/// output can survive into the returned string. A URL scraped from a +/// subprocess and handed to the OS browser opener is an injection primitive; +/// building the answer makes that structurally impossible instead of checking +/// for it afterwards. The cost is that a custom control server (Headscale) +/// prints a different host and gets no clickable link — the right trade against +/// trusting an arbitrary host from remote output. +pub fn auth_url_in(stderr: &[u8]) -> Option { + const MARKER: &str = "https://login.tailscale.com/a/"; + /// Comfortably above the ~14-character token Tailscale prints. A longer one + /// is dropped rather than truncated: half a URL is worse than none. + const MAX_TOKEN: usize = 128; + + let start = stderr + .windows(MARKER.len()) + .position(|window| window == MARKER.as_bytes())? + + MARKER.len(); + let token: String = stderr[start..] + .iter() + .copied() + .take(MAX_TOKEN + 1) + .take_while(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~')) + .map(char::from) + .collect(); + (1..=MAX_TOKEN) + .contains(&token.len()) + .then(|| format!("{MARKER}{token}")) +} + +fn device_from_peer(peer: &Peer, magic_dns: bool) -> Option { + let os = peer.os.as_deref().unwrap_or(""); + // Phones and TVs are tailnet members but cannot host an agent. + if matches!(os.to_ascii_lowercase().as_str(), "ios" | "android" | "tvos") { + return None; + } + + let fqdn = peer + .dns_name + .as_deref() + .map(|name| name.trim_end_matches('.')) + .filter(|name| !name.is_empty()); + let ip = peer + .tailscale_ips + .as_ref() + .and_then(|ips| ips.first()) + .map(String::as_str); + // MagicDNS name when it is resolvable, else the raw tailnet IP. + let address = if magic_dns { fqdn.or(ip) } else { ip.or(fqdn) }?.to_string(); + + let name = peer + .host_name + .as_deref() + .filter(|name| !name.is_empty()) + .unwrap_or(&address); + let tailscale_ssh = peer + .ssh_host_keys + .as_ref() + .is_some_and(|keys| !keys.is_empty()); + + let online = peer.online.unwrap_or(false); + let mut label = name.to_string(); + if !os.is_empty() { + label.push_str(" — "); + label.push_str(os); + } + label.push_str(if online { " · online" } else { " · offline" }); + if tailscale_ssh { + label.push_str(" · Tailscale SSH"); + } + + Some(Device { + address, + label, + online, + }) +} + +/// Where the `tailscale` CLI lives when PATH does not have it. macOS GUI apps +/// inherit a minimal launchd PATH and the App Store build ships the CLI only +/// inside the bundle; Windows registers an install dir but not a PATH entry. +fn cli_candidates() -> Vec { + let mut candidates = Vec::new(); + let exe = if cfg!(windows) { + "tailscale.exe" + } else { + "tailscale" + }; + if let Some(path) = std::env::var_os("PATH") { + candidates.extend(std::env::split_paths(&path).map(|dir| dir.join(exe))); + } + if cfg!(windows) { + let program_files = std::env::var_os("ProgramFiles") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Program Files")); + candidates.push(program_files.join("Tailscale").join(exe)); + } else { + candidates.push(PathBuf::from("/usr/bin/tailscale")); + candidates.push(PathBuf::from("/usr/local/bin/tailscale")); + candidates.push(PathBuf::from( + "/Applications/Tailscale.app/Contents/MacOS/Tailscale", + )); + } + candidates +} + +#[cfg(test)] +mod tests { + use super::*; + + const RUNNING: &str = r#"{ + "BackendState": "Running", + "CurrentTailnet": { "MagicDNSEnabled": true }, + "Self": { "HostName": "vmi3160506", "OS": "linux", "Online": true }, + "Peer": { + "nodekey:aa": { "HostName": "troys-mac-mini", "DNSName": "troys-mac-mini.tailcfd703.ts.net.", + "OS": "macOS", "Online": false, "TailscaleIPs": ["100.64.0.2"] }, + "nodekey:bb": { "HostName": "troys_machine", "DNSName": "troys-machine.tailcfd703.ts.net.", + "OS": "windows", "Online": true, "TailscaleIPs": ["100.121.179.68"] }, + "nodekey:cc": { "HostName": "localhost", "DNSName": "iphone181.tailcfd703.ts.net.", + "OS": "iOS", "Online": true, "TailscaleIPs": ["100.64.0.4"] }, + "nodekey:dd": { "HostName": "vps-prod", "DNSName": "vps-prod.tailcfd703.ts.net.", + "OS": "linux", "Online": true, "TailscaleIPs": ["100.64.0.5"], + "sshHostKeys": ["ssh-ed25519 AAAA"] } + } + }"#; + + /// A logged-out daemon exits 0. Branching on the exit code gets this wrong. + const NEEDS_LOGIN: &str = r#"{ + "BackendState": "NeedsLogin", + "Health": ["Tailscale is stopped."], + "Peer": null + }"#; + + #[test] + fn running_tailnet_yields_hostable_peers_only() { + let tailnet = Tailnet::parse(RUNNING); + let addresses: Vec<&str> = tailnet + .devices() + .iter() + .map(|d| d.address.as_str()) + .collect(); + // iOS is filtered out; `Self` was never a peer to begin with. + assert_eq!( + addresses, + [ + "troys-machine.tailcfd703.ts.net", + "vps-prod.tailcfd703.ts.net", + "troys-mac-mini.tailcfd703.ts.net", + ] + ); + // Online first, offline last. + assert!(tailnet.devices()[2].label.contains("· offline")); + } + + #[test] + fn ordering_reads_the_online_flag_not_the_rendered_label() { + // A hostname that happens to contain the offline marker must not sort + // itself last — the flag decides, never the label text. + let doc = RUNNING.replace( + "\"HostName\": \"vps-prod\"", + "\"HostName\": \"a · offline\"", + ); + let tailnet = Tailnet::parse(&doc); + let devices = tailnet.devices(); + assert!( + devices[0].label.starts_with("a · offline"), + "{:?}", + devices[0].label + ); + assert!(devices[0].label.ends_with("· online · Tailscale SSH")); + assert!(devices[2].label.contains("· offline")); + } + + #[test] + fn ssh_host_keys_drive_the_tailscale_ssh_marker() { + let tailnet = Tailnet::parse(RUNNING); + let vps = tailnet + .devices() + .iter() + .find(|d| d.address.starts_with("vps-prod")) + .unwrap(); + assert_eq!(vps.label, "vps-prod — linux · online · Tailscale SSH"); + + // Absent `sshHostKeys` means not SSH-ready, not unknown — so the peer + // is still offered, just without the marker. + let windows = tailnet + .devices() + .iter() + .find(|d| d.address.starts_with("troys-machine")) + .unwrap(); + assert_eq!(windows.label, "troys_machine — windows · online"); + } + + #[test] + fn magic_dns_disabled_falls_back_to_the_tailscale_ip() { + let doc = RUNNING.replace("\"MagicDNSEnabled\": true", "\"MagicDNSEnabled\": false"); + let tailnet = Tailnet::parse(&doc); + assert!(tailnet + .devices() + .iter() + .all(|d| d.address.starts_with("100."))); + } + + #[test] + fn non_running_and_garbage_documents_yield_nothing_quietly() { + for doc in [ + NEEDS_LOGIN, + r#"{"BackendState":"Stopped","Peer":{}}"#, + "{\"BackendState\": \"Runn", + "", + "not json at all", + ] { + let tailnet = Tailnet::parse(doc); + assert!( + tailnet.devices().is_empty(), + "unexpected devices for {doc:?}" + ); + assert!(tailnet.schema_options().is_empty()); + } + } + + #[test] + fn schema_options_are_const_title_pairs() { + let options = Tailnet::parse(RUNNING).schema_options(); + assert_eq!(options.len(), 3); + assert_eq!(options[0]["const"], "troys-machine.tailcfd703.ts.net"); + assert!(options[0]["title"].as_str().unwrap().contains("windows")); + } + + #[test] + fn contains_matches_only_enumerated_addresses() { + let tailnet = Tailnet::parse(RUNNING); + assert!(tailnet.contains("VPS-PROD.tailcfd703.ts.net")); + assert!(!tailnet.contains("vps.example.com")); + assert!(!Tailnet::parse(NEEDS_LOGIN).contains("vps-prod.tailcfd703.ts.net")); + } + + #[test] + fn an_auth_url_is_lifted_out_of_the_line_ssh_prints_around_it() { + let stderr = + b"# To authenticate, visit:\n#\n#\thttps://login.tailscale.com/a/1a2b3c4d5e6f7g\n#\n"; + assert_eq!( + auth_url_in(stderr).as_deref(), + Some("https://login.tailscale.com/a/1a2b3c4d5e6f7g") + ); + } + + #[test] + fn stderr_without_the_marker_yields_nothing() { + assert_eq!(auth_url_in(b""), None); + assert_eq!(auth_url_in(b"Permission denied (publickey).\n"), None); + // The host must match byte-exactly: a look-alike control server is not + // the one host this function is allowed to name. + assert_eq!( + auth_url_in(b"https://login.tailscale.com.evil.test/a/tok"), + None + ); + assert_eq!(auth_url_in(b"http://login.tailscale.com/a/tok"), None); + } + + #[test] + fn a_marker_with_no_token_after_it_yields_nothing() { + // Half a URL is worse than none: it would open a Tailscale 404. + assert_eq!(auth_url_in(b"https://login.tailscale.com/a/"), None); + assert_eq!(auth_url_in(b"https://login.tailscale.com/a/ tok"), None); + assert_eq!(auth_url_in(b"https://login.tailscale.com/a/\n"), None); + } + + #[test] + fn an_overlong_token_is_dropped_rather_than_truncated() { + // 128 is the last length that is still plausibly a real token; a + // truncated 129th would be a valid-looking URL that goes nowhere. + let at_cap = format!("https://login.tailscale.com/a/{}", "a".repeat(128)); + assert_eq!(auth_url_in(at_cap.as_bytes()).as_deref(), Some(&at_cap[..])); + let over_cap = format!("https://login.tailscale.com/a/{}", "a".repeat(129)); + assert_eq!(auth_url_in(over_cap.as_bytes()), None); + } + + #[test] + fn nothing_after_the_token_survives_into_the_result() { + // The whole point of constructing rather than parsing: a query, a + // fragment, or a second URL cannot ride along into the browser. + assert_eq!( + auth_url_in(b"https://login.tailscale.com/a/tok?next=https://evil.test#x").as_deref(), + Some("https://login.tailscale.com/a/tok") + ); + // A token flush against the end of the buffer is still a whole token. + assert_eq!( + auth_url_in(b"visit: https://login.tailscale.com/a/tok").as_deref(), + Some("https://login.tailscale.com/a/tok") + ); + } + + #[test] + fn cli_candidates_cover_the_platform_install_locations() { + let candidates = cli_candidates(); + let joined = candidates + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect::>() + .join("|"); + if cfg!(windows) { + assert!(joined.contains("Tailscale\\tailscale.exe")); + } else { + assert!(joined.contains("/usr/bin/tailscale")); + assert!(joined.contains("/Applications/Tailscale.app")); + } + } +} From e1eb13b6937cdfd206ef6ae78e0f10f8cc36cbac Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Wed, 29 Jul 2026 03:47:18 +0200 Subject: [PATCH 02/13] feat(scripts): add a remote-host provisioning preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host contract in docs/remote-agents.md is short, but two of its items fail in ways that do not look like misconfiguration. Without `loginctl enable-linger` the user manager is torn down when the last session ends — so the agent is killed the moment the deploy's own SSH session closes, and the symptom is "the agent keeps dying", not "lingering is off". Without lingering there is also no /run/user/$(id -u), so every `systemctl --user` call fails at the bus before it reaches a unit. This script turns both into a row in a table you read before deploying instead of a mystery you debug after. It fixes what it safely can (enables lingering, creates ~/.local/bin, appends the PATH export to ~/.profile only when absent) and installs nothing. Harness CLIs carry their own installers and their own interactive logins, which cannot run over a non-interactive SSH deploy; buzz-acp and the buzz CLI the deploy op itself resolves or pushes. Reporting them is useful, installing them here would duplicate a code path that already exists and is tested. Only three checks decide the exit code — lingering, ~/.local/bin on PATH, and a reachable systemd --user bus — because only those three make a deploy fail. Everything else, Tailscale included, is a note: Tailscale buys the desktop's device picker, and plain SSH is the unchanged fallback, so a host without it is correctly configured, not degraded. Three details are deliberate and easy to "clean up" wrongly. It refuses to run as root: the env file holding the minted nsec must land owned by the user whose home already holds the harness credentials, and a root run would provision the wrong home. It checks the systemd bus under the same missing-XDG_RUNTIME_DIR assumption the deploy makes, since a check that only passes in a login shell would pass on hosts the deploy then fails on. And it reads Tailscale's SSH state from prefs rather than from `Self.sshHostKeys` in the status document — that field is populated for peers and reads null when a node looks at itself, so the obvious source reports a false negative on an SSH-enabled host. POSIX sh, no jq, no bashisms: it has to run on a bare host, which is the one place none of those are guaranteed. Idempotent — a provisioned host is a no-op. Signed-off-by: Troy Hoffman --- scripts/provision-buzz-host.sh | 242 +++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100755 scripts/provision-buzz-host.sh diff --git a/scripts/provision-buzz-host.sh b/scripts/provision-buzz-host.sh new file mode 100755 index 00000000000..0b83b0ec0b1 --- /dev/null +++ b/scripts/provision-buzz-host.sh @@ -0,0 +1,242 @@ +#!/bin/sh +# ============================================================================= +# provision-buzz-host.sh — preflight a Linux host for Buzz remote agents +# ============================================================================= +# Usage: +# ./scripts/provision-buzz-host.sh # run ON the host, as the agent user +# +# Checks (and where it can, fixes) the host contract that `buzz-backend-ssh` +# assumes: see docs/remote-agents.md, "Host prerequisites". Safe to re-run — +# every action is idempotent, and a fully provisioned host is a no-op. +# +# It installs nothing itself. Harness CLIs have their own installers and their +# own authentication, and stay an operator step. The two Buzz tools — `buzz-acp` +# and the `buzz` CLI — the deploy op resolves on the host's PATH or in +# ~/.local/bin, and *installs* when it resolves none and the desktop supplied a +# binary to push (`BUZZ_ACP_PUSH_BINARY` / `BUZZ_CLI_PUSH_BINARY`, see +# docs/remote-agents.md). With no binary supplied, a missing `buzz-acp` fails +# the deploy with exit 90 — this preflight is what you run first — while a +# missing `buzz` CLI only warns and the deploy continues. +# +# Exit 0 when the mandatory set is green (lingering, ~/.local/bin, systemd +# --user); 1 otherwise. Everything else is reported as a note, never a failure. +# ============================================================================= +set -eu + +USER_NAME="$(id -un)" +HOME_DIR="${HOME:-$(cd ~ && pwd)}" +LOCAL_BIN="${HOME_DIR}/.local/bin" + +# Summary rows, one per line, `requirement|status|action`. POSIX sh has no +# arrays, and a newline-delimited string prints back through one `read` loop. +ROWS="" +BLOCKERS=0 + +# `mandatory` as the third positional: a red row there is what decides the exit +# code, so the caller cannot forget to account for one. +add_row() { # requirement, status, action, [mandatory] + ROWS="${ROWS}${1}|${2}|${3} +" + if [ "${4:-}" = "mandatory" ] && [ "${2}" != "OK" ]; then + BLOCKERS=$((BLOCKERS + 1)) + fi +} + +note() { printf '%s\n' "$*"; } + +# ---- 1. The agent user ------------------------------------------------------ +# The whole flow is root-free: the env file holding the minted nsec lands under +# this user's ownership, beside the harness credentials that already live in +# its home (~/.claude, ~/.config/goose). Provisioning as root would create +# those paths for the wrong user and the deploy would silently target another. +if [ "$(id -u)" -eq 0 ]; then + note "provision-buzz-host: refusing to run as root." + note " Run this as the unprivileged user the agents will run as, e.g.:" + note " su - ubuntu -c '/path/to/provision-buzz-host.sh'" + exit 1 +fi +add_row "non-root user" "OK" "running as ${USER_NAME}" + +# ---- 2. Lingering ----------------------------------------------------------- +# THE non-obvious prerequisite. Without it the user manager is torn down when +# the last session ends, so the agent is killed the moment the deploy's own SSH +# session closes — which reads as a flaky agent, not as a misconfiguration. It +# also creates /run/user/$(id -u), without which no `systemctl --user` call can +# reach the bus at all. +linger_is_on() { + # Two sources because either can be unavailable: `loginctl` needs a logind + # user record (absent on a host with no active session), the marker file is + # world-readable and always authoritative. + if [ -e "/var/lib/systemd/linger/${USER_NAME}" ]; then + return 0 + fi + loginctl show-user "${USER_NAME}" --property=Linger 2>/dev/null | + grep -q '^Linger=yes$' +} + +if linger_is_on; then + add_row "loginctl linger" "OK" "already enabled" mandatory +else + # Best-effort, exactly as the deploy script's own call is: some hosts gate + # enable-linger behind polkit, where it needs a root run instead. + loginctl enable-linger "${USER_NAME}" >/dev/null 2>&1 || true + if linger_is_on; then + add_row "loginctl linger" "OK" "enabled just now" mandatory + else + add_row "loginctl linger" "MISSING" \ + "run: sudo loginctl enable-linger ${USER_NAME}" mandatory + fi +fi + +# ---- 3. ~/.local/bin -------------------------------------------------------- +# Where `buzz-acp` and most harness CLIs install, and what the deploy prepends +# to the unit's PATH. It has to exist and be on the login PATH so `command -v` +# resolves the same binaries the unit will. +mkdir -p "${LOCAL_BIN}" + +case ":${PATH}:" in + *":${LOCAL_BIN}:"*) + add_row "local bin on PATH" "OK" "${LOCAL_BIN}" mandatory + ;; + *) + # Append only when absent: re-running must not stack duplicate exports into + # a file the user also edits by hand. + if [ -f "${HOME_DIR}/.profile" ] && + grep -q '\.local/bin' "${HOME_DIR}/.profile"; then + add_row "local bin on PATH" "NOTE" \ + "already in ~/.profile; re-login to pick it up" mandatory + else + # Single quotes deliberately: $HOME and $PATH must reach .profile as + # literals, to be expanded at each login rather than frozen now. + # shellcheck disable=SC2016 + printf '\n# Added by provision-buzz-host.sh\nexport PATH="$HOME/.local/bin:$PATH"\n' \ + >>"${HOME_DIR}/.profile" + add_row "local bin on PATH" "NOTE" \ + "appended to ~/.profile; re-login to pick it up" mandatory + fi + ;; +esac + +# ---- 4. buzz-acp ------------------------------------------------------------ +# Reported, never installed. `discover_harnesses` tolerates its absence, but +# `deploy` refuses with exit 90, so a host that stops here fails late. +if command -v buzz-acp >/dev/null 2>&1; then + add_row "buzz-acp" "OK" "$(command -v buzz-acp)" +else + add_row "buzz-acp" "MISSING" "copy the release binary to ${LOCAL_BIN}/buzz-acp" +fi + +# ---- 5. buzz CLI ------------------------------------------------------------ +# Reported, never installed — and never mandatory. Deploy pushes it when the +# desktop supplies one, and without it the deploy only warns: agents cannot +# reply with `buzz messages send` and degrade to slower replies. +if command -v buzz >/dev/null 2>&1; then + add_row "buzz" "OK" "$(command -v buzz)" +else + add_row "buzz" "MISSING" \ + "deploy will push it, or copy the release binary to ${LOCAL_BIN}/buzz" +fi + +# ---- 6. Harness CLIs -------------------------------------------------------- +# `discover_harnesses` probes the ACP ADAPTER name, not the vendor CLI, and the +# adapter is what the deploy pins — but the adapter is a shim over the vendor +# CLI, which carries the authentication. Both must be present, so both are +# reported. Neither is installed here: each has its own installer and its own +# interactive login, which cannot run over a non-interactive SSH deploy. +check_harness() { # label, adapter command, vendor cli, install hint + _adapter="$(command -v "$2" 2>/dev/null || true)" + _cli="$(command -v "$3" 2>/dev/null || true)" + if [ -n "${_adapter}" ] && [ -n "${_cli}" ]; then + add_row "harness: $1" "OK" "${_adapter}" + elif [ -n "${_cli}" ]; then + add_row "harness: $1" "NOTE" "$3 present, ACP adapter $2 missing — $4" + elif [ -n "${_adapter}" ]; then + add_row "harness: $1" "NOTE" "$2 present, $3 CLI missing — install and log in" + else + add_row "harness: $1" "MISSING" "$4" + fi +} + +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" + +# ---- 7. Tailscale (optional) ------------------------------------------------ +# An enhancement, never a dependency: absence only costs the desktop's device +# picker, and manual SSH is the unchanged fallback. So every branch here is a +# note. +if command -v tailscale >/dev/null 2>&1; then + # jq-free on purpose: this script must run on a bare host, and jq is not a + # prerequisite of anything else in the contract. BackendState is top-level and + # appears once, so the first match is the right one. + TS_STATE="$(tailscale status --json 2>/dev/null | + sed -n 's/.*"BackendState"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | + head -n 1)" + [ -n "${TS_STATE}" ] || TS_STATE="unknown" + + if [ "${TS_STATE}" = "Running" ]; then + # SSH-server state comes from prefs, not from Self.sshHostKeys in the status + # document: that field is populated for PEERS (it is how the desktop marks a + # device "· Tailscale SSH"), and reads as null when a node looks at itself — + # so trusting it here reports a false negative on an SSH-enabled host. + if tailscale debug prefs 2>/dev/null | + grep -q '"RunSSH"[[:space:]]*:[[:space:]]*true'; then + add_row "tailscale" "OK" "Running, Tailscale SSH enabled" + else + add_row "tailscale" "NOTE" \ + "Running, no SSH server — run: tailscale set --ssh (or tailscale up --ssh)" + fi + else + add_row "tailscale" "NOTE" \ + "BackendState=${TS_STATE} — run: tailscale up (optional)" + fi +else + add_row "tailscale" "NOTE" "not installed (optional; use plain SSH keys)" +fi + +# ---- 8. systemd --user ------------------------------------------------------ +# The deploy's own workaround, mirrored: a non-interactive SSH command often +# gets no XDG_RUNTIME_DIR, and without it every `systemctl --user` fails with +# "Failed to connect to bus". Checking under the same assumption is the point — +# a check that only passes in a login shell would pass on hosts the deploy then +# fails on. +if [ -z "${XDG_RUNTIME_DIR:-}" ]; then + XDG_RUNTIME_DIR="/run/user/$(id -u)" + export XDG_RUNTIME_DIR +fi + +if systemctl --user show-environment >/dev/null 2>&1; then + add_row "systemd --user" "OK" "bus reachable at ${XDG_RUNTIME_DIR}" mandatory +else + add_row "systemd --user" "MISSING" \ + "no user bus at ${XDG_RUNTIME_DIR} — enable lingering, then re-run" mandatory +fi + +# ---- Summary ---------------------------------------------------------------- + +# `hostname` is not in every minimal image's base install, so it never decides +# an exit code — only how the header reads. +HOST_LABEL="$(hostname 2>/dev/null || echo "this host")" +printf '\n%s\n' "Buzz remote-agent host preflight — ${USER_NAME}@${HOST_LABEL}" +printf '\n %-22s %-8s %s\n' "REQUIREMENT" "STATUS" "ACTION / DETAIL" +printf ' %-22s %-8s %s\n' "----------------------" "--------" "---------------" +printf '%s' "${ROWS}" | while IFS='|' read -r req status action; do + [ -n "${req}" ] || continue + printf ' %-22s %-8s %s\n' "${req}" "${status}" "${action}" +done +printf '\n' + +if [ "${BLOCKERS}" -eq 0 ]; then + note "Mandatory checks green. Deploy a remote agent from the Buzz desktop app." + note "Reminder: buzz-acp, the buzz CLI and the harness CLIs are installed separately." + exit 0 +fi + +note "${BLOCKERS} mandatory check(s) not satisfied — see ACTION above." +exit 1 From 00ae776c3d798df91494287d6c3e8b2af861a40c Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Wed, 29 Jul 2026 14:04:58 +0200 Subject: [PATCH 03/13] fix(backend-ssh): key remote units by agent identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The systemd instance, env-file path and returned `backend_agent_id` were all derived from the agent's display name. A name is not unique: two agents called the same thing on one SSH account collapsed onto one unit and one env file, so the second deploy overwrote the first agent's minted nsec and starting either record drove whichever identity was written last. Key them on the agent's pubkey instead — the record's own primary key, and the only stable identifier the payload can carry. The slug keeps the name as its readable stem and appends a 12-hex fragment of the key, so `systemctl --user status` still reads as the agent a human recognizes. The payload field is required, not optional: falling back to the name on an absent `pubkey` would reintroduce the collision silently, so deploy refuses a payload without a 64-character hex key. Signed-off-by: Troy Hoffman --- crates/buzz-backend-ssh/src/deploy.rs | 121 ++++++++++++++---- .../src-tauri/src/commands/agents_deploy.rs | 7 + 2 files changed, 104 insertions(+), 24 deletions(-) diff --git a/crates/buzz-backend-ssh/src/deploy.rs b/crates/buzz-backend-ssh/src/deploy.rs index 39508829bcf..a497c8a5713 100644 --- a/crates/buzz-backend-ssh/src/deploy.rs +++ b/crates/buzz-backend-ssh/src/deploy.rs @@ -48,6 +48,9 @@ const RESERVED_ENV_KEYS: &[&str] = &[ /// `Debug` would put provider credentials one `{:?}` away from a log line. pub struct Agent { pub name: String, + /// The agent's minted Nostr pubkey — the desktop record's own primary key, + /// and the only stable identifier in the payload. See [`Agent::slug`]. + pub pubkey: String, pub relay_url: String, pub private_key_nsec: Secret, pub auth_tag: Option, @@ -120,8 +123,31 @@ impl Agent { reached the host (see instanceInputForDefinition provider branch)", )?; + // The one stable identifier in the payload, and what every host-side + // name is keyed on. Refused rather than defaulted for the same reason + // the minted key is: without it two agents that merely share a display + // name would share one unit, one env file and one `backend_agent_id`, + // and the second deploy would overwrite the first agent's identity. + // + // Validated to the shape the desktop always mints (`to_hex()` of a + // Nostr public key) because the fragment taken from it becomes a + // filename and a systemd instance name. Anything else is a payload bug, + // and sanitizing it would trade a loud failure for a silent collision. + let pubkey = string("pubkey").ok_or( + "deploy payload carries no 'pubkey': the remote unit would be keyed on the agent's \ + display name, which two agents can share", + )?; + if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "'pubkey' is not a 64-character hex Nostr public key: {} characters", + pubkey.len() + )); + } + let pubkey = pubkey.to_lowercase(); + Ok(Self { name: string("name").ok_or("'name' is required")?, + pubkey, relay_url: string("relay_url").ok_or("'relay_url' is required")?, private_key_nsec, auth_tag: string("auth_tag"), @@ -166,10 +192,17 @@ impl Agent { /// `record.backend_agent_id`. /// /// It becomes both a filename and a unit instance name, so it follows the - /// desktop's own `util::slugify` rule. The hash suffix is not decoration: - /// the payload carries no stable agent identifier, and without it two - /// agents whose names differ only in punctuation would share one unit and - /// one env file. + /// desktop's own `util::slugify` rule. The name is the readable half; the + /// **pubkey fragment is the identity**, and it is what makes the name safe + /// to read: two agents may legitimately be called "Research Bot" on one SSH + /// account, and keying on the name alone gave them one unit, one env file + /// and one `backend_agent_id` — so the second deploy silently overwrote the + /// first agent's minted nsec, and starting either record then drove + /// whichever identity was written last. + /// + /// [`PUBKEY_FRAGMENT`] characters of a 256-bit key are far more than a + /// per-host unit namespace needs to stay collision-free, and short enough + /// to leave the name legible in `systemctl --user status`. pub fn slug(&self) -> String { let sanitized: String = self .name @@ -182,7 +215,8 @@ impl Agent { let stem = &stem[..stem.len().min(32)]; let stem = stem.trim_end_matches('-'); let stem = if stem.is_empty() { "agent" } else { stem }; - format!("{stem}-{}", short_hash(&self.name)) + // Hex and lowercased by `from_request`, so this is already unit-safe. + format!("{stem}-{}", &self.pubkey[..PUBKEY_FRAGMENT]) } pub fn agent_id(&self) -> String { @@ -190,15 +224,8 @@ impl Agent { } } -/// FNV-1a, truncated. Only ever used to keep distinct names on distinct units. -fn short_hash(value: &str) -> String { - let mut hash: u64 = 0xcbf2_9ce4_8422_2325; - for byte in value.as_bytes() { - hash ^= u64::from(*byte); - hash = hash.wrapping_mul(0x0000_0100_0000_01b3); - } - format!("{:08x}", hash as u32) -} +/// How much of the agent's pubkey identifies its unit. See [`Agent::slug`]. +const PUBKEY_FRAGMENT: usize = 12; pub fn env_map(value: Option<&serde_json::Value>) -> BTreeMap { value @@ -663,6 +690,10 @@ mod tests { use super::*; const NSEC: &str = "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"; + /// A 64-hex Nostr pubkey, in the shape `record.pubkey` always carries. + const PUBKEY: &str = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + /// The fragment of [`PUBKEY`] every host-side name is keyed on. + const PUBKEY_SLUG: &str = "3bf0c63fcb93"; fn request() -> serde_json::Value { serde_json::json!({ @@ -670,6 +701,7 @@ mod tests { "provider_config": { "ssh_host": "vps", "ssh_user": "ubuntu" }, "agent": { "name": "Research Bot", + "pubkey": PUBKEY, "relay_url": "wss://relay.example/ws", "private_key_nsec": NSEC, "auth_tag": "tag-abc", @@ -1018,24 +1050,65 @@ mod tests { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'), "{slug}" ); - assert!(slug.starts_with("research-bot-")); - // Redeploy is the start path: the same name must yield the same unit + assert_eq!(slug, format!("research-bot-{PUBKEY_SLUG}")); + // Redeploy is the start path: the same agent must yield the same unit // and the same agent_id, or start would provision a duplicate. assert_eq!(slug, Agent::from_request(&request()).unwrap().slug()); assert_eq!(agent.agent_id(), format!("buzz-acp@{slug}")); } + /// The collision this key exists to prevent: one SSH account, two agents a + /// user called the same thing. Keyed on the name they shared a unit, an env + /// file and an `agent_id`, so the second deploy overwrote the first's nsec. #[test] - fn names_that_sanitize_alike_still_get_distinct_units() { - let named = |name: &str| { + fn two_agents_with_one_name_get_distinct_units() { + let deployed = |pubkey: &str| { let mut request = request(); - request["agent"]["name"] = serde_json::json!(name); - Agent::from_request(&request).unwrap().slug() + request["agent"]["pubkey"] = serde_json::json!(pubkey); + let agent = Agent::from_request(&request).unwrap(); + (agent.slug(), agent.agent_id()) }; - assert_ne!(named("Bot!"), named("Bot?")); - // A name with nothing usable still produces a legal instance name. - let empty = named("!!!"); - assert!(empty.starts_with("agent-"), "{empty}"); + let other = "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411"; + let (first_slug, first_id) = deployed(PUBKEY); + let (second_slug, second_id) = deployed(other); + assert_ne!(first_slug, second_slug); + assert_ne!(first_id, second_id); + // Both still name the agent a human recognizes. + assert!(first_slug.starts_with("research-bot-"), "{first_slug}"); + assert!(second_slug.starts_with("research-bot-"), "{second_slug}"); + } + + #[test] + fn a_name_with_nothing_usable_still_produces_a_legal_instance_name() { + let mut request = request(); + request["agent"]["name"] = serde_json::json!("!!!"); + let slug = Agent::from_request(&request).unwrap().slug(); + assert_eq!(slug, format!("agent-{PUBKEY_SLUG}")); + } + + /// The identity is refused rather than defaulted: a payload without it + /// would silently fall back to name-keyed units, which is the collision. + #[test] + fn deploy_refuses_a_payload_with_no_usable_agent_identity() { + let mut request = request(); + request["agent"].as_object_mut().unwrap().remove("pubkey"); + assert!(rejection(&request).contains("no 'pubkey'")); + + for bad in ["", "abc123", &"z".repeat(64)] { + request["agent"]["pubkey"] = serde_json::json!(bad); + let error = rejection(&request); + assert!( + error.contains("pubkey"), + "accepted {bad:?} with error {error}" + ); + } + + // Case is normalized, so the same key never yields two units. + request["agent"]["pubkey"] = serde_json::json!(PUBKEY.to_uppercase()); + assert_eq!( + Agent::from_request(&request).unwrap().slug(), + Agent::from_request(&self::request()).unwrap().slug() + ); } #[test] diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 47ee5f92d49..5429b0f0d84 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -195,6 +195,13 @@ pub(super) fn deploy_payload_json( crate::managed_agents::projected_access_with_policy(record, projections.owner_only_access); serde_json::json!({ "name": &record.name, + // The record's own primary key, and the only stable identifier a + // provider can key host-side names on. The SSH provider derives its + // systemd instance, env-file path and returned `backend_agent_id` from + // a fragment of it: two agents can legitimately share a display name on + // one host, and a name-keyed unit gave the second deploy the first + // agent's env file — overwriting its minted nsec. + "pubkey": &record.pubkey, "relay_url": relay_url, "private_key_nsec": &record.private_key_nsec, "auth_tag": &record.auth_tag, From d46e181e8849645189b9e7729deb6a0cb59612dd Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Wed, 29 Jul 2026 14:13:27 +0200 Subject: [PATCH 04/13] fix(backend-ssh): quote the resolved harness path in ExecStart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExecStart= splits an unquoted value on whitespace, so a "buzz-acp path on the server" naming a directory with a space in it — which the config schema accepts — made systemd run the first word with the rest as arguments, and the unit never started. The SSH resolution code already shell-quotes such a path; systemd's command-line syntax is a different question. Write the substituted path double-quoted, escaping backslash and double quote first since systemd unquotes C-style escapes inside double quotes. The escape pass filters through sed rather than editing in place, so the portability argument that kept sed -i out still holds. Signed-off-by: Troy Hoffman --- .../buzz-backend-ssh/assets/buzz-acp@.service | 8 +- crates/buzz-backend-ssh/src/deploy.rs | 75 ++++++++++++++++--- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/crates/buzz-backend-ssh/assets/buzz-acp@.service b/crates/buzz-backend-ssh/assets/buzz-acp@.service index fb1f7414b83..ff67be35ce6 100644 --- a/crates/buzz-backend-ssh/assets/buzz-acp@.service +++ b/crates/buzz-backend-ssh/assets/buzz-acp@.service @@ -13,9 +13,11 @@ Type=simple # chmod 600; systemd reads it as the owning user. EnvironmentFile=%h/.config/buzz-acp/%i.env # Absolute path, substituted at install time from the host's resolved -# `buzz-acp`. systemd does not expand environment variables in the program -# position, and the shell indirection that would work around that is not worth -# adding to a unit whose environment carries a private key. +# `buzz-acp` and double-quoted there — systemd splits an unquoted value on +# whitespace, and the configurable `buzz-acp path on the server` may name a +# directory that contains some. systemd does not expand environment variables +# in the program position, and the shell indirection that would work around +# that is not worth adding to a unit whose environment carries a private key. ExecStart=@BUZZ_ACP_BIN@ Restart=always RestartSec=5 diff --git a/crates/buzz-backend-ssh/src/deploy.rs b/crates/buzz-backend-ssh/src/deploy.rs index a497c8a5713..d4d197de006 100644 --- a/crates/buzz-backend-ssh/src/deploy.rs +++ b/crates/buzz-backend-ssh/src/deploy.rs @@ -580,14 +580,31 @@ fi // silently runs the old unit. // // `@BUZZ_ACP_BIN@` is substituted with parameter expansion rather than - // `sed`: `sed -i` is a GNU extension BSD and macOS hosts reject, and any - // `s///` would need a delimiter no resolved path can contain. + // `sed -i`, a GNU extension BSD and macOS hosts reject, and any in-place + // `s///` over the template would need a delimiter no resolved path can + // contain. + // + // The substituted value is quoted per systemd's own command-line syntax, + // which is not the shell's. `ExecStart=` splits an unquoted value on + // whitespace, so a `buzz-acp path on the server` pointing at + // `/opt/buzz tools/buzz-acp` would make systemd run `/opt/buzz` with + // `tools/buzz-acp` as an argument — the schema accepts such a path, and + // `quote()` already makes it safe as a *shell* argument, which is a + // different question. Inside double quotes systemd unquotes C-style + // escapes, so `\` and `"` are escaped first; a filtering `sed` is portable + // even where `sed -i` is not. + // + // The quotes are literals in `printf`'s single-quoted format rather than + // shell-escaped inside the value, so the three interpolations stay plain + // `%s` arguments — the unit template's own `%i`/`%h` specifiers ride + // through untouched for the same reason. script.push_str(&format!( r#"unit_file="$units/buzz-acp@.service" +acp_unit=$(printf '%s' "$acp" | sed 's/[\\"]/\\&/g') template=$(cat <<'BUZZ_UNIT_EOF' {unit}BUZZ_UNIT_EOF ) -printf '%s\n' "${{template%%@BUZZ_ACP_BIN@*}}$acp${{template#*@BUZZ_ACP_BIN@}}" > "$unit_file.new" +printf '%s"%s"%s\n' "${{template%%@BUZZ_ACP_BIN@*}}" "$acp_unit" "${{template#*@BUZZ_ACP_BIN@}}" > "$unit_file.new" if cmp -s "$unit_file.new" "$unit_file"; then rm -f "$unit_file.new" else @@ -1134,10 +1151,12 @@ mod tests { let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); assert!(UNIT_TEMPLATE.contains("ExecStart=@BUZZ_ACP_BIN@")); assert!(UNIT_TEMPLATE.contains("EnvironmentFile=%h/.config/buzz-acp/%i.env")); - // Substitution is parameter expansion, not `sed`: `sed -i` is a GNU - // extension that BSD and macOS hosts reject. - assert!(!script.contains("sed ")); - assert!(script.contains("${template%%@BUZZ_ACP_BIN@*}$acp${template#*@BUZZ_ACP_BIN@}")); + // Substitution is parameter expansion, not `sed -i`: in-place editing + // is a GNU extension that BSD and macOS hosts reject. + assert!(!script.contains("sed -i")); + assert!(script.contains( + r#"printf '%s"%s"%s\n' "${template%%@BUZZ_ACP_BIN@*}" "$acp_unit" "${template#*@BUZZ_ACP_BIN@}""# + )); // Lingering, or the agent dies when this SSH session ends. It also // creates /run/user/$(id -u), so it must precede any bus traffic. let linger = script.find("loginctl enable-linger").unwrap(); @@ -1301,7 +1320,7 @@ mod tests { let unit = std::fs::read_to_string(root.join(".config/systemd/user/buzz-acp@.service")).unwrap(); assert!(unit.contains(&format!( - "ExecStart={}", + "ExecStart=\"{}\"", root.join("bin/buzz-acp").display() ))); assert!(!unit.contains("@BUZZ_ACP_BIN@")); @@ -1517,10 +1536,11 @@ if [ -z "${{XDG_RUNTIME_DIR:-}}" ]; then export XDG_RUNTIME_DIR fi unit_file="$units/buzz-acp@.service" +acp_unit=$(printf '%s' "$acp" | sed 's/[\\"]/\\&/g') template=$(cat <<'BUZZ_UNIT_EOF' {unit}BUZZ_UNIT_EOF ) -printf '%s\n' "${{template%%@BUZZ_ACP_BIN@*}}$acp${{template#*@BUZZ_ACP_BIN@}}" > "$unit_file.new" +printf '%s"%s"%s\n' "${{template%%@BUZZ_ACP_BIN@*}}" "$acp_unit" "${{template#*@BUZZ_ACP_BIN@}}" > "$unit_file.new" if cmp -s "$unit_file.new" "$unit_file"; then rm -f "$unit_file.new" else @@ -1600,7 +1620,7 @@ systemctl --user restart 'buzz-acp@{slug}.service' let unit = std::fs::read_to_string(root.join(".config/systemd/user/buzz-acp@.service")).unwrap(); assert!( - unit.contains(&format!("ExecStart={}", installed.display())), + unit.contains(&format!("ExecStart=\"{}\"", installed.display())), "{unit}" ); } @@ -1679,7 +1699,7 @@ systemctl --user restart 'buzz-acp@{slug}.service' let unit = std::fs::read_to_string(root.join(".config/systemd/user/buzz-acp@.service")).unwrap(); assert!(unit.contains(&format!( - "ExecStart={}", + "ExecStart=\"{}\"", root.join("bin/buzz-acp").display() ))); } @@ -1744,7 +1764,38 @@ systemctl --user restart 'buzz-acp@{slug}.service' let unit = std::fs::read_to_string(root.join(".config/systemd/user/buzz-acp@.service")).unwrap(); assert!( - unit.contains(&format!("ExecStart={}", installed.display())), + unit.contains(&format!("ExecStart=\"{}\"", installed.display())), + "{unit}" + ); + } + + /// `ExecStart=` splits an unquoted value on whitespace, so a configured + /// `buzz-acp path on the server` inside a directory with a space in it + /// would make systemd run the first word and pass the rest as arguments. + /// The schema accepts such a path and `quote()` makes it a safe *shell* + /// argument, which is a different question from what systemd parses. + #[cfg(unix)] + #[test] + fn a_resolved_path_containing_whitespace_stays_one_word_in_exec_start() { + let root = sandbox_host("spaced-acp", HostAcp::Missing); + let acp = seed_stub(&root.join("buzz tools"), "buzz-acp", "#!/bin/sh\nexit 0\n"); + let config = SshConfig { + buzz_acp_path: Some(acp.display().to_string()), + ..config() + }; + 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 failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let unit = + std::fs::read_to_string(root.join(".config/systemd/user/buzz-acp@.service")).unwrap(); + assert!( + unit.contains(&format!("ExecStart=\"{}\"\n", acp.display())), "{unit}" ); } From e2e3d731f78dafccbe6764b09fef3af4d43e1fd7 Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Wed, 29 Jul 2026 14:14:24 +0200 Subject: [PATCH 05/13] feat(backend-ssh): run remote units with NoNewPrivileges A remote agent runs arbitrary code by design, so the SSH user's own privileges are the intended ceiling. Without NoNewPrivileges the harness can climb past them through any setuid/setgid binary on the host, or through passwordless sudo granted to that user. A VPS pilot exercised the harness, the Buzz CLI, NIP-OA owner-reviewed draft creation and repository branch pushes with it set; all remained functional. Deliberately the whole hardening delta. ProtectSystem/ProtectHome belong in this unit too, but an agent has no modeled workspace yet, so until writable paths are something the protocol states those directives would be guessing at which of the user's home an agent legitimately needs. Signed-off-by: Troy Hoffman --- crates/buzz-backend-ssh/assets/buzz-acp@.service | 13 +++++++++++++ crates/buzz-backend-ssh/src/deploy.rs | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/crates/buzz-backend-ssh/assets/buzz-acp@.service b/crates/buzz-backend-ssh/assets/buzz-acp@.service index ff67be35ce6..bdd56c224cc 100644 --- a/crates/buzz-backend-ssh/assets/buzz-acp@.service +++ b/crates/buzz-backend-ssh/assets/buzz-acp@.service @@ -9,6 +9,19 @@ StartLimitIntervalSec=0 [Service] Type=simple +# The agent runs arbitrary code by design, so the SSH user's own privileges are +# the intended ceiling — but without this the harness can climb past them +# through any setuid/setgid binary on the host, or through passwordless sudo +# granted to that user. A VPS pilot exercised the harness, the Buzz CLI, +# NIP-OA owner-reviewed draft creation and repository branch pushes with this +# set; all remained functional. +# +# Deliberately the whole hardening delta. ProtectSystem/ProtectHome would also +# apply here, but the agent has no modeled workspace yet (see the REPOS / +# working-directory limitation in docs/remote-agents.md) — so until writable +# paths are something the protocol states, those directives would be guessing +# at which of the user's home an agent legitimately needs. +NoNewPrivileges=true # Holds the agent's minted nsec. Written by the provider with umask 077 and # chmod 600; systemd reads it as the owning user. EnvironmentFile=%h/.config/buzz-acp/%i.env diff --git a/crates/buzz-backend-ssh/src/deploy.rs b/crates/buzz-backend-ssh/src/deploy.rs index d4d197de006..6c25348e8d2 100644 --- a/crates/buzz-backend-ssh/src/deploy.rs +++ b/crates/buzz-backend-ssh/src/deploy.rs @@ -1151,6 +1151,12 @@ mod tests { let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); assert!(UNIT_TEMPLATE.contains("ExecStart=@BUZZ_ACP_BIN@")); assert!(UNIT_TEMPLATE.contains("EnvironmentFile=%h/.config/buzz-acp/%i.env")); + // The SSH user's own privileges are the intended ceiling for an agent + // that runs arbitrary code. Without this it can climb past them through + // a setuid binary or passwordless sudo granted to that user, and the + // unit — not the deploy script — is the only place that can say so. + assert!(UNIT_TEMPLATE.contains("\nNoNewPrivileges=true\n")); + assert!(script.contains("NoNewPrivileges=true")); // Substitution is parameter expansion, not `sed -i`: in-place editing // is a GNU extension that BSD and macOS hosts reject. assert!(!script.contains("sed -i")); From 2815acb400282e6fdcecda8b1b461bcd82822bff Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Wed, 29 Jul 2026 14:23:09 +0200 Subject: [PATCH 06/13] docs(remote-agents): report the Git helper and the missing workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git-credential-nostr had no preflight row of its own. It is a third tool with a third policy: deploy resolves it, never installs it, and writes the agent's GIT_CONFIG_* block only when the host already has it — so a remote agent on a host without it cannot push to a Buzz repository, with no deploy-time warning and no error until the agent tries. Folding that into the non-fatal buzz CLI check hid a different failure behind a different tool's status. Give it its own row, still reported and still never mandatory. Record the workspace gap as a known limitation while it is one. A local managed agent runs inside Desktop's REPOS workspace; the unit here has no WorkingDirectory at all, so a remote agent has no Buzz-native project checkout and in practice operates in a separately provisioned one. Closing it is a protocol addition — a workspace field the desktop states, rather than the provider guessing which project to clone. Signed-off-by: Troy Hoffman --- scripts/provision-buzz-host.sh | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/scripts/provision-buzz-host.sh b/scripts/provision-buzz-host.sh index 0b83b0ec0b1..4d4c216003a 100755 --- a/scripts/provision-buzz-host.sh +++ b/scripts/provision-buzz-host.sh @@ -137,7 +137,24 @@ else "deploy will push it, or copy the release binary to ${LOCAL_BIN}/buzz" fi -# ---- 6. Harness CLIs -------------------------------------------------------- +# ---- 6. git-credential-nostr ------------------------------------------------ +# Its own row, not folded into the `buzz` CLI check above: the two tools answer +# different questions and fail differently. Deploy pushes the CLI when the +# desktop supplies one; nothing pushes this helper. Deploy resolves it, and +# writes the agent's Git credential block ONLY if the host already has it — so +# without it a remote agent silently cannot push to a Buzz repository, with no +# warning at deploy time and no error until the agent tries. +# +# Reported, never installed, and never mandatory: an agent that never touches +# Git is unaffected, and blocking a whole preflight on it would be wrong. +if command -v git-credential-nostr >/dev/null 2>&1; then + add_row "git-credential-nostr" "OK" "$(command -v git-credential-nostr)" +else + add_row "git-credential-nostr" "MISSING" \ + "no Buzz Git auth for agents here — copy the release binary to ${LOCAL_BIN}/git-credential-nostr" +fi + +# ---- 7. Harness CLIs -------------------------------------------------------- # `discover_harnesses` probes the ACP ADAPTER name, not the vendor CLI, and the # adapter is what the deploy pins — but the adapter is a shim over the vendor # CLI, which carries the authentication. Both must be present, so both are @@ -167,7 +184,7 @@ check_harness "claude" "${CLAUDE_ACP_ADAPTER}" "claude" \ check_harness "codex" "codex-acp" "codex" \ "npm i -g @agentclientprotocol/codex-acp; curl -fsSL https://chatgpt.com/codex/install.sh | sh" -# ---- 7. Tailscale (optional) ------------------------------------------------ +# ---- 8. Tailscale (optional) ------------------------------------------------ # An enhancement, never a dependency: absence only costs the desktop's device # picker, and manual SSH is the unchanged fallback. So every branch here is a # note. @@ -200,7 +217,7 @@ else add_row "tailscale" "NOTE" "not installed (optional; use plain SSH keys)" fi -# ---- 8. systemd --user ------------------------------------------------------ +# ---- 9. systemd --user ------------------------------------------------------ # The deploy's own workaround, mirrored: a non-interactive SSH command often # gets no XDG_RUNTIME_DIR, and without it every `systemctl --user` fails with # "Failed to connect to bus". Checking under the same assumption is the point — From db227057012494b004ccff27c48e8e21c4c044e0 Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Wed, 29 Jul 2026 17:27:07 +0200 Subject: [PATCH 07/13] fix(backend-ssh): resolve harnesses from ~/.local/bin over non-interactive SSH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-interactive `ssh host sh -s` reads no profile. On stock Debian that leaves PATH at `/usr/local/bin:/usr/bin:/bin:/usr/games`, which does not contain `~/.local/bin` — where every Buzz tool installs, and where `pipx` and `npm --prefix=~/.local` put the harness adapters. Every lookup that went through a bare `command -v` therefore answered for the wrong PATH. On a host with all twelve adapters installed, `discover` reported the entire catalog as `available: false`, and the deploy that followed refused the operator's pin with exit 91, "harness not found". The env file already pinned `PATH="$HOME/.local/bin:$PATH"` for the unit, so the contract was only half-applied: the spawned harness could see the install destination, but the scripts that resolved the harness could not. Prepend it in the scripts too, via one shared `install::PATH_PREAMBLE`, so both sides ask the same question. That also simplifies the env file's own PATH line. It now writes the deploy shell's `$PATH` verbatim, which already leads with the install destination — one prepend, in one place, instead of a second one that could drift from the first. `resolve` is unchanged: it tests `-x ~/.local/bin/` explicitly because it must also answer for an absolute configured path. Covered by two tests that run the generated scripts against a real `/bin/sh` with the stock Debian PATH and an adapter installed only to `~/.local/bin` — the reported case, in both the discover and the deploy direction. Both fail without the preamble. The provisioning preflight gets the same prepend for the same reason, so it stops reporting a tool MISSING on a host where the deploy finds it. Signed-off-by: Troy Hoffman --- crates/buzz-backend-ssh/src/deploy.rs | 79 ++++++++++++++---- crates/buzz-backend-ssh/src/discover.rs | 105 ++++++++++++++++++++++-- crates/buzz-backend-ssh/src/install.rs | 51 +++++++++--- scripts/provision-buzz-host.sh | 23 +++++- 4 files changed, 222 insertions(+), 36 deletions(-) diff --git a/crates/buzz-backend-ssh/src/deploy.rs b/crates/buzz-backend-ssh/src/deploy.rs index 6c25348e8d2..20690256cf4 100644 --- a/crates/buzz-backend-ssh/src/deploy.rs +++ b/crates/buzz-backend-ssh/src/deploy.rs @@ -457,7 +457,14 @@ fn deploy_script( let resolve_cli = install::resolve_or_install(install::CLI, "e(install::CLI.name), push.cli.as_ref()); + // `install::PATH_PREAMBLE` before anything resolves: the harness pin below + // is looked up by name with `command -v`, and the adapters the operator + // installed to `~/.local/bin` are not on a non-interactive SSH `PATH`. It + // is also what makes the right half of the env file's own `PATH` line — the + // deploy shell's `$PATH`, captured further down — carry the install + // destination, so the two agree by construction. let mut script = String::from("set -eu\numask 077\n"); + script.push_str(install::PATH_PREAMBLE); // The harness name is bound once and thereafter referenced only as // `"$harness_name"`. Interpolating it into the double-quoted error message // would be a command-injection hole: `quote()` makes a value inert as an @@ -508,15 +515,15 @@ tmp="$env_file.new" // It is composed HERE, by the host's shell, and not written into the unit // as `Environment=PATH=$HOME/.local/bin:$PATH`: systemd expands no variable // in `Environment=` or an `EnvironmentFile`, so that form would hand the - // harness the five literal characters `$PATH`. `$PATH` on the right is the - // non-interactive SSH shell's, captured at deploy time, which is the same - // `PATH` `install::resolve` just searched — so anything `command -v` found - // stays findable for the agent. + // harness the five literal characters `$PATH`. // - // `install::resolve` can only land on this `PATH` or on `~/.local/bin`, so - // these two segments cover every copy either tool can resolve to; there is - // nothing further to add from `$acp` or `$cli`. - script.push_str("printf 'PATH=\"%s\"\\n' \"$HOME/.local/bin:$PATH\"\n"); + // `$PATH` is this script's own, and it already leads with the install + // destination because `install::PATH_PREAMBLE` put it there before anything + // resolved. So the value written here is exactly the `PATH` every + // `command -v` above searched, with the install destination in the same + // position — one decision, made once, rather than a second prepend that + // would only duplicate an entry and could drift from the first. + script.push_str("printf 'PATH=\"%s\"\\n' \"$PATH\"\n"); script.push_str("cat <<'BUZZ_ENV_EOF'\n"); script.push_str(&env_file_body(agent)?); script.push_str("BUZZ_ENV_EOF\n"); @@ -1489,6 +1496,7 @@ mod tests { let expected = format!( r#"set -eu umask 077 +export PATH="${{HOME:-}}/.local/bin${{PATH:+:$PATH}}" harness_name='goose' 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 @@ -1516,7 +1524,7 @@ env_file="$conf/{slug}.env" tmp="$env_file.new" {{ printf 'BUZZ_ACP_AGENT_COMMAND="%s"\n' "$harness" -printf 'PATH="%s"\n' "$HOME/.local/bin:$PATH" +printf 'PATH="%s"\n' "$PATH" cat <<'BUZZ_ENV_EOF' {env}BUZZ_ENV_EOF if [ -n "$claude_cli" ]; then @@ -1714,11 +1722,12 @@ systemctl --user restart 'buzz-acp@{slug}.service' #[test] fn a_second_deploy_keeps_the_binary_the_first_one_installed() { // The install destination — `~/.local/bin` — is NOT on a - // non-interactive SSH PATH, which is exactly why the env file below - // pins `PATH="$HOME/.local/bin:$PATH"` itself. Resolution has to say so - // too: with a bare `command -v`, the probe answered "missing" forever - // and every deploy re-streamed and replaced the binary. Deploy is the - // start path, so that is every agent start, underneath a running fleet. + // non-interactive SSH PATH, which is exactly why every script prepends + // it. `install::probe_script` is the one round trip that carries no + // preamble, so its `-x` test has to say so too: with a bare + // `command -v`, the probe answered "missing" forever and every deploy + // re-streamed and replaced the binary. Deploy is the start path, so + // that is every agent start, underneath a running fleet. // // `an_existing_host_binary_is_never_replaced_by_the_pushed_one` cannot // see this: it seeds the stub into the sandbox's `bin`, which IS on the @@ -2011,6 +2020,48 @@ systemctl --user restart 'buzz-acp@{slug}.service' // Not the five literal characters systemd would hand through verbatim. assert!(!written.contains("$PATH")); assert!(!written.contains("$HOME")); + // One prepend, not two: the value written here is the script's own + // `PATH`, which `install::PATH_PREAMBLE` already fixed up before + // anything resolved. + assert_eq!(path_line.matches("/.local/bin").count(), 1, "{path_line}"); + } + + /// The reported failure: a harness adapter installed to `~/.local/bin` on a + /// host whose non-interactive `PATH` does not contain it. Deploy resolves + /// the pin by name with `command -v`, so before the prepend this exited 91 + /// — "harness codex-acp not found" — against a host that had it. + #[cfg(unix)] + #[test] + fn a_harness_installed_to_local_bin_satisfies_the_pin() { + let root = sandbox_host("local-bin-harness", HostAcp::Installed); + // Not in the sandbox's `bin`, which IS on its PATH — in the install + // destination, which is not, exactly as `pipx` and `npm` leave it. + seed_stub(&root.join(".local/bin"), "codex-acp", "#!/bin/sh\nexit 0\n"); + let mut request = request(); + request["agent"]["agent_command"] = serde_json::json!("codex-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(), + "deploy failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + // Resolved, and pinned as the absolute path the unit re-reads on every + // restart — not merely "the script did not exit 91". + let written = std::fs::read_to_string( + root.join(".config/buzz-acp") + .join(format!("{}.env", agent.slug())), + ) + .unwrap(); + assert!( + written.contains(&format!( + "BUZZ_ACP_AGENT_COMMAND=\"{}\"", + root.join(".local/bin/codex-acp").display() + )), + "{written}" + ); } #[cfg(unix)] diff --git a/crates/buzz-backend-ssh/src/discover.rs b/crates/buzz-backend-ssh/src/discover.rs index da9700b74b7..771e3ef6067 100644 --- a/crates/buzz-backend-ssh/src/discover.rs +++ b/crates/buzz-backend-ssh/src/discover.rs @@ -117,19 +117,28 @@ const CANDIDATES: &[Candidate] = &[ }, ]; -/// Preamble shared by every remote script. +/// The first two lines of both scripts this module generates: `set -u`, then +/// the `~/.local/bin` prepend every op needs before it resolves anything by +/// name ([`crate::install::PATH_PREAMBLE`]). +fn script_preamble() -> String { + format!("set -u\n{}", crate::install::PATH_PREAMBLE) +} + +/// The `probe` helper, shared by every candidate in the discover script. /// -/// `probe` writes one tab-separated record per resolved command, rather than -/// JSON assembled in `sh`: quoting arbitrary `--version` output into valid JSON -/// from a POSIX shell is a bug farm, and the parsing belongs on the Rust side -/// where it is testable. +/// It writes one tab-separated record per resolved command, rather than JSON +/// assembled in `sh`: quoting arbitrary `--version` output into valid JSON from +/// a POSIX shell is a bug farm, and the parsing belongs on the Rust side where +/// it is testable. /// /// Two details are load-bearing. `/dev/null 2>&1; then _t="timeout 5"; else _t=""; fi +/// +/// `set -u` and [`crate::install::PATH_PREAMBLE`] precede this rather than living in +/// it, because [`models_script`] needs those two lines and not this function. +const PROBE_PREAMBLE: &str = r#"if command -v timeout >/dev/null 2>&1; then _t="timeout 5"; else _t=""; fi probe() { _p=$(command -v "$2" 2>/dev/null) || return 0 [ -n "$_p" ] || return 0 @@ -227,7 +236,8 @@ fi /// The one probe script: `buzz-acp`, every harness candidate, and — only where /// `hermes` resolves — that host's Hermes profiles. fn discover_script(config: &SshConfig) -> String { - let mut script = String::from(PROBE_PREAMBLE); + let mut script = script_preamble(); + script.push_str(PROBE_PREAMBLE); let acp = config.buzz_acp_path.as_deref().unwrap_or("buzz-acp"); script.push_str(&format!("probe 'buzz-acp' {}\n", quote(acp))); for candidate in CANDIDATES { @@ -538,7 +548,10 @@ fn models_script(request: &serde_json::Value, config: &SshConfig) -> Result`,** never `PATH` alone. //! A non-interactive SSH command reads no profile, so the install destination -//! is not on the ambient `PATH` — which is precisely why the unit's env file -//! pins `PATH="$HOME/.local/bin:$PATH"` itself. A `command -v`-only rule would -//! therefore never find the copy a previous deploy installed, and because -//! deploy is the start path, every start would re-stream the binary and swap it -//! underneath a running fleet. [`resolve`] is that rule, and the probe asks the -//! same question so the two can never disagree. +//! is not on the ambient `PATH` — which is why every generated script prepends +//! it ([`PATH_PREAMBLE`]) and why the unit's env file pins a `PATH` that leads +//! with it. A `command -v`-only rule would therefore never find the copy a +//! previous deploy installed, and because deploy is the start path, every start +//! would re-stream the binary and swap it underneath a running fleet. +//! [`resolve`] is that rule, and the probe asks the same question so the two +//! can never disagree. The explicit `-x` test is what makes [`probe_script`] +//! correct on its own: it is a separate round trip that carries no preamble. //! //! **Staleness rule: push-when-missing only.** A host that already resolves a //! tool keeps the copy it has, whatever its version. Deploy is also the start @@ -81,6 +83,29 @@ const LINE_WIDTH: usize = 76; /// that matters. const INSTALL_DIR: &str = "$HOME/.local/bin"; +/// The first statement of every generated script, so a bare `command -v` on the +/// host searches the install destination too. +/// +/// A non-interactive `ssh host sh -s` reads no profile: on stock Debian the +/// whole `PATH` is `/usr/local/bin:/usr/bin:/bin:/usr/games`, and `~/.local/bin` +/// — where every Buzz tool installs, where `pipx` and `npm --prefix=~/.local` +/// put harness adapters, and what the unit's own env file already pins — is not +/// on it. Without this line, `discover` reports a host's whole harness catalog +/// as absent and `deploy` then refuses the pin the operator picked, on a host +/// where every one of those binaries is present and runnable. +/// +/// [`resolve`] does not depend on it: that rule tests `-x ~/.local/bin/` +/// explicitly, because it also has to answer for an absolute configured path. +/// This covers everything resolved by *name* — the harness adapters, the vendor +/// CLIs, `git-credential-nostr` — which is most of what a script looks up. +/// +/// Written to survive `set -u` and a hostile environment, because it is the +/// first line of the script and everything after it depends on the shell +/// getting past it. `${HOME:-}` for a host with no `HOME`, `${PATH:+:$PATH}` so +/// an unset `PATH` yields no empty element — an empty element means the current +/// directory, which is the one `PATH` value worth refusing to write. +pub const PATH_PREAMBLE: &str = "export PATH=\"${HOME:-}/.local/bin${PATH:+:$PATH}\"\n"; + /// The marker every non-fatal host-side complaint carries, so `deploy` can /// forward exactly those lines and nothing else from a successful run's stderr /// (`deploy::deploy`). Structural, not decorative: a successful deploy's remote @@ -166,11 +191,13 @@ pub const CLI: Tool = Tool { /// Where an install of `tool` lands, and the second half of the resolution rule /// — `~/.local/bin` is the documented convention and the destination below, but /// it is **not** on a non-interactive SSH `PATH`: the remote shell reads no -/// profile, which is exactly why the unit's env file has to pin -/// `PATH="$HOME/.local/bin:$PATH"` itself. Resolving by `PATH` alone would -/// therefore never see the copy the previous deploy installed, and since deploy -/// is the start path, every agent start would re-stream tens of megabytes and -/// swap the binary underneath a running fleet. +/// profile, which is exactly why every generated script prepends it +/// ([`PATH_PREAMBLE`]) and why the unit's env file pins a `PATH` that leads with +/// it. Testing this path explicitly is what keeps [`probe_script`] — a separate +/// round trip, with no preamble of its own — from missing the copy the previous +/// deploy installed; and since deploy is the start path, missing it would make +/// every agent start re-stream tens of megabytes and swap the binary underneath +/// a running fleet. fn install_path(tool: Tool) -> String { format!("{INSTALL_DIR}/{}", tool.name) } diff --git a/scripts/provision-buzz-host.sh b/scripts/provision-buzz-host.sh index 4d4c216003a..239543e41e1 100755 --- a/scripts/provision-buzz-host.sh +++ b/scripts/provision-buzz-host.sh @@ -5,6 +5,12 @@ # Usage: # ./scripts/provision-buzz-host.sh # run ON the host, as the agent user # +# Runs correctly over a non-interactive `ssh host /path/to/provision-buzz-host.sh` +# as well as in a login shell. A non-interactive SSH command reads no profile, so +# this script prepends ~/.local/bin to its own PATH exactly as the deploy scripts +# do — without that, every tool installed there would be reported MISSING on a +# host that has it. +# # Checks (and where it can, fixes) the host contract that `buzz-backend-ssh` # assumes: see docs/remote-agents.md, "Host prerequisites". Safe to re-run — # every action is idempotent, and a fully provisioned host is a no-op. @@ -27,6 +33,21 @@ USER_NAME="$(id -un)" HOME_DIR="${HOME:-$(cd ~ && pwd)}" LOCAL_BIN="${HOME_DIR}/.local/bin" +# The PATH this script was *given*, kept before the line below rewrites it. +# Section 3 reports on this one: whether ~/.local/bin is on the login PATH is +# the question, and a check that inspected a PATH the script itself fixed up +# would answer yes on every host. +INHERITED_PATH="${PATH:-}" + +# Everything else resolves against the same PATH the deploy does. `deploy` and +# `discover` prepend the install destination to their own scripts for this +# reason (`install::PATH_PREAMBLE`): a non-interactive `ssh host sh -s` reads no +# profile, so on stock Debian PATH is `/usr/local/bin:/usr/bin:/bin:/usr/games` +# and every adapter under ~/.local/bin is invisible. Reporting them MISSING here +# while the deploy finds them would make this preflight lie in the direction +# that costs the most: an operator chasing an install that is already there. +export PATH="${LOCAL_BIN}${PATH:+:$PATH}" + # Summary rows, one per line, `requirement|status|action`. POSIX sh has no # arrays, and a newline-delimited string prints back through one `read` loop. ROWS="" @@ -94,7 +115,7 @@ fi # resolves the same binaries the unit will. mkdir -p "${LOCAL_BIN}" -case ":${PATH}:" in +case ":${INHERITED_PATH}:" in *":${LOCAL_BIN}:"*) add_row "local bin on PATH" "OK" "${LOCAL_BIN}" mandatory ;; From fb33dbdde219584f4c467db607b8b0464be3e2b1 Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Wed, 29 Jul 2026 17:27:52 +0200 Subject: [PATCH 08/13] fix(scripts): let the provisioning PATH check pass without a login shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `~/.local/bin` row was mandatory and asked whether the directory was on the PATH of the process running the check. Over a non-interactive `ssh host /path/to/provision-buzz-host.sh` it never can be: ~/.profile is read by a login shell, so the check always landed in the NOTE arm and a fully provisioned host exited 1 against its own preflight. The strict form was not load-bearing. Nothing in the deploy path consults the login PATH — the deploy composes the unit's PATH itself, and both remote scripts prepend the install destination before they resolve anything. What is worth requiring is that the directory exists and that a login shell on the host is configured to find it, which the ~/.profile arms already guarantee. So those arms report OK and carry the difference in the ACTION column: whether a re-login is still owed. A host with neither the PATH entry nor the ~/.profile line still gets the line appended and still says so. Exit semantics are otherwise unchanged: a genuinely unsatisfied mandatory row — no lingering, no user bus — still exits 1. Signed-off-by: Troy Hoffman --- scripts/provision-buzz-host.sh | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/scripts/provision-buzz-host.sh b/scripts/provision-buzz-host.sh index 239543e41e1..80994ea7711 100755 --- a/scripts/provision-buzz-host.sh +++ b/scripts/provision-buzz-host.sh @@ -26,6 +26,8 @@ # # Exit 0 when the mandatory set is green (lingering, ~/.local/bin, systemd # --user); 1 otherwise. Everything else is reported as a note, never a failure. +# The ~/.local/bin row asks whether a login shell is configured to find the +# directory, not whether this process inherited it — see section 3. # ============================================================================= set -eu @@ -110,9 +112,19 @@ else fi # ---- 3. ~/.local/bin -------------------------------------------------------- -# Where `buzz-acp` and most harness CLIs install, and what the deploy prepends -# to the unit's PATH. It has to exist and be on the login PATH so `command -v` -# resolves the same binaries the unit will. +# Where `buzz-acp`, the `buzz` CLI and most harness adapters install. +# +# What is mandatory is that the directory exists and that a shell on this host +# is CONFIGURED to find it — not that this particular process inherited it. +# ~/.profile is read by a login shell and not by the `ssh host sh -s` the deploy +# uses, so a strict "must be on $PATH right now" rule could not pass over the +# very transport the deploy runs on: a fully provisioned host would fail its own +# preflight. It is not load-bearing either, because nothing in the deploy path +# depends on the login PATH — the deploy composes the unit's PATH itself, and +# both remote scripts prepend this directory before they resolve anything. +# +# So all three arms below are green, and the ACTION column carries the one thing +# that differs: whether a re-login is still owed. mkdir -p "${LOCAL_BIN}" case ":${INHERITED_PATH}:" in @@ -124,15 +136,15 @@ case ":${INHERITED_PATH}:" in # a file the user also edits by hand. if [ -f "${HOME_DIR}/.profile" ] && grep -q '\.local/bin' "${HOME_DIR}/.profile"; then - add_row "local bin on PATH" "NOTE" \ - "already in ~/.profile; re-login to pick it up" mandatory + add_row "local bin on PATH" "OK" \ + "in ~/.profile; a login shell picks it up" mandatory else # Single quotes deliberately: $HOME and $PATH must reach .profile as # literals, to be expanded at each login rather than frozen now. # shellcheck disable=SC2016 printf '\n# Added by provision-buzz-host.sh\nexport PATH="$HOME/.local/bin:$PATH"\n' \ >>"${HOME_DIR}/.profile" - add_row "local bin on PATH" "NOTE" \ + add_row "local bin on PATH" "OK" \ "appended to ~/.profile; re-login to pick it up" mandatory fi ;; From 56311f548cc5baf48580a7d5776d71a2c783bf3e Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Sun, 2 Aug 2026 13:09:18 +0200 Subject: [PATCH 09/13] feat(backend-ssh): declare protocol_version in the info response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/remote-agents.md §Info fixes the provider contract at protocol_version 1 and requires an integer. §Discovery makes it load-bearing rather than informational: the desktop's pre-secret negotiation gate stages the provider binary, calls info on the staged bytes, and must validate an explicit supported protocol_version before it sends a request carrying private_key_nsec. Absence is an error there, not a presumed 1 — so a provider that omits the field is not degraded, it is undeployable. Emit it as protocol::PROTOCOL_VERSION, distinct from CARGO_PKG_VERSION: the software version says nothing about wire compatibility. The test asserts the JSON type as well as the value, because a string "1" would fail the gate exactly like an absent field. Signed-off-by: Troy Hoffman --- crates/buzz-backend-ssh/src/protocol.rs | 29 + docs/remote-agents-ssh.md | 700 ++++++++++++++++++++++++ 2 files changed, 729 insertions(+) create mode 100644 docs/remote-agents-ssh.md diff --git a/crates/buzz-backend-ssh/src/protocol.rs b/crates/buzz-backend-ssh/src/protocol.rs index 4c262df4173..338966f6a13 100644 --- a/crates/buzz-backend-ssh/src/protocol.rs +++ b/crates/buzz-backend-ssh/src/protocol.rs @@ -203,6 +203,17 @@ impl SshConfig { } } +/// The provider-protocol wire-contract version this binary speaks. +/// +/// Distinct from `CARGO_PKG_VERSION`, which is the provider's *software* +/// version and says nothing about compatibility. The spec +/// (`docs/remote-agents.md` §Info) fixes this document at `1` and requires an +/// **integer** — the desktop's pre-secret negotiation gate (§Discovery) invokes +/// `info` on a staged copy of this binary and rejects an absent or unsupported +/// `protocol_version` *before* a request carrying `private_key_nsec` is sent. +/// Absence is an error, not a presumed `1`, so this field is not optional. +pub const PROTOCOL_VERSION: u32 = 1; + /// The `info` response, including the Tailscale-decorated config schema. /// /// When Tailscale is absent, logged out, or has no usable peers, `ssh_host` @@ -223,6 +234,7 @@ pub fn info_response() -> serde_json::Value { "ok": true, "name": "SSH", "version": env!("CARGO_PKG_VERSION"), + "protocol_version": PROTOCOL_VERSION, "description": "Run agents on a remote host over SSH, supervised by systemd --user.", "config_schema": { "type": "object", @@ -300,6 +312,23 @@ mod tests { assert!(desktop_rejects_key("ssh_key_path")); } + /// The desktop's pre-secret gate rejects an absent or non-integer + /// `protocol_version` before the nsec is sent (spec §Discovery), so a + /// regression here does not degrade — it makes the provider undeployable. + #[test] + fn info_declares_an_integer_protocol_version() { + let info = info_response(); + let declared = &info["protocol_version"]; + assert!( + declared.is_u64(), + "protocol_version must be an integer, got {declared}" + ); + assert_eq!(declared.as_u64(), Some(u64::from(PROTOCOL_VERSION))); + assert_eq!(PROTOCOL_VERSION, 1, "this document specifies 1"); + // The wire-contract version is not the software version. + assert_ne!(declared, &info["version"]); + } + #[test] fn info_schema_has_no_one_of_without_tailscale_devices() { // Nothing in this test environment advertises a tailnet, so the schema diff --git a/docs/remote-agents-ssh.md b/docs/remote-agents-ssh.md new file mode 100644 index 00000000000..e07bb4d09f3 --- /dev/null +++ b/docs/remote-agents-ssh.md @@ -0,0 +1,700 @@ +# Remote Agents over SSH: the SSH provider binding + +This document is the **SSH binding** of the provider contract specified in +[docs/remote-agents.md](remote-agents.md). That document is normative for the wire protocol, +the invariants (I1–I5), and the launcher/provider conformance levels; this one describes how +`buzz-backend-ssh` realizes them on a `systemd --user` substrate, and where the binding's +policy choices differ from the Kubernetes binding's. + +A **remote agent** is a managed agent whose harness runs on another host. The desktop still owns +the agent: it mints the agent's nostr key, holds the record, and renders it beside local agents. +It does not own the process. On the host, `buzz-acp` runs as a `systemd --user` unit, and the only +liveness signal the desktop has is the agent's presence on the relay — there is no status op, no +polling channel, and no open connection between deploys. + +`buzz-backend-ssh` is the provider binary that puts it there. It is not bundled with the desktop: +`discover_provider_candidates` prepends the app bundle's own directory to the provider search path, +so shipping it inside the bundle would give every install an auto-discovered SSH-deploy capability +and quietly undermine the "only use providers from trusted sources" warning the create dialog +shows. Install it to `~/.local/bin`, which is already on the discovery path. + +## Provider protocol + +The desktop enumerates PATH (plus the executable's own directory and `~/.local/bin`) for files +named `buzz-backend-`, and resolves `` against `^[a-z0-9][a-z0-9_-]*$`. A trailing +executable extension (`.exe`, `.com`, `.cmd`, `.bat`) is not part of the id — Cargo installs the +provider as `buzz-backend-ssh.exe` on Windows, and reading that filename literally derives `ssh.exe`, +which the id rule rejects for the dot. Discovery deduplicates on the id, so a host carrying both +spellings offers one provider. It spawns the binary, writes one JSON request to stdin, closes it, +and reads one JSON response from stdout. One process per op; no daemon, no state. + +There is no *negotiation* — the provider declares one wire-contract version and the desktop +either speaks it or refuses. `info` carries `"protocol_version": 1` as an **integer** +(`protocol::PROTOCOL_VERSION`), which is the version of the provider contract in +[docs/remote-agents.md](remote-agents.md), not this binary's `version` (its software version, +useful in error reports and useless for compatibility). The distinction matters because the +spec's pre-secret negotiation gate (§Discovery) stages this binary, calls `info` on the staged +bytes, and requires an explicit supported `protocol_version` **before** it sends a request +carrying `private_key_nsec`. Absence is an error there, not a presumed `1`, so dropping the field +does not degrade the provider — it makes it undeployable. + +`buzz-backend-ssh` implements five ops. + +| op | opens SSH | provider budget | desktop budget | desktop caller | +|---|---|---|---|---| +| `info` | no | — | 10s | `probe_backend_provider`: the host field, and Settings → Remote servers to name and version each row | +| `check` | yes | 8s | — | none yet | +| `discover_harnesses` | yes | 40s | 60s | `WhereToRunSection`, on "check host" | +| `probe_models` | yes | 110s | 150s | `WhereToRunSection`, after a harness resolves | +| `deploy` | yes | 300s | 600s | `deploy_to_provider`, from create and from start | + +The provider budget always fires first, so a timeout arrives as a structured error rather than as a +killed child. Any other `op` value is rejected before a connection is opened, so a typo costs a +parse and not an SSH handshake. + +Every desktop-side entry point resolves the provider through `resolve_discovered_provider` before +spawning it, so a frontend or IPC caller that names a `binaryPath` cannot steer execution at an +arbitrary binary. + +`info` is the only op that runs before a host is configured — it is what produces the host field — +so it never opens a session and never requires `provider_config`. + +```json +{"op": "info", "request_id": ""} +``` + +```json +{"ok": true, "name": "SSH", "version": "…", "protocol_version": 1, + "description": "Run agents on a remote host over SSH, supervised by systemd --user.", + "config_schema": {"type": "object", "required": ["ssh_host"], "properties": {…}}} +``` + +`check` is a preflight: `echo buzz-ok` over the configured session. Failures are classified into +actionable guidance (`Permission denied` → `authorized_keys` / `tailscale set --ssh`, +`Host key verification failed` → known_hosts, `Could not resolve hostname` → address or tailnet, +`Connection refused`/`timed out` → reachability). Anything unclassified passes through verbatim +rather than being flattened. + +`discover_harnesses` probes `buzz-acp` and every candidate harness in **one** generated `sh` +script. N sequential `ssh` invocations would spend the whole budget on handshakes over a +200 ms link and the harness picker would visibly hang. Two details in that script are load-bearing: +every probed child gets `/profiles/*/`, `HERMES_HOME` +honored and trimmed back to the root) in the same single script round trip, gated on `hermes` +resolving — `hermes profile list` is a human table with no `--json`, and the directory layout is +what Hermes itself resolves a profile against. Names are untrusted remote input on their way into an +id and an argv, so only `[a-z0-9][a-z0-9_-]*` (Hermes's own rule, and a subset of the desktop's +harness-id rule) is accepted; anything else is skipped whole rather than sanitized, and the count is +capped at 32 with the remainder logged to stderr. Absent Hermes, the catalog is byte-identical to +what it was before; present with a Hermes root but no `profiles/` store, it is the plain entry plus +`hermes-default`, since the root directory *is* the default profile; present with no Hermes root at +all, it is just the plain entry. + +Those per-profile entries carry `"exclusive": true`, the catalog's one statement about identity: the +entry names a persistent identity on the host — its own memory, sessions and credentials — rather +than an ephemeral runner. Deploying `claude` or the plain `hermes-acp` entry N times to one host is +the point; pinning two agents to *the same profile* is two puppeteers driving one body, so the +desktop refuses the second. Every other entry omits the key, and an absent key means "deploy as many +as you like" — the flag is the only hermes-aware thing here, and the desktop reads it generically +(`isExclusiveRemoteHarnessAdded`): an entry counts as already taken when an existing agent is backed +by the *same provider and provider config* and pinned to the *same command and args*, in which case +the harness picker renders it disabled with an "(added)" suffix and auto-pick skips it. Config +equality is exact (after trimming, dropping blanks and sorting keys), not host resolution, so +`10.0.0.4` and `vps.tail1234.ts.net` read as different hosts and the guard simply does not fire — +it under-matches rather than ever falsely blocking a create. Resolving aliases needs a +host-identity answer from the provider, which is the real fix rather than a normalization table in +the desktop. + +`probe_models` exports the harness env inside the script, then runs `buzz-acp models --json` on the +host and returns the document verbatim under `models_raw`. The desktop feeds it straight into the +same `normalize_agent_models` the local path uses, so the model picker needs no remote-specific +code. That host-side command carries its own budget inside the provider's 110s: `MODELS_TIMEOUT`, +60s, matched to what the normal agent-init path gives the same adapter spawn — a shorter one only +fails probes the real spawn would have survived, since a cold node adapter on a busy host takes +tens of seconds to reach `initialize`. Model env must be nested under `agent.env_vars` — that is the only place the desktop's +`env_secrets_from_request` scrubber looks. A flat `model_env` is accepted but loses that second +redaction layer. + +`deploy` provisions and starts the unit, and returns `{"ok": true, "agent_id": "buzz-acp@"}`. +The desktop persists `agent_id` in `record.backend_agent_id`. + +`deploy` also **verifies or installs** two host-side tools. When the payload carries the optional +path — a path on the *desktop* machine to a Linux binary — and the host resolves none, that binary is +installed to `~/.local/bin` inside the provisioning round trip, before anything else is written. +The fields are seams, not modes: there is no second op, no provisioning step, and no new UI state. +A payload carrying neither field sends no binary and opens no extra round trip — the script is the +one the crate has always sent, plus the CLI resolution block, which is unconditional because its +whole job is to notice a host that has no CLI. A test pins that script byte for byte. + +| payload field | tool | installed as | host has neither it nor a payload | +|---|---|---|---| +| `agent.buzz_acp_binary` | `buzz-acp`, the harness | `~/.local/bin/buzz-acp` | **exit 90** — the deploy stops | +| `agent.buzz_cli_binary` | `buzz`, the agent-facing CLI | `~/.local/bin/buzz` | a `WARNING:` line on stderr; the deploy **continues** | + +**The asymmetry is deliberate.** `buzz-acp` *is* the agent, so its absence is fail-closed. The `buzz` +CLI is what a remote agent's own system prompt tells it to reply with (`buzz messages send --reply-to +`, `buzz feed get`) — a local agent gets it because the desktop bundles it as a sidecar and +prepends its directory to the spawned harness's `PATH`. Without it a remote agent still runs; it just +cannot use the CLI, and in practice spends its first minutes hunting the filesystem for a command +that is not there. That is worth a warning and never worth failing a deploy over. Integrity failures +(exit 93/94) are fatal for **both**: a payload that arrives damaged is evidence the stream is damaged, +and that stream also carries the minted nsec. + +**Resolution is `PATH` *or* `~/.local/bin/`, never `PATH` alone.** A non-interactive SSH +command reads no profile, so `~/.local/bin` — the documented convention and the install destination — +is not on the ambient `PATH`. That is exactly why the unit's env file pins +`PATH="$HOME/.local/bin:$PATH"` itself (see the env file contract). A `command -v`-only rule would +therefore never see the copy a previous deploy installed: since deploy is the start path, every agent +start would re-stream tens of megabytes and swap the binary underneath a running fleet. The probe and +the deploy script apply the same two-part rule, so they cannot disagree. + +**Staleness rule: push-when-missing only.** A host that already resolves a tool keeps the binary it +has, whatever its version. Deploy is the start path, so a version-comparing rule would reinstall +underneath a running fleet on every start, and a desktop pinned to an older artifact would +*downgrade* the host. Refreshing an existing install belongs to the release-artifact follow-up below. + +**Setting either field costs one extra round trip, and only when one is set.** Deploy is the start +path, so embedding binaries unconditionally would stream tens of megabytes of base64 on every start of +every agent, forever, to hosts that were provisioned on day one. So when — and only when — at least +one field is present, `deploy` asks the host the resolution question above first, for both tools in a +single probe; anything the host already has is never even read from disk. The probe is an +optimization, never the decision: the deploy script re-checks on the host and installs only into an +empty variable, so a host that gains or loses a tool between the two round trips still lands correct. + +The install rides the SSH stdin channel with everything else, which dictates its shape: + +- **base64, not raw bytes.** The script is text; a NUL or a stray newline inside an ELF section + would corrupt the *script*, not just the payload. The encoded alphabet (`A-Za-z0-9+/=`) contains + no shell metacharacter and no `_`, so no data line can terminate a `BUZZ_ACP_B64_EOF` / + `BUZZ_CLI_B64_EOF` heredoc early. The delimiters are quoted as well, so the remote shell expands + nothing in either body, and they differ so one script can carry both. +- **sha256 before install.** The digest is computed on the desktop and travels in the script in the + clear (a fingerprint, not a credential); the host runs `sha256sum -c` against the decoded temp + file and aborts with exit 94 on a mismatch. Nothing is made executable before it verifies. +- **Atomic.** Decode goes to `~/.local/bin/..tmp.$$` — same directory as the target, so the + `mv` is a rename — then `chmod 755`, then `mv`. Every failure path removes the temp file first, so + no run leaves a half-written executable where `ExecStart` would name it. +- **`base64` and `sha256sum` must exist on the host** (coreutils). Their absence is exit 92 with a + clear message, never a silent skip of the integrity check. +- **The desktop refuses the payload before the session opens** when the path is missing, is not a + file, is empty, is over 200 MB, or is not an ELF binary — and the message names which tool it is + about. Pushing a Mach-O from a macOS desktop would otherwise install cleanly and restart-loop on + `Exec format error` every five seconds after a deploy that reported success. This holds for the CLI + too: a *missing* CLI is tolerable, but a desktop that pointed the seam at the wrong file has a bug + worth naming. +- **The secret discipline is untouched.** The pushed bytes are not secret, but they share the stream + with the minted nsec; base64 is what keeps them from corrupting it. `umask 077`, the `chmod 600` + env file and the "nothing secret on any argv" rule are unchanged. +- **Only `buzz-acp` reaches the unit.** `ExecStart` is substituted from the resolved harness path. + The CLI is reached purely through the env file's `PATH`, which is why installing it and pinning + that `PATH` are one change and not two. + +The fields are filled desktop-side from the `BUZZ_ACP_PUSH_BINARY` and `BUZZ_CLI_PUSH_BINARY` +environment variables, read at deploy time (`deploy_payload_json`), so a developer can point either at +a fresh build without restarting the app. Those are dev/dogfood seams, not the destination: the +release build should resolve the artifacts for the host's platform by version, with no user-visible +path at all. **Fetching release artifacts is out of scope here and is the immediate follow-up**, along +with the version-refresh rule that only becomes safe once the desktop knows which version it is +offering. + +Non-fatal host-side complaints — today, only the missing-CLI warning — reach the desktop on the +provider's **stderr**, prefixed `WARNING: ` and scrubbed by the same redactor the failure path uses. +`invoke_provider` writes them to the desktop log (`tracing::warn`) when the op succeeds, and folds +them into the error message when it fails. The op's JSON response is unchanged either way: the deploy +succeeded, and a warning is not a result. + +Errors are `{"ok": false, "error": "…"}` on stdout, human detail on stderr, and **exit 0 always**. +A non-zero exit makes `invoke_provider` discard stdout entirely and report raw stderr, which throws +the structured error away. + +A failure the user can act on may carry an optional `recovery` alongside `error`: + +```json +{ "ok": false, + "error": "this host requires Tailscale SSH authentication in a browser: https://login.tailscale.com/a/…", + "recovery": { "action": "open_url", "url": "https://login.tailscale.com/a/…" } } +``` + +`recovery` is optional in both directions, so there is no negotiation and no flag: a desktop that +does not read it still renders `error`, which names the problem and carries the URL as text, and a +desktop that does read it finds nothing there from an older provider. The only `action` today is +`open_url`, and the only URL is Tailscale's login host — the SSH provider **constructs** that URL +from a fixed prefix plus a charset-constrained token rather than parsing one out of remote output, +so no host, scheme, or query from the host can reach the browser opener. The desktop re-validates +the prefix anyway before opening, on the same "the provider is a subprocess, not a trusted peer" +footing as its secret re-redaction. + +The provider emits this when a tailnet ACL uses Tailscale SSH's `check` action, which makes `ssh` +print the URL and then block for a human that `BatchMode` cannot supply. It is detected by peeking +at buffered stderr during the poll loop, so the op fails in one 25 ms tick instead of burning its +whole budget (8 s for `check`, 300 s for `deploy`) and reporting a bare timeout. + +On the desktop, `invoke_provider` returns `ProviderFailure { message, recovery }` rather than a +`String`, and `ProviderRecovery::from_response` is where the URL is re-validated — on entry, so an +unvalidated one never exists in desktop memory at all and no later reader of the payload can become +a second, unguarded way to open it. There is deliberately no `From for String`: +that is the type-level guard against a caller flattening the recovery away, which is the one bug +this plumbing exists to prevent. The provider commands carry the type out to the frontend, which +reads it off `TauriInvokeError.payload` via `providerRecoveryOf`. + +Two paths drop the recovery **explicitly**, each at one named site, because their surface cannot +render an action: `start_managed_agent` (a toast) and `create_managed_agent`'s `spawn_error` (a +reported field of a succeeding create). Nothing is lost to the user there — the message names the +problem and carries the URL as text — but widening either needs its surface to grow an action +first. The agent record's `last_error` is a plain string for a different reason: it is read back +long after the fact, and an auth URL is a one-shot token that is stale by then. + +**Recovery is a manual retry.** The create dialog renders an "Authenticate in browser" button beside +the failure and nothing else: the desktop cannot tell when the user has finished authenticating in a +browser it does not own, so an auto-retry would be guessing at a delay. "Check the host again" is +already the retry, and it is the same button every other host failure offers. + +## Configuration + +`validate_provider_config` rejects any config key whose word-split contains +`secret`/`password`/`token`/`key`/`credential`, and drops it silently. That is why the identity +field is `ssh_identity_file` and not `ssh_key_path`. + +| key | required | notes | +|---|---|---| +| `ssh_host` | yes | hostname, IP, or `user@host`. Rejected if it starts with `-` or contains whitespace/control characters. Carries a `oneOf` of tailnet devices when one is available. | +| `ssh_user` | no | Ignored when `ssh_host` already contains `@`. | +| `ssh_port` | no | Number or numeric string, `1..=65535`. Default 22. | +| `ssh_identity_file` | no | Passed as `ssh -i`. Defaults to `~/.ssh/config` and the agent. | +| `buzz_acp_path` | no | Absolute path to `buzz-acp` on the host. Defaults to whatever is on the host's PATH. | + +There is no `unit_scope`. All deploys are `systemctl --user`. + +The `oneOf` is a **generic decoration, not an SSH feature**. Any provider may attach +`oneOf: [{ const, title }]` to any config property; the desktop renders a dropdown over the +`const` values labelled by `title`, always with an "Other…" row that swaps back to the plain +text field. Nothing in the desktop knows what a tailnet is, and a value the list does not +contain — one carried over from before the decoration existed, or a peer that has since left +the tailnet — stays in the text field rather than reading as unselected. Omit the `oneOf` and +the field is exactly the text input it was before. + +## Host prerequisites + +`scripts/provision-buzz-host.sh` checks all of these on a candidate host and prints what is +missing. It is a preflight, not an installer. + +1. **A non-root user.** The whole flow is root-free. The env file lands under that user's + ownership, beside the harness credentials that already live there (`~/.claude`, + `~/.config/goose`). + +2. **`loginctl enable-linger `.** This is the one non-obvious prerequisite. Without lingering, + the user manager is torn down when the last session ends, so the agent is killed the moment the + deploy's own SSH session closes — which reads as a flaky agent, not as a configuration problem. + Lingering also creates `/run/user/$(id -u)`, without which every `systemctl --user` call fails + to reach the bus. `deploy` runs `loginctl enable-linger` itself, before any bus traffic, but + best-effort: some hosts gate it behind polkit, and failing it must not fail an otherwise good + deploy. On those hosts, run it once by hand as root. + +3. **`buzz-acp` on the host's PATH or at `~/.local/bin/buzz-acp`** — `deploy` resolves both, since a + non-interactive SSH `PATH` does not contain the latter — or an absolute path in + `buzz_acp_path`. `discover_harnesses` reports its absence without failing. `deploy` installs it + when the desktop supplied one (`BUZZ_ACP_PUSH_BINARY`, see the `deploy` section) and otherwise + refuses. Installing it needs `base64` and `sha256sum` on the host — coreutils, present on any + normal Linux — and nothing else. + + **The `buzz` CLI is the same story with a softer ending.** Agents are told by their system prompt + to reply with `buzz messages send`, so a host without it produces an agent that cannot. `deploy` + resolves it the same two ways, installs it from `BUZZ_CLI_PUSH_BINARY` when the host has none, and + otherwise emits a warning and provisions the agent anyway. Not a prerequisite — but a host that + satisfies it gets noticeably better agents. + + **`git-credential-nostr` is a third tool with a third policy: resolved, never installed.** + `deploy` writes the agent's `GIT_CONFIG_*` block only when the host already has the helper, and + nothing pushes it, so a remote agent on a host without it cannot push to a Buzz repository — + with no deploy-time warning, and no error until the agent tries. That is why the preflight + reports it as its own row rather than folding it into the `buzz` CLI check. + +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 + run `tailscale set --ssh` on the host. + +6. **Tailscale (optional).** When the desktop's own `tailscale status --json` reports + `BackendState: "Running"`, its peers decorate the `ssh_host` field as a device picker. Phones and + TVs are filtered out; `Self` is never offered. The label carries reachability and, when the peer + advertises `sshHostKeys`, a `· Tailscale SSH` marker — that field's absence is the negative + signal, not an unknown. Tailscale absent, logged out, or empty produces a schema byte-identical + to the plain one; manual SSH is the unchanged fallback. + +`XDG_RUNTIME_DIR` needs no host action: a non-interactive SSH command often gets none, and `deploy` +sets it when the session did not supply one. + +Windows hosts are never deploy targets. The provider runs on Windows — it resolves +`%SystemRoot%\System32\OpenSSH\ssh.exe` before PATH and suppresses the console window for every +child — but the remote side is POSIX `sh` and `systemd --user` throughout. + +## Security invariants + +These are properties of the code, not conventions to uphold. + +- **Secrets cross on stdin only.** Every op sends its script to a remote `sh -s`; the remote argv is + the literal string `sh -s`, and the local argv is ssh options. The remote `ps` is world-readable + and the desktop's redaction has no reach there, so a secret on the remote argv would leak the + agent identity to every user on the box. +- **The env file is owner-only.** Written under `umask 077`, `chmod 600`, then moved into place, so + a failed write never leaves a half-written identity behind. +- **A pushed `buzz-acp` cannot corrupt the script carrying the nsec.** It travels base64-encoded + inside a quoted heredoc, so no byte of it is ever read as shell syntax. It is verified against a + desktop-computed sha256 before it is made executable, and installed by a same-directory rename, so + a damaged or interrupted push leaves nothing runnable behind. +- **A deploy without the minted nsec fails closed.** An agent that mints its own key on the host + looks deployed and is permanently unreachable: presence, mentions, `!shutdown`, badges and the + NIP-OA auth tag all key off the pubkey the desktop minted. +- **A deploy without the harness pin fails closed.** The pin is the only channel by which the + harness choice reaches the host. A blank one would fall through to `buzz-agent`, silently + provisioning a harness the user never chose, so it is refused rather than substituted. +- **Reserved env keys are refused**, as are env names that are not POSIX identifiers and env values + containing control characters. A newline in a value would otherwise end the assignment and start a + line of the value's own choosing — including one that re-sets `BUZZ_PRIVATE_KEY`. The list is a + verbatim copy of the desktop's `RESERVED_ENV_KEYS`, so a leak needs two independent failures. +- **`Secret` renders as `[REDACTED]`** in both `Debug` and `Display` and zeroizes on drop. + `Agent` and `ssh::Output` deliberately do not derive `Debug` at all: the first holds provider API + keys in plain `String`s, the second holds raw remote stderr, and only `Output::failure()` runs + that through the scrubber. +- **Host-key trust is never relaxed for a typed address.** `StrictHostKeyChecking=ask` by default; + `accept-new` only for an address this machine's own Tailscale daemon lists as a peer, which was + already reached over a WireGuard-authenticated tunnel. +- **Provider binaries are resolved by discovery, never by name.** Every deploy, start and probe path + resolves through `discover_provider_candidates`, so a frontend or IPC caller that names a + `binaryPath` cannot steer execution at an arbitrary binary and feed it the agent's private key. + +## The systemd unit + +One templated `buzz-acp@.service` per host, instantiated per agent. + +```ini +[Unit] +Description=Buzz agent %i +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +NoNewPrivileges=true +EnvironmentFile=%h/.config/buzz-acp/%i.env +ExecStart=@BUZZ_ACP_BIN@ +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target +``` + +- `StartLimitIntervalSec=0` — a long-running agent must never be rate-limited into staying down. A + unit held by the start limiter looks exactly like an agent that silently died, and only + `systemctl reset-failed` clears it. +- `NoNewPrivileges=true` — the agent runs arbitrary code by design, so the SSH user's own privileges + are the intended ceiling. Without this the harness can climb past them through any setuid/setgid + binary on the host, or through passwordless `sudo` granted to that user. It is deliberately the + whole hardening delta: `ProtectSystem`/`ProtectHome` belong here too, but an agent has no modeled + workspace yet (see Known limitations), so until writable paths are something the protocol states + those directives would be guessing at which of the user's home an agent legitimately needs. +- `EnvironmentFile` — holds the minted nsec; systemd reads it as the owning user. +- `ExecStart` is an absolute path, substituted at install time from the host's resolved `buzz-acp`. + systemd does not expand environment variables in the program position, and the shell indirection + that would work around that is not worth adding to a unit whose environment carries a private key. + The substitution is shell parameter expansion, not in-place editing: `sed -i` is a GNU extension + that BSD and macOS hosts reject. Resolution runs *first*; the install only fills an empty `$acp`, + so a deploy that installed `buzz-acp` writes the path of the copy it just installed, not a stale + one. The path is written **double-quoted, per systemd's command-line syntax** — `ExecStart=` splits + an unquoted value on whitespace, and `buzz_acp_path` may legitimately name a directory containing + some, which would otherwise make systemd run the first word with the rest as arguments. `\` and `"` + are escaped on the way in, since systemd unquotes C-style escapes inside double quotes. + +The instance name is the agent name made unit-safe — lowercased, non-alphanumerics collapsed to `-`, +truncated to 32 characters — followed by the first 12 hex characters of the agent's `pubkey`. The +name is the readable half; **the pubkey fragment is the identity**. A display name is not unique: +two agents called "Research Bot" on one SSH account keyed on the name alone shared one unit, one env +file and one `agent_id`, so the second deploy overwrote the first agent's minted nsec and starting +either record drove whichever identity was written last. `deploy` therefore refuses a payload whose +`agent.pubkey` is absent or is not a 64-character hex key, rather than falling back to the name. + +**A host provisioned before this rule keeps its old units.** The instance name changed, so a +redeploy provisions a new unit alongside the name-keyed one rather than replacing it, and the old +unit keeps running under `Restart=always`. There is no `undeploy` op to clean that up, so on a +pilot host stop and remove the stale pair by hand: +`systemctl --user disable --now buzz-acp@.service` and delete +`~/.config/buzz-acp/.env`, which holds an nsec. + +## Env file contract + +The local spawn contract from `runtime.rs`, transcribed. Values resolved on the host — the absolute +harness path, `git-credential-nostr`, `PATH` — are appended by the remote script. + +| 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` | the deploy script's own `$PATH`, which already leads with `$HOME/.local/bin`; **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) | +| `BUZZ_ACP_AGENT_ARGS` | comma-joined | +| `BUZZ_ACP_MCP_COMMAND` | empty | +| `BUZZ_ACP_LAZY_POOL` | `true` — the pool warms on the first accepted event instead of at startup; queued work is not dropped, and a restarted or idle unit does not hold N harness subprocesses | +| `BUZZ_ACP_AGENTS` | payload `parallelism` | +| `BUZZ_ACP_MULTIPLE_EVENT_HANDLING` | `steer` | +| `BUZZ_ACP_DEDUP` | `queue` | +| `BUZZ_ACP_RELAY_OBSERVER` | `true` | +| `BUZZ_ACP_RESPOND_TO` (+ `_ALLOWLIST`) | payload; `allowlist` mode with an empty list is refused | +| `BUZZ_ACP_SYSTEM_PROMPT`, `BUZZ_ACP_MODEL` | payload, omitted when empty | +| runtime model/provider env | payload `model` / `provider`, under the runtime's own names — see below | +| `BUZZ_ACP_IDLE_TIMEOUT`, `BUZZ_ACP_MAX_TURN_DURATION` | emitted only when set, so the harness's own defaults win | +| `NOSTR_PRIVATE_KEY`, `GIT_TERMINAL_PROMPT`, `GIT_CONFIG_*` | only when `git-credential-nostr` is on the host | +| user `env_vars` | written last, so they override — matching the local layering | + +**The `PATH` line is the remote half of the desktop's own PATH contract.** Local spawn prepends +`/.local/bin` (and the bundled sidecar directory) to the spawned harness's `PATH` +(`managed_agents::runtime::path::build_augmented_path`), which is why a local agent can run the +`buzz` CLI its system prompt tells it to reply with. Remotely the harness runs under `systemd --user`, +whose `PATH` is the user manager's: no profile, no login shell, and on many distributions no +`~/.local/bin` at all. Without this line every tool `deploy` installs would be installed and +unreachable. + +It is composed **by the host's shell during the deploy**, not written into the unit as +`Environment=PATH=$HOME/.local/bin:$PATH`. systemd expands no variable in `Environment=` or in an +`EnvironmentFile`, so that form would hand the harness the five literal characters `$PATH`. The value +written is the deploy script's own `PATH`, captured at deploy time — which is the same `PATH` every +`command -v` in that script searched, so anything it found on the host stays findable for the agent. +The harness passes its environment to its children unchanged, so this is what makes `buzz` a command +a remote agent can actually run. + +**Every remote script prepends `~/.local/bin` to its own `PATH` first** (`install::PATH_PREAMBLE`), +which is why the captured value already leads with the install destination. A non-interactive +`ssh host sh -s` reads no profile — on stock Debian the whole `PATH` is +`/usr/local/bin:/usr/bin:/bin:/usr/games` — so without it `discover` reports a host's entire harness +catalog as absent, and the deploy that follows refuses the pin with exit 91 on a host where every +adapter is installed and runnable. `scripts/provision-buzz-host.sh` does the same, so its report and +the deploy's behavior cannot disagree. + +**`BUZZ_ACP_RELAY_OBSERVER` is pinned `true`, but it only does anything once an owner resolves.** +Observer frames are addressed and encrypted to the owner's pubkey, so `buzz-acp` resolves one at +startup — from `BUZZ_AUTH_TAG` (the NIP-OA attestation, verified against the agent's own key) or +from `--agent-owner` — and logs `relay observer requested but no agent owner was resolved at +startup` and publishes nothing when it has neither. This is worth stating because dropping the +NIP-OA identity is the current workaround for the cross-machine mention gate (#3277, #2987): a +deploy with no `auth_tag` and `respond_to=allowlist` is a perfectly working agent that answers +mentions and pushes to repositories, and the only thing quietly missing is its observer telemetry. +Deploy does not warn, since a payload without an auth tag is a legitimate configuration; the +harness's own log is the place that says so. + +The runtime model/provider pair is the remote half of `runtime_metadata_env_vars`. `BUZZ_ACP_MODEL` +is what `buzz-acp` reads; these are what the harness underneath it reads, and local spawn writes +both. Without them a remote Goose would fall back to whatever `~/.config/goose/config.yaml` on the +host says, with the user's model pick silently ignored. + +| pinned command | model var | provider var | +|---|---|---| +| `goose` | `GOOSE_MODEL` | `GOOSE_PROVIDER` | +| `buzz-agent` | `BUZZ_AGENT_MODEL` | `BUZZ_AGENT_PROVIDER` | + +The lookup is keyed on the command's file name, so an absolute pin +(`/home/ubuntu/.local/bin/goose`) resolves to the same runtime — matching `known_acp_runtime` +locally. Runtimes absent from the table declare no such vars in `KNOWN_ACP_RUNTIMES` either: +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. + +`turn_timeout_seconds` is deliberately never read. The payload still carries it, but +`BUZZ_ACP_TURN_TIMEOUT` is deprecated and ignored by the harness, and local spawn does not write it +either — `idle_timeout_seconds` and `max_turn_duration_seconds` are the live controls. A test pins +that no `TURN_TIMEOUT` key can reappear in the env file. + +## Lifecycle + +**Deploy is the start path.** `start_managed_agent` re-enters `deploy_to_provider`, so start and +redeploy are one code path and everything in it is idempotent. Non-idempotence would surface as +duplicate units, not as an error. One deploy is one round trip (plus the cheap resolution probe, +only when a push field is set) that resolves — or, on a host that has none and a payload that +carries one, installs — `buzz-acp` and the `buzz` CLI, resolves the +harness, writes the env file atomically, enables lingering, installs the unit template, +`daemon-reload`s only when the unit content actually changed, then `enable --now` and `restart`. +The restart is what makes an already-running unit adopt the rewritten env file. + +**Stop is `!shutdown`.** The desktop's `stop_managed_agent` command rejects non-local agents +outright. The frontend sends a signed `!shutdown` @mention; the harness consumes it, drains +in-flight prompts, publishes `offline` presence, and exits. `Restart=always` then restarts the unit +after `RestartSec=5` — a `!shutdown` stops the current process, not the unit. To stop the unit, +`systemctl --user stop buzz-acp@.service` on the host. + +**There is no `undeploy` op.** Deleting a deployed remote agent requires `force_remote_delete: true` +and permanently orphans a systemd unit and an env file containing an nsec on the host. This is the +strongest candidate for the immediate follow-up PR. + +**Logs** are `journalctl --user -u buzz-acp@ -f` on the host. + +## Troubleshooting + +**"Failed to connect to bus" during deploy.** Lingering is off and `loginctl enable-linger` was +rejected (polkit), so `/run/user/$UID` does not exist and no `systemctl --user` call can reach the +user manager. Run `sudo loginctl enable-linger ` once and redeploy. + +**Agent goes online, then offline as soon as the deploy finishes.** Same cause, softer symptom: the +bus was reachable through the deploy's own session, and the user manager was torn down with it. +Enable lingering. + +**`buzz-acp not found on the server's PATH or in ~/.local/bin` (exit 90).** Neither `command -v +buzz-acp` nor `~/.local/bin/buzz-acp` resolved on the host, and the payload carried no binary to +install. Install it to `~/.local/bin`, set `buzz_acp_path`, or +point `BUZZ_ACP_PUSH_BINARY` at a Linux `buzz-acp` on the desktop and let the deploy install it. +Note that `discover_harnesses` reports this non-fatally, so it can first appear at deploy time. + +**`WARNING: no 'buzz' CLI on the server's PATH or in ~/.local/bin`.** The deploy **succeeded** — this +is a warning on the provider's stderr, not an error. The agent is running, but it cannot answer with +`buzz messages send --reply-to …` the way its own system prompt tells it to, so it will fall back to +slower replies (and, left to itself, waste its first turns looking for the command). Install `buzz` +into `~/.local/bin` on the host, or point `BUZZ_CLI_PUSH_BINARY` at a Linux `buzz` on the desktop and +redeploy. This is the one host-side complaint that is deliberately not fatal. + +**Agent replies are slow, or it reports it cannot find `buzz`.** Either the CLI is not installed +(above), or it is installed somewhere the unit's `PATH` does not reach. The env file pins +`PATH="$HOME/.local/bin:"`; `systemctl --user show-environment` and +`cat ~/.config/buzz-acp/.env` show what the harness actually got. A tool installed after the +last deploy into a directory that was not on the deploying shell's `PATH` needs one redeploy. + +**`the server has no 'base64' / 'sha256sum'` (exit 92).** The host is missing coreutils, so a pushed +binary cannot be decoded or — the part that is not negotiable — verified. Install coreutils, or +install the tool on the host by hand. The deploy stops before writing anything. + +**`the pushed buzz-acp` / `the pushed buzz did not decode` / `failed its sha256 check` (exit 93 / +94).** The binary was damaged between the desktop and the host. Nothing is installed and no temp file +survives; the env file and unit are never written. Fatal for both tools — including the +non-load-bearing CLI, because the damaged stream is the one carrying the nsec. Retry, and if it +repeats, the local file named by the corresponding `BUZZ_*_PUSH_BINARY` is the suspect. + +**`the buzz-acp binary to push is not a Linux (ELF) executable`** (or `the buzz binary …`). +`BUZZ_ACP_PUSH_BINARY` / `BUZZ_CLI_PUSH_BINARY` points at the desktop's own build (Mach-O or PE) +rather than a Linux one. Caught locally, before the session opens; the message names which variable to +fix. The alternative is a unit that restart-loops on `Exec format error`, or a `buzz` on the host that +fails on every invocation, after a deploy that reported success. + +**The model list never arrives, or reports `agent timed out (60s)`.** There are three nested +budgets on that path and the innermost one is on the host: `buzz-acp models --json` allows +`MODELS_TIMEOUT` — 60s, deliberately the same budget the normal agent-init path gives the very same +adapter spawn — for the harness to reach `initialize`. Outside it, the provider allows 110s for the +whole `probe_models` op and the desktop allows 150s. A cold node adapter (`codex-acp`) on a busy +host can take tens of seconds on its first spawn and be instant warm, so a probe that times out +once and succeeds on "check host again" is a slow host, not a broken harness. A probe that keeps +hitting 60s is the harness failing to start: run `buzz-acp models --json` on the host by hand, where +the adapter's own stderr is visible. Longer than 110s and the failure changes shape — the provider +budget fires first and the desktop reports a provider timeout rather than an agent one. + +**`harness not found on the server's PATH` (exit 91).** The pinned harness is not installed +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. + +**`Host key verification failed`.** The host key is not in `known_hosts`, and `BatchMode` cannot +prompt to accept it. Connect once with `ssh` by hand to review and accept the key. This is expected +for any manually typed address; tailnet peers are exempt. + +**The device dropdown disappeared.** `tailscale status --json` no longer reports +`BackendState: "Running"` — most often a logged-out daemon, which exits 0 with +`BackendState: "NeedsLogin"`. The field degrades to plain text and manual SSH still works; a +MagicDNS name typed into it will fail with `Could not resolve hostname`. + +## Known limitations + +- `runtimeSupportsLlmProviderSelection` is a hardcoded id test (`buzz-agent` or `goose`). A remote + harness whose id matches gets the LLM-provider selector; any other remote id does not, however + the host's own catalog describes it. +- `BUZZ_ACP_TEAM_INSTRUCTIONS` is not carried to the host — the deploy payload has no team field, so + a team-linked remote agent starts without its team's standing rules. No longer *silent*: the + create flow states it the moment "elsewhere" is the answer, and the edit dialog states it for a + record where it is already true (`remoteTeamInstructions.ts` owns both). Carrying the resolved + text needs a payload field. +- **No project workspace is projected onto the host.** A local managed agent runs inside Desktop's + `REPOS` workspace; the unit here has no `WorkingDirectory` at all, so a remote agent starts in + whatever `systemd --user` gives it and has no Buzz-native project checkout. Buzz Git auth is + configured only when `git-credential-nostr` already happens to be on the host — the deploy + resolves the helper but never installs it, and writes no `GIT_CONFIG_*` block without it, so a + remote agent on a host that lacks it cannot push to a Buzz repository and finds out only when it + tries. `provision-buzz-host.sh` reports the helper as its own row for that reason. In practice + every long-running remote agent operates in a separately provisioned checkout. Closing this is a + protocol addition: a workspace field the desktop states, rather than the provider guessing which + project to clone. +- `MCP_HOOK_SERVERS` is not emitted. `mcp_hooks` is local catalog metadata the provider cannot + compute, so remote agents have no `_Stop`/`_PostCompact` hook tools. +- `check` is implemented but has no desktop caller. `discover_harnesses` serves as the de facto + preflight, since it is the first op the create flow runs against a host. +- The credential gate cannot see what the host already supplies. It asks the pinned REMOTE harness + which env keys matter, but the runtime file layer (`~/.config/goose/config.yaml`) is local, so it + is suppressed entirely for a remote create rather than answering for the wrong machine. A host + whose config file already carries the credentials is therefore still asked for them. Closing this + needs a `check`-style round trip that reports the host's own configuration — a protocol addition. +- The tailnet device picker filters out phones and TVs, but still offers Windows peers, which + cannot be deploy targets. Picking one fails at deploy, not at selection. +- **Neither host-side tool is installed unless the desktop supplies one.** `deploy` installs the + binaries named by `agent.buzz_acp_binary` / `agent.buzz_cli_binary` (from `BUZZ_ACP_PUSH_BINARY` / + `BUZZ_CLI_PUSH_BINARY`) on a host that has none; with the variables unset a missing `buzz-acp` is + still exit 90 and a missing `buzz` is still just a warning. Resolving the right release artifacts + for the host — which is what makes the create dialog's deploy-will-install promise true, and what + gives every remote agent CLI parity with a local one without a developer setting an env var — is + the immediate follow-up, and the version-refresh rule rides with it. +- An already-installed tool is never upgraded by a deploy, by design (see the staleness rule). A host + stuck on an old `buzz-acp` or `buzz` has to be updated by hand until artifact fetching lands. From b70cb1178c42cd42a701788e14bda7ff5bcae073 Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Sun, 2 Aug 2026 13:14:46 +0200 Subject: [PATCH 10/13] fix(backend-ssh): derive the agent identity from the nsec, never trust the payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/remote-agents.md §Deploy, Step 0: "the provider MUST parse private_key_nsec and derive the public key from it; a malformed or undecodable key is an immediate in-band error. Every selector, name, and comparison below uses the *derived* pubkey — never a caller-supplied one." The provider was taking the pubkey straight off the payload, which is the caller-supplied identity the spec rules out. It matters here because that value is not decorative: the first 12 hex characters of it name the systemd unit, the env file and backend_agent_id. A payload whose pubkey and nsec disagreed would name every host-side artifact for one identity while the harness authenticated to the relay as another — an agent that looks deployed and is permanently unreachable, which is the exact failure the fail-closed nsec check already exists to prevent. New identity module: bech32-decode the nsec (rejecting a wrong hrp, so an npub in the private key's field is caught by name), take the secp256k1 x-only public key, and reconcile against the payload's pubkey if it carried one. The derived value always wins and is never replaced by the assertion; a well-formed assertion for a different key is a fatal in-band error. Decoded key bytes are Zeroizing, and no error string echoes any part of a key — both pubkeys are truncated to a 12-character fragment, since the response is persisted in the desktop's last_error. Test fixtures now use real nsec/pubkey pairs, because an arbitrary pubkey beside an unrelated nsec no longer parses at all — which is the point. Signed-off-by: Troy Hoffman --- Cargo.lock | 4 +- crates/buzz-backend-ssh/Cargo.toml | 8 + crates/buzz-backend-ssh/src/deploy.rs | 123 ++++++++++----- crates/buzz-backend-ssh/src/identity.rs | 197 ++++++++++++++++++++++++ crates/buzz-backend-ssh/src/main.rs | 1 + docs/remote-agents-ssh.md | 28 +++- 6 files changed, 320 insertions(+), 41 deletions(-) create mode 100644 crates/buzz-backend-ssh/src/identity.rs diff --git a/Cargo.lock b/Cargo.lock index a1e5d9b3c15..be977096cd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -969,7 +969,9 @@ dependencies = [ name = "buzz-backend-ssh" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", + "bech32", + "secp256k1 0.31.1", "serde", "serde_json", "sha2 0.11.0", diff --git a/crates/buzz-backend-ssh/Cargo.toml b/crates/buzz-backend-ssh/Cargo.toml index ebc58b26271..e3b8ab05a88 100644 --- a/crates/buzz-backend-ssh/Cargo.toml +++ b/crates/buzz-backend-ssh/Cargo.toml @@ -29,3 +29,11 @@ zeroize = { workspace = true } # arrived damaged. Both are already workspace dependencies. base64 = { workspace = true } sha2 = { workspace = true } +# Step 0 of the deploy reconciliation loop (docs/remote-agents.md) requires the +# provider to derive the agent's identity from `private_key_nsec` rather than +# trust a caller-supplied pubkey. bech32 decodes the NIP-19 nsec; secp256k1 +# turns those bytes into the x-only public key every host-side name is keyed on. +# Versions match the ones already resolved in the workspace lockfile +# (buzz-pair-relay pins the same secp256k1 major). +bech32 = "0.11" +secp256k1 = "0.31" diff --git a/crates/buzz-backend-ssh/src/deploy.rs b/crates/buzz-backend-ssh/src/deploy.rs index 20690256cf4..da1b93ee51e 100644 --- a/crates/buzz-backend-ssh/src/deploy.rs +++ b/crates/buzz-backend-ssh/src/deploy.rs @@ -48,8 +48,11 @@ const RESERVED_ENV_KEYS: &[&str] = &[ /// `Debug` would put provider credentials one `{:?}` away from a log line. pub struct Agent { pub name: String, - /// The agent's minted Nostr pubkey — the desktop record's own primary key, - /// and the only stable identifier in the payload. See [`Agent::slug`]. + /// The agent's Nostr pubkey — the desktop record's own primary key, and the + /// only stable identifier this deploy has. **Derived from + /// `private_key_nsec`, never read from the payload's `pubkey` field**, per + /// `docs/remote-agents.md` §Deploy Step 0. See [`Agent::slug`] and + /// [`crate::identity`]. pub pubkey: String, pub relay_url: String, pub private_key_nsec: Secret, @@ -123,27 +126,33 @@ impl Agent { reached the host (see instanceInputForDefinition provider branch)", )?; - // The one stable identifier in the payload, and what every host-side - // name is keyed on. Refused rather than defaulted for the same reason - // the minted key is: without it two agents that merely share a display - // name would share one unit, one env file and one `backend_agent_id`, - // and the second deploy would overwrite the first agent's identity. + // Step 0 of the reconciliation loop (docs/remote-agents.md §Deploy): + // "the payload carries the nsec, not the pubkey ... the provider MUST + // parse `private_key_nsec` and derive the public key from it ... Every + // selector, name, and comparison below uses the *derived* pubkey — + // never a caller-supplied one." // - // Validated to the shape the desktop always mints (`to_hex()` of a - // Nostr public key) because the fragment taken from it becomes a - // filename and a systemd instance name. Anything else is a payload bug, - // and sanitizing it would trade a loud failure for a silent collision. - let pubkey = string("pubkey").ok_or( - "deploy payload carries no 'pubkey': the remote unit would be keyed on the agent's \ - display name, which two agents can share", - )?; - if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(format!( - "'pubkey' is not a 64-character hex Nostr public key: {} characters", - pubkey.len() - )); + // This is the one stable identifier, and every host-side name is keyed + // on it: the unit instance, the env file, `backend_agent_id`. Deriving + // rather than trusting is what guarantees the name and the identity the + // harness actually authenticates as cannot come apart. The payload's + // own `pubkey` is still read, but only as an assertion to reconcile + // against — see `identity::reconcile`, where a mismatch is fatal. + let asserted_pubkey = string("pubkey"); + if let Some(claimed) = asserted_pubkey.as_deref() { + // Shape-check the assertion before comparing, so a payload bug + // reports as a malformed field rather than as an identity mismatch. + if claimed.len() != 64 || !claimed.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "'pubkey' is not a 64-character hex Nostr public key: {} characters", + claimed.len() + )); + } } - let pubkey = pubkey.to_lowercase(); + let pubkey = crate::identity::reconcile( + crate::identity::derive_pubkey(&private_key_nsec)?, + asserted_pubkey.as_deref(), + )?; Ok(Self { name: string("name").ok_or("'name' is required")?, @@ -713,11 +722,19 @@ pub fn deploy( mod tests { use super::*; - const NSEC: &str = "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"; - /// A 64-hex Nostr pubkey, in the shape `record.pubkey` always carries. - const PUBKEY: &str = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + /// The fixture key pair, and it must be a *real* pair: since Step 0 derives + /// the identity from the nsec rather than reading `pubkey`, an arbitrary + /// pubkey next to an unrelated nsec no longer parses at all. + const NSEC: &str = "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsmhltgl"; + /// A 64-hex Nostr pubkey, in the shape `record.pubkey` always carries — and + /// here, the one [`NSEC`] actually derives to. + const PUBKEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; /// The fragment of [`PUBKEY`] every host-side name is keyed on. - const PUBKEY_SLUG: &str = "3bf0c63fcb93"; + const PUBKEY_SLUG: &str = "79be667ef9dc"; + + /// A second real pair, for the tests that need two distinct identities. + const OTHER_NSEC: &str = "nsec1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqstywftw"; + const OTHER_PUBKEY: &str = "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"; fn request() -> serde_json::Value { serde_json::json!({ @@ -1086,15 +1103,18 @@ mod tests { /// file and an `agent_id`, so the second deploy overwrote the first's nsec. #[test] fn two_agents_with_one_name_get_distinct_units() { - let deployed = |pubkey: &str| { + // Two agents are two *keys*, which is now the only way to be two agents: + // the identity comes from the nsec, so the distinguishing input is the + // minted key rather than the pubkey field beside it. + let deployed = |nsec: &str, pubkey: &str| { let mut request = request(); + request["agent"]["private_key_nsec"] = serde_json::json!(nsec); request["agent"]["pubkey"] = serde_json::json!(pubkey); let agent = Agent::from_request(&request).unwrap(); (agent.slug(), agent.agent_id()) }; - let other = "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411"; - let (first_slug, first_id) = deployed(PUBKEY); - let (second_slug, second_id) = deployed(other); + let (first_slug, first_id) = deployed(NSEC, PUBKEY); + let (second_slug, second_id) = deployed(OTHER_NSEC, OTHER_PUBKEY); assert_ne!(first_slug, second_slug); assert_ne!(first_id, second_id); // Both still name the agent a human recognizes. @@ -1110,24 +1130,39 @@ mod tests { assert_eq!(slug, format!("agent-{PUBKEY_SLUG}")); } - /// The identity is refused rather than defaulted: a payload without it - /// would silently fall back to name-keyed units, which is the collision. + /// Step 0 (`docs/remote-agents.md` §Deploy): the identity comes from the + /// nsec, so the key is the thing that cannot be missing. The `pubkey` field + /// is now an assertion — absent it costs nothing, malformed it is a payload + /// bug, and disagreeing with the key it travels beside it is fatal. #[test] - fn deploy_refuses_a_payload_with_no_usable_agent_identity() { + fn deploy_derives_the_identity_and_refuses_a_payload_that_contradicts_it() { + // No pubkey at all still deploys, keyed on the derived identity: the + // nsec is sufficient, which is precisely the spec's point. let mut request = request(); request["agent"].as_object_mut().unwrap().remove("pubkey"); - assert!(rejection(&request).contains("no 'pubkey'")); + let derived = Agent::from_request(&request).unwrap(); + assert_eq!(derived.pubkey, PUBKEY); + assert_eq!(derived.slug(), format!("research-bot-{PUBKEY_SLUG}")); - for bad in ["", "abc123", &"z".repeat(64)] { + // A malformed assertion reports as a malformed field, not as a mismatch. + for bad in ["abc123", &"z".repeat(64)] { request["agent"]["pubkey"] = serde_json::json!(bad); let error = rejection(&request); assert!( - error.contains("pubkey"), + error.contains("not a 64-character hex"), "accepted {bad:?} with error {error}" ); } - // Case is normalized, so the same key never yields two units. + // A well-formed assertion for a *different* key is fatal: the unit would + // be named for one identity and the harness would authenticate as the + // other. + request["agent"]["pubkey"] = serde_json::json!(OTHER_PUBKEY); + let error = rejection(&request); + assert!(error.contains("does not match"), "{error}"); + + // Case is not identity, and the derived value is what the slug uses, so + // an uppercase assertion still yields exactly one unit. request["agent"]["pubkey"] = serde_json::json!(PUBKEY.to_uppercase()); assert_eq!( Agent::from_request(&request).unwrap().slug(), @@ -1135,6 +1170,22 @@ mod tests { ); } + /// The key is the identity, so an undecodable one has no fallback: there is + /// nothing left to name the unit after. + #[test] + fn deploy_refuses_a_key_it_cannot_derive_an_identity_from() { + let mut request = request(); + for bad in ["not-an-nsec", "nsec1clearlynotvalid"] { + request["agent"]["private_key_nsec"] = serde_json::json!(bad); + let error = rejection(&request); + assert!( + error.contains("private_key_nsec"), + "accepted {bad:?} with error {error}" + ); + assert!(!error.contains(bad), "error echoed the key: {error}"); + } + } + #[test] fn redeploy_is_idempotent() { let agent = Agent::from_request(&request()).unwrap(); diff --git a/crates/buzz-backend-ssh/src/identity.rs b/crates/buzz-backend-ssh/src/identity.rs new file mode 100644 index 00000000000..bf08257c72e --- /dev/null +++ b/crates/buzz-backend-ssh/src/identity.rs @@ -0,0 +1,197 @@ +//! Deriving the agent's public identity from the key it was minted with. +//! +//! `docs/remote-agents.md` §Deploy, **Step 0 — derive and verify identity**: +//! +//! > The payload carries the nsec, not the pubkey. Before any substrate read or +//! > mutation, the provider MUST parse `private_key_nsec` and derive the public +//! > key from it; a malformed or undecodable key is an immediate in-band error. +//! > Every selector, name, and comparison below uses the *derived* pubkey — +//! > never a caller-supplied one. +//! +//! That is the strict reading, and it is the one this module implements. The +//! payload's `pubkey` field is retained on the wire, but it is demoted from +//! *identity* to *assertion*: [`derive_pubkey`] produces the identity, and a +//! payload pubkey that disagrees with it is a fatal in-band error rather than a +//! value that wins. The reason the field cannot simply be trusted is the same +//! reason the spec says "never a caller-supplied one": the unit slug, the env +//! file name and `backend_agent_id` are all keyed on it, so a payload whose +//! pubkey and nsec disagree would name a unit after one identity and run it +//! under another — an agent that looks deployed and is permanently unreachable, +//! which is exactly the failure the fail-closed nsec check already guards. +//! +//! Key material is handled as `Zeroizing` throughout: the bech32 decode yields +//! the 32 secret bytes, and they are wiped when this module is done with them. + +use bech32::primitives::decode::CheckedHrpstring; +use bech32::Bech32; +use secp256k1::{Secp256k1, SecretKey}; +use zeroize::Zeroizing; + +use crate::protocol::Secret; + +/// The human-readable part of a Nostr private key (NIP-19). +const NSEC_HRP: &str = "nsec"; + +/// Derive the agent's 64-character lowercase hex x-only public key from its +/// bech32 `nsec`. +/// +/// The error strings are deliberately shape-only. A decode failure must not +/// echo any part of the key back into a response the desktop persists in +/// `last_error`. +pub fn derive_pubkey(nsec: &Secret) -> Result { + let raw = nsec.expose().trim(); + + let checked = CheckedHrpstring::new::(raw).map_err(|_| { + "'private_key_nsec' is not a valid bech32 Nostr private key: the agent's identity \ + cannot be derived from it" + .to_string() + })?; + if checked.hrp().to_lowercase() != NSEC_HRP { + return Err(format!( + "'private_key_nsec' has bech32 prefix '{}', expected '{NSEC_HRP}': the payload \ + carries the wrong kind of key", + checked.hrp().to_lowercase() + )); + } + + let bytes = Zeroizing::new(checked.byte_iter().collect::>()); + if bytes.len() != 32 { + return Err(format!( + "'private_key_nsec' decodes to {} bytes, expected 32", + bytes.len() + )); + } + + let secret = SecretKey::from_byte_array( + <[u8; 32]>::try_from(bytes.as_slice()).expect("length checked above"), + ) + .map_err(|_| { + "'private_key_nsec' decodes to bytes that are not a valid secp256k1 secret key".to_string() + })?; + + let (x_only, _parity) = secret.x_only_public_key(&Secp256k1::new()); + Ok(hex_lower(&x_only.serialize())) +} + +/// Reconcile the derived identity against a pubkey the payload also carried. +/// +/// The derived value always wins — this never returns the asserted one. A +/// mismatch is fatal because the two identities would be used for different +/// things: the slug and every host-side name would follow the assertion while +/// the running harness authenticated as the derived key. +pub fn reconcile(derived: String, asserted: Option<&str>) -> Result { + match asserted { + Some(claimed) if claimed.to_lowercase() != derived => Err(format!( + "deploy payload's 'pubkey' does not match the identity derived from \ + 'private_key_nsec': the unit would be named for {} while the harness ran as {}. \ + Refusing to deploy an agent no desktop surface could reach.", + fragment(&claimed.to_lowercase()), + fragment(&derived) + )), + _ => Ok(derived), + } +} + +/// A short, non-secret prefix of a public key, for error messages that must +/// distinguish two identities without printing two 64-character strings. +fn fragment(pubkey: &str) -> String { + let end = pubkey + .char_indices() + .nth(12) + .map_or(pubkey.len(), |(i, _)| i); + format!("{}…", &pubkey[..end]) +} + +fn hex_lower(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// NIP-19 test vector: the nsec for the all-`0x01` secret key, and the + /// x-only public key secp256k1 derives from it. Fixed so a change in how + /// the key is decoded cannot silently move every agent's unit slug. + const NSEC: &str = "nsec1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqstywftw"; + const PUBKEY: &str = "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"; + + fn secret(value: &str) -> Secret { + serde_json::from_value(serde_json::json!(value)).unwrap() + } + + #[test] + fn a_valid_nsec_derives_its_known_pubkey() { + assert_eq!(derive_pubkey(&secret(NSEC)).unwrap(), PUBKEY); + } + + #[test] + fn derivation_is_stable_across_calls_and_surrounding_whitespace() { + let first = derive_pubkey(&secret(NSEC)).unwrap(); + let padded = derive_pubkey(&secret(&format!(" {NSEC}\n"))).unwrap(); + assert_eq!(first, padded); + assert_eq!(first.len(), 64); + assert!(first + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())); + } + + #[test] + fn a_malformed_nsec_is_rejected() { + for bad in [ + "", + "not-bech32-at-all", + // Valid bech32 shape, wrong checksum. + "nsec1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqstywftx", + // Hex, not bech32 — the shape a caller might paste by mistake. + "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + ] { + let error = derive_pubkey(&secret(bad)).unwrap_err(); + assert!( + error.contains("private_key_nsec"), + "{bad:?} produced {error:?}" + ); + assert!( + !error.contains(bad) || bad.is_empty(), + "error echoed the key" + ); + } + } + + #[test] + fn an_npub_is_rejected_as_the_wrong_kind_of_key() { + // A public key in the private key's field: correct bech32, wrong hrp. + let npub = "npub1rwzv24nmzfjypx2a8m264ws9vht3uxp5vpypnluuzl67n4waq78suk0wul"; + let error = derive_pubkey(&secret(npub)).unwrap_err(); + assert!(error.contains("expected 'nsec'"), "{error}"); + } + + #[test] + fn reconcile_accepts_a_matching_assertion_and_returns_the_derived_value() { + let derived = derive_pubkey(&secret(NSEC)).unwrap(); + assert_eq!( + reconcile(derived.clone(), Some(PUBKEY)).unwrap(), + derived, + "the derived value is the identity" + ); + // Case is not identity: the desktop mints lowercase hex, but an + // uppercase assertion still describes the same key. + assert_eq!( + reconcile(derived.clone(), Some(&PUBKEY.to_uppercase())).unwrap(), + derived + ); + // No assertion at all is fine — the nsec is sufficient. + assert_eq!(reconcile(derived.clone(), None).unwrap(), derived); + } + + #[test] + fn reconcile_rejects_a_mismatched_assertion() { + let derived = derive_pubkey(&secret(NSEC)).unwrap(); + let other = "f".repeat(64); + let error = reconcile(derived, Some(&other)).unwrap_err(); + assert!(error.contains("does not match"), "{error}"); + // Both identities are named, neither in full. + assert!(error.contains("ffffffffffff…"), "{error}"); + assert!(error.contains("1b84c5567b12…"), "{error}"); + } +} diff --git a/crates/buzz-backend-ssh/src/main.rs b/crates/buzz-backend-ssh/src/main.rs index 2f53facba81..3b1ff40c397 100644 --- a/crates/buzz-backend-ssh/src/main.rs +++ b/crates/buzz-backend-ssh/src/main.rs @@ -22,6 +22,7 @@ mod deploy; mod discover; +mod identity; mod install; mod protocol; mod ssh; diff --git a/docs/remote-agents-ssh.md b/docs/remote-agents-ssh.md index e07bb4d09f3..06fe06519ad 100644 --- a/docs/remote-agents-ssh.md +++ b/docs/remote-agents-ssh.md @@ -384,7 +384,11 @@ These are properties of the code, not conventions to uphold. a damaged or interrupted push leaves nothing runnable behind. - **A deploy without the minted nsec fails closed.** An agent that mints its own key on the host looks deployed and is permanently unreachable: presence, mentions, `!shutdown`, badges and the - NIP-OA auth tag all key off the pubkey the desktop minted. + NIP-OA auth tag all key off the pubkey the desktop minted. The same is true of an nsec that + cannot be decoded: the identity is derived from it (spec §Deploy Step 0), so an unusable key + leaves nothing to key the deploy on. +- **The identity is derived, not asserted.** Every host-side name comes from the pubkey computed + from the nsec; a payload `pubkey` that disagrees with it is refused rather than believed. - **A deploy without the harness pin fails closed.** The pin is the only channel by which the harness choice reaches the host. A blank one would fall through to `buzz-agent`, silently provisioning a harness the user never chose, so it is refused rather than substituted. @@ -448,12 +452,28 @@ WantedBy=default.target are escaped on the way in, since systemd unquotes C-style escapes inside double quotes. The instance name is the agent name made unit-safe — lowercased, non-alphanumerics collapsed to `-`, -truncated to 32 characters — followed by the first 12 hex characters of the agent's `pubkey`. The +truncated to 32 characters — followed by the first 12 hex characters of the agent's pubkey. The name is the readable half; **the pubkey fragment is the identity**. A display name is not unique: two agents called "Research Bot" on one SSH account keyed on the name alone shared one unit, one env file and one `agent_id`, so the second deploy overwrote the first agent's minted nsec and starting -either record drove whichever identity was written last. `deploy` therefore refuses a payload whose -`agent.pubkey` is absent or is not a 64-character hex key, rather than falling back to the name. +either record drove whichever identity was written last. + +**That fragment comes from a pubkey this provider derives, never one the payload asserts.** Spec +§Deploy Step 0 is explicit — "the provider MUST parse `private_key_nsec` and derive the public key +from it ... Every selector, name, and comparison below uses the *derived* pubkey — never a +caller-supplied one" — and `identity::derive_pubkey` implements exactly that: bech32-decode the +nsec, reject anything that is not a 32-byte `nsec1…`, and take the secp256k1 x-only public key. An +undecodable key is an immediate in-band `{"ok": false, …}`, because with no derivable identity +there is nothing left to name the unit after. + +The payload still carries `agent.pubkey`, but it is demoted from identity to **assertion**. When +present it is shape-checked and reconciled against the derived value, and a well-formed pubkey for +a *different* key is fatal rather than authoritative: honoring it would name the unit, the env file +and `backend_agent_id` for one identity while the harness authenticated to the relay as another — +the same permanently-unreachable agent the fail-closed nsec check already refuses to create. When +absent it costs nothing; the nsec alone is sufficient. Neither the derived nor the asserted key is +ever printed in full in an error — both are truncated to a 12-character fragment, since the whole +response is persisted in the desktop's `last_error`. **A host provisioned before this rule keeps its old units.** The instance name changed, so a redeploy provisions a new unit alongside the name-keyed one rather than replacing it, and the old From 407929ea360b663d2e87966c051513d1c5824c61 Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Sun, 2 Aug 2026 13:41:18 +0200 Subject: [PATCH 11/13] feat(backend-ssh): allow a non-default known_hosts file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requested on PR #3449: a deploying user whose host keys do not live at ~/.ssh/known_hosts — a shared or generated file, or one kept per-fleet rather than per-user — had no way to point the provider at it, and copying entries across is not a fix. Optional ssh_known_hosts_file in SshConfig and the info config_schema, forwarded as `-o UserKnownHostsFile=`. It selects which file the host key is checked against and nothing else: StrictHostKeyChecking is untouched, so a key missing from the named file fails exactly as one missing from the default would. This is not a way to relax host-key trust. Left unset, the option is not passed at all — the test asserts the argv is byte-identical to the pre-existing one, since an optional field that perturbs the default connection is not optional. Named to survive the desktop's validate_provider_config, which word-splits a config key and silently drops anything containing `key`. The obvious ssh_host_key_file spelling would arrive as absent with no error anywhere, the same trap that made the identity field ssh_identity_file rather than ssh_key_path; both names are now pinned by the schema test. Signed-off-by: Troy Hoffman --- crates/buzz-backend-ssh/src/protocol.rs | 21 +++++++- crates/buzz-backend-ssh/src/ssh.rs | 68 +++++++++++++++++++++++++ docs/remote-agents-ssh.md | 17 ++++++- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/crates/buzz-backend-ssh/src/protocol.rs b/crates/buzz-backend-ssh/src/protocol.rs index 338966f6a13..2e2ddd94574 100644 --- a/crates/buzz-backend-ssh/src/protocol.rs +++ b/crates/buzz-backend-ssh/src/protocol.rs @@ -138,6 +138,16 @@ pub struct SshConfig { pub user: Option, pub port: Option, pub identity_file: Option, + /// A `UserKnownHostsFile` for this connection, when the deploying user's + /// host keys do not live at `~/.ssh/known_hosts`. + /// + /// Optional, and absent it changes nothing: `ssh` reads its own default, + /// so the argv is byte-identical to what it has always been. Named + /// `ssh_known_hosts_file` for the same reason `identity_file` is not + /// `ssh_key_path` — the desktop's `validate_provider_config` word-splits + /// the key and drops anything containing `key`, so `ssh_host_key_file` + /// would vanish silently on the way in. + pub known_hosts_file: Option, pub buzz_acp_path: Option, } @@ -183,6 +193,7 @@ impl SshConfig { user: string("ssh_user"), port, identity_file: string("ssh_identity_file"), + known_hosts_file: string("ssh_known_hosts_file"), buzz_acp_path: string("buzz_acp_path"), }) } @@ -248,6 +259,11 @@ pub fn info_response() -> serde_json::Value { "title": "SSH identity file (optional)", "description": "Defaults to your ~/.ssh/config and agent", }, + "ssh_known_hosts_file": { + "type": "string", + "title": "SSH known hosts file (optional)", + "description": "Defaults to your ~/.ssh/known_hosts", + }, "buzz_acp_path": { "type": "string", "title": "buzz-acp path on the server (optional)", @@ -302,14 +318,17 @@ mod tests { let info = info_response(); let properties = info["config_schema"]["properties"].as_object().unwrap(); assert!(properties.contains_key("ssh_identity_file")); + assert!(properties.contains_key("ssh_known_hosts_file")); for key in properties.keys() { assert!( !desktop_rejects_key(key), "config key {key:?} would be rejected by validate_provider_config" ); } - // The name this field must never have. + // The names these fields must never have: both would be word-split into + // a forbidden `key` and dropped by the desktop with no error anywhere. assert!(desktop_rejects_key("ssh_key_path")); + assert!(desktop_rejects_key("ssh_host_key_file")); } /// The desktop's pre-secret gate rejects an absent or non-integer diff --git a/crates/buzz-backend-ssh/src/ssh.rs b/crates/buzz-backend-ssh/src/ssh.rs index b869a07afe2..bf96b7ebe5d 100644 --- a/crates/buzz-backend-ssh/src/ssh.rs +++ b/crates/buzz-backend-ssh/src/ssh.rs @@ -99,6 +99,14 @@ impl Session { args.push("-i".into()); args.push(identity.clone()); } + // Only when configured. Absent, `ssh` reads its own default + // (`~/.ssh/known_hosts`) and the argv is byte-identical to what it was + // before this option existed — the host-key policy above is unchanged + // either way, this only says *which file* it is checked against. + if let Some(known_hosts) = &config.known_hosts_file { + args.push("-o".into()); + args.push(format!("UserKnownHostsFile={known_hosts}")); + } args.push("--".into()); args.push(config.target()); // The remote argv, in full. Everything else arrives on stdin. @@ -306,10 +314,70 @@ mod tests { user: Some("ubuntu".into()), port: Some(2222), identity_file: Some("/home/me/.ssh/id_ed25519".into()), + known_hosts_file: None, buzz_acp_path: None, } } + /// Set, it becomes one `-o UserKnownHostsFile=…`. Unset, the argv must be + /// **byte-identical** to what it was before the option existed — an + /// optional field that perturbs the default connection is not optional. + #[test] + fn a_known_hosts_file_is_forwarded_only_when_configured() { + let Ok(default) = Session::new(&config("vps"), false) else { + return; // no ssh client in this environment + }; + assert!( + !default + .args + .iter() + .any(|a| a.contains("UserKnownHostsFile")), + "{:?}", + default.args + ); + + let mut with_file = config("vps"); + with_file.known_hosts_file = Some("/home/me/.ssh/known_hosts.buzz".into()); + let Ok(session) = Session::new(&with_file, false) else { + return; + }; + assert!(session + .args + .windows(2) + .any(|w| w == ["-o", "UserKnownHostsFile=/home/me/.ssh/known_hosts.buzz"])); + + // The only difference is that one pair — the host-key policy, batch + // mode, port and identity are untouched. + let added: Vec<_> = session + .args + .iter() + .filter(|a| !default.args.contains(a)) + .collect(); + assert_eq!( + added, + vec!["UserKnownHostsFile=/home/me/.ssh/known_hosts.buzz"] + ); + } + + /// The desktop drops a config key whose word-split contains `key`, so the + /// obvious `ssh_host_key_file` spelling would arrive as `None` with no + /// error anywhere. Pinned here as well as in the schema test, because this + /// is the field the name protects. + #[test] + fn the_known_hosts_field_is_read_from_the_name_the_desktop_forwards() { + let request = serde_json::json!({ + "provider_config": { + "ssh_host": "vps", + "ssh_known_hosts_file": "/etc/ssh/ssh_known_hosts", + } + }); + let parsed = SshConfig::from_request(&request).unwrap(); + assert_eq!( + parsed.known_hosts_file.as_deref(), + Some("/etc/ssh/ssh_known_hosts") + ); + } + #[test] fn every_invocation_is_batch_mode_with_the_script_on_stdin() { let Ok(session) = Session::new(&config("vps"), false) else { diff --git a/docs/remote-agents-ssh.md b/docs/remote-agents-ssh.md index 06fe06519ad..99ecbd15472 100644 --- a/docs/remote-agents-ssh.md +++ b/docs/remote-agents-ssh.md @@ -288,7 +288,9 @@ already the retry, and it is the same button every other host failure offers. `validate_provider_config` rejects any config key whose word-split contains `secret`/`password`/`token`/`key`/`credential`, and drops it silently. That is why the identity -field is `ssh_identity_file` and not `ssh_key_path`. +field is `ssh_identity_file` and not `ssh_key_path`, and why the host-key file is +`ssh_known_hosts_file` and not `ssh_host_key_file` — the latter splits into a forbidden `key` and +would arrive as absent with no error anywhere. | key | required | notes | |---|---|---| @@ -296,8 +298,16 @@ field is `ssh_identity_file` and not `ssh_key_path`. | `ssh_user` | no | Ignored when `ssh_host` already contains `@`. | | `ssh_port` | no | Number or numeric string, `1..=65535`. Default 22. | | `ssh_identity_file` | no | Passed as `ssh -i`. Defaults to `~/.ssh/config` and the agent. | +| `ssh_known_hosts_file` | no | Passed as `ssh -o UserKnownHostsFile=`. Defaults to `~/.ssh/known_hosts`. | | `buzz_acp_path` | no | Absolute path to `buzz-acp` on the host. Defaults to whatever is on the host's PATH. | +`ssh_known_hosts_file` is for a deploying user whose host keys do not live at the default path — a +shared or generated `known_hosts`, or one kept per-fleet rather than per-user. It selects *which +file* the host key is checked against and nothing else: `StrictHostKeyChecking` is unchanged, so a +key missing from the named file fails exactly as one missing from the default would. Left unset the +option is not passed at all and the `ssh` argv is byte-identical to what it was before the field +existed, which is what makes it safe to add to every existing record. + There is no `unit_scope`. All deploys are `systemctl --user`. The `oneOf` is a **generic decoration, not an SSH feature**. Any provider may attach @@ -671,7 +681,10 @@ the public key to `~/.ssh/authorized_keys`, or `tailscale set --ssh` on the host **`Host key verification failed`.** The host key is not in `known_hosts`, and `BatchMode` cannot prompt to accept it. Connect once with `ssh` by hand to review and accept the key. This is expected -for any manually typed address; tailnet peers are exempt. +for any manually typed address; tailnet peers are exempt. If the key *is* on record but in a file +other than `~/.ssh/known_hosts`, name that file in `ssh_known_hosts_file` rather than copying the +entry across — the provider passes it as `-o UserKnownHostsFile=` and checks the key exactly +as strictly there. **The device dropdown disappeared.** `tailscale status --json` no longer reports `BackendState: "Running"` — most often a logged-out daemon, which exits 0 with From 25dff780f19e88f9bbbd23db9e6f9622dc98eb71 Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Sun, 2 Aug 2026 13:48:21 +0200 Subject: [PATCH 12/13] fix(backend-ssh): drop the expect() from identity derivation and erase the key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of the Step 0 work found three things worth correcting. The length check was `if bytes.len() != 32` followed by `try_from(...).expect("length checked above")` — a panic in a production path, which this repo forbids, and one whose safety depended on two separate statements staying in sync. Taking `try_from` as the fallible conversion makes the panic-free property structural instead of asserted. secp256k1::SecretKey does not zeroize on drop; it exposes an opt-in `non_secure_erase`, which this now calls. The module doc previously implied guaranteed erasure — it is corrected to what the code actually achieves, which is best-effort hygiene on a value that lives for microseconds. Overclaiming in a comment about key handling is worse than not commenting. `fragment` slices by byte index and is only safe because `char_indices` yields char boundaries. It is reached with hex today, but a helper must not depend on its caller's validation to avoid panicking, so a test pins it against multi-byte input. Signed-off-by: Troy Hoffman --- crates/buzz-backend-ssh/src/identity.rs | 55 +++++++++++++++++++------ 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/crates/buzz-backend-ssh/src/identity.rs b/crates/buzz-backend-ssh/src/identity.rs index bf08257c72e..b9b9e5821fe 100644 --- a/crates/buzz-backend-ssh/src/identity.rs +++ b/crates/buzz-backend-ssh/src/identity.rs @@ -19,8 +19,15 @@ //! under another — an agent that looks deployed and is permanently unreachable, //! which is exactly the failure the fail-closed nsec check already guards. //! -//! Key material is handled as `Zeroizing` throughout: the bech32 decode yields -//! the 32 secret bytes, and they are wiped when this module is done with them. +//! Every buffer this module owns holds the decoded key as `Zeroizing`, so the +//! 32 secret bytes are wiped when derivation finishes rather than left on the +//! stack. `secp256k1::SecretKey` does *not* erase itself on drop — it exposes +//! `non_secure_erase` instead — so this module calls that explicitly before +//! the key goes out of scope. What remains is the by-value copy +//! `SecretKey::from_byte_array` takes, which no caller can reach; the crate's +//! own documentation is candid that fully preventing such copies is not +//! something a library can promise. This is best-effort hygiene on a value +//! that lives for microseconds, not a claim of guaranteed erasure. use bech32::primitives::decode::CheckedHrpstring; use bech32::Bech32; @@ -54,22 +61,26 @@ pub fn derive_pubkey(nsec: &Secret) -> Result { )); } + // `try_from` is the length check: taking it as the fallible conversion + // rather than checking `len()` and then unwrapping keeps the panic-free + // property structural instead of dependent on the two staying in sync. let bytes = Zeroizing::new(checked.byte_iter().collect::>()); - if bytes.len() != 32 { - return Err(format!( - "'private_key_nsec' decodes to {} bytes, expected 32", - bytes.len() - )); - } - - let secret = SecretKey::from_byte_array( - <[u8; 32]>::try_from(bytes.as_slice()).expect("length checked above"), - ) - .map_err(|_| { + let bytes: Zeroizing<[u8; 32]> = <[u8; 32]>::try_from(bytes.as_slice()) + .map(Zeroizing::new) + .map_err(|_| { + format!( + "'private_key_nsec' decodes to {} bytes, expected 32", + bytes.len() + ) + })?; + + let mut secret = SecretKey::from_byte_array(*bytes).map_err(|_| { "'private_key_nsec' decodes to bytes that are not a valid secp256k1 secret key".to_string() })?; let (x_only, _parity) = secret.x_only_public_key(&Secp256k1::new()); + // `SecretKey` has no zeroizing Drop; this is the crate's own opt-in. + secret.non_secure_erase(); Ok(hex_lower(&x_only.serialize())) } @@ -184,6 +195,24 @@ mod tests { assert_eq!(reconcile(derived.clone(), None).unwrap(), derived); } + /// `fragment` slices, and the only thing making that safe is that + /// `char_indices` yields char boundaries. `deploy` shape-checks an + /// assertion to hex before it gets here, but this function must not depend + /// on a caller's validation to avoid panicking. + #[test] + fn fragmenting_never_splits_a_character() { + for value in [ + "", + "abc", + "é".repeat(40).as_str(), + "🔑🔑🔑🔑🔑🔑🔑🔑🔑🔑🔑🔑🔑🔑", + ] { + let fragment = fragment(value); + assert!(fragment.ends_with('…'), "{fragment}"); + assert!(value.starts_with(fragment.trim_end_matches('…'))); + } + } + #[test] fn reconcile_rejects_a_mismatched_assertion() { let derived = derive_pubkey(&secret(NSEC)).unwrap(); From 7b4c14f6fb299cba81aa4c23208da6fd826b5b61 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sun, 16 Aug 2026 11:14:58 -0400 Subject: [PATCH 13/13] fix(backend-ssh): consume resolved launch payload Signed-off-by: Fredrik Jonsson --- Cargo.lock | 2 +- crates/buzz-backend-ssh/src/deploy.rs | 141 ++++++++++++++++++++++++-- docs/remote-agents-ssh.md | 58 ++++------- 3 files changed, 152 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be977096cd9..d59174f7426 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -970,7 +970,7 @@ name = "buzz-backend-ssh" version = "0.1.0" dependencies = [ "base64 0.22.1", - "bech32", + "bech32 0.11.1", "secp256k1 0.31.1", "serde", "serde_json", diff --git a/crates/buzz-backend-ssh/src/deploy.rs b/crates/buzz-backend-ssh/src/deploy.rs index da1b93ee51e..9429200cb8f 100644 --- a/crates/buzz-backend-ssh/src/deploy.rs +++ b/crates/buzz-backend-ssh/src/deploy.rs @@ -69,6 +69,12 @@ pub struct Agent { pub respond_to: String, pub respond_to_allowlist: Vec, pub env_vars: BTreeMap, + /// Desktop-resolved behavior defaults, applied before [`Self::env_vars`]. + pub policy_env: BTreeMap, + /// Resolved workspace owner used when an older record has no auth tag. + pub owner_pubkey: Option, + /// Whether command, args and env came from the normative `launch` block. + pub resolved_launch: bool, /// A path on the **desktop** machine to a Linux `buzz-acp` to install on /// the host when the host resolves none. Optional, and absent it changes /// nothing: deploy resolves `buzz-acp` on the host or fails with exit 90 @@ -121,10 +127,21 @@ impl Agent { // it verbatim. A blank value means the pin was lost on the way, and the // host would silently run `buzz-agent` instead of the harness the user // picked, so refuse rather than substitute. - let agent_command = string("agent_command").ok_or( - "deploy payload carries no 'agent_command': the harness pin was lost before it \ + let launch = agent.get("launch").and_then(serde_json::Value::as_object); + let launch_string = |key: &str| { + launch + .and_then(|value| value.get(key)) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }; + let agent_command = launch_string("command") + .or_else(|| string("agent_command")) + .ok_or( + "deploy payload carries no 'agent_command': the harness pin was lost before it \ reached the host (see instanceInputForDefinition provider branch)", - )?; + )?; // Step 0 of the reconciliation loop (docs/remote-agents.md §Deploy): // "the payload carries the nsec, not the pubkey ... the provider MUST @@ -153,20 +170,31 @@ impl Agent { crate::identity::derive_pubkey(&private_key_nsec)?, asserted_pubkey.as_deref(), )?; + let auth_tag = string("auth_tag"); + let owner_pubkey = launch_string("owner_pubkey"); + if launch.is_some() && auth_tag.is_none() && owner_pubkey.is_none() { + return Err( + "deploy payload has neither an auth tag nor launch.owner_pubkey; owner-only control would be unavailable" + .to_string(), + ); + } Ok(Self { name: string("name").ok_or("'name' is required")?, pubkey, relay_url: string("relay_url").ok_or("'relay_url' is required")?, private_key_nsec, - auth_tag: string("auth_tag"), + auth_tag, agent_command, // `agent_args` must be the remote entry's default args. The // desktop's local branch sends `[]` on purpose so spawn re-resolves // them live, but a provider-backed record never spawns locally, so // `[]` here would mean "no args" for any harness the local // default-args table does not know. - agent_args: crate::discover::string_list(agent.get("agent_args")), + agent_args: launch + .and_then(|value| value.get("args")) + .map(|value| crate::discover::string_list(Some(value))) + .unwrap_or_else(|| crate::discover::string_list(agent.get("agent_args"))), system_prompt: string("system_prompt"), model: string("model"), provider: string("provider"), @@ -186,7 +214,16 @@ impl Agent { .unwrap_or(1), respond_to: string("respond_to").unwrap_or_else(|| "owner-only".to_string()), respond_to_allowlist: crate::discover::string_list(agent.get("respond_to_allowlist")), - env_vars: env_map(agent.get("env_vars")), + env_vars: launch + .and_then(|value| value.get("env")) + .map(|value| env_map(Some(value))) + .unwrap_or_else(|| env_map(agent.get("env_vars"))), + policy_env: launch + .and_then(|value| value.get("policy_env")) + .map(|value| env_map(Some(value))) + .unwrap_or_default(), + owner_pubkey, + resolved_launch: launch.is_some(), // Read from the same `agent` block as everything else, but neither // is agent configuration: nothing about them reaches the env file // or the unit. They are the desktop handing the provider copies of @@ -296,6 +333,55 @@ fn env_file_body(agent: &Agent) -> Result { // and mirroring that table here would drift. Empty rather than omitted, // matching what local spawn writes when it does not apply. push("BUZZ_ACP_MCP_COMMAND", "")?; + if agent.resolved_launch { + // The desktop is the single source of truth for runtime metadata and + // six-layer env resolution. Preserve its precedence exactly: + // policy defaults first, then layered/user env. + for (key, value) in &agent.policy_env { + if !is_well_formed_env_key(key) { + return Err(format!("env var name '{key}' is not a valid identifier")); + } + if key != "BUZZ_ACP_AGENTS" + && RESERVED_ENV_KEYS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(key)) + { + return Err(format!("policy env var '{key}' is reserved")); + } + push(key, value)?; + } + for (key, value) in &agent.env_vars { + if !is_well_formed_env_key(key) { + return Err(format!("env var name '{key}' is not a valid identifier")); + } + if RESERVED_ENV_KEYS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(key)) + { + return Err(format!( + "env var '{key}' is reserved and cannot be overridden" + )); + } + push(key, value)?; + } + if let Some(owner_pubkey) = &agent.owner_pubkey { + push("BUZZ_ACP_AGENT_OWNER", owner_pubkey)?; + } + push("BUZZ_ACP_RESPOND_TO", &agent.respond_to)?; + if agent.respond_to == "allowlist" { + if agent.respond_to_allowlist.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist" + .to_string(), + ); + } + push( + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + &agent.respond_to_allowlist.join(","), + )?; + } + return Ok(body); + } // Lazy defers the *pool warm*, not the process: buzz-acp connects, // subscribes, and queues accepted work, then the first flushable event // wakes all `BUZZ_ACP_AGENTS` slots. Nothing is dropped, and the one-shot @@ -890,11 +976,13 @@ mod tests { 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()); + // This is a generated-script assertion rather than a host execution: + // the test runner itself may legitimately have a global `claude` + // binary, which is exactly the fallback this branch is meant to use. + assert!(script.contains("if [ -z \"$claude_cli\" ]")); + assert!(script.contains("Claude Code CLI not found")); + assert!(script.contains("exit 95")); } /// A Hermes per-profile pin, end to end through the deploy path. @@ -1011,6 +1099,39 @@ mod tests { assert!(!body.contains("GOOSE_PROVIDER")); } + #[test] + fn resolved_launch_is_authoritative_and_preserves_env_precedence() { + let mut request = request(); + request["agent"]["agent_command"] = serde_json::json!("stale-command"); + request["agent"]["agent_args"] = serde_json::json!(["stale-arg"]); + request["agent"]["env_vars"] = serde_json::json!({"SOURCE": "legacy"}); + request["agent"]["launch"] = serde_json::json!({ + "command": "openclaw", + "args": ["acp", "--resolved"], + "policy_env": { + "BUZZ_ACP_AGENTS": "5", + "OVERRIDABLE": "policy" + }, + "env": { + "OVERRIDABLE": "user", + "SOURCE": "launch" + }, + "owner_pubkey": "a".repeat(64) + }); + + let agent = Agent::from_request(&request).unwrap(); + assert_eq!(agent.agent_command, "openclaw"); + assert_eq!(agent.agent_args, ["acp", "--resolved"]); + + let body = env_file_body(&agent).unwrap(); + assert!(body.contains("BUZZ_ACP_AGENT_ARGS=\"acp,--resolved\"")); + assert!(body.contains("BUZZ_ACP_AGENTS=\"5\"")); + assert!(body.contains("SOURCE=\"launch\"")); + assert!(!body.contains("SOURCE=\"legacy\"")); + assert!(body.rfind("OVERRIDABLE=\"user\"") > body.rfind("OVERRIDABLE=\"policy\"")); + assert!(body.contains(&format!("BUZZ_ACP_AGENT_OWNER=\"{}\"", "a".repeat(64)))); + } + #[test] fn the_deprecated_turn_timeout_is_never_written() { // The payload still carries `turn_timeout_seconds` (upstream diff --git a/docs/remote-agents-ssh.md b/docs/remote-agents-ssh.md index 99ecbd15472..b7f3635269f 100644 --- a/docs/remote-agents-ssh.md +++ b/docs/remote-agents-ssh.md @@ -494,8 +494,13 @@ pilot host stop and remove the stale pair by hand: ## Env file contract -The local spawn contract from `runtime.rs`, transcribed. Values resolved on the host — the absolute -harness path, `git-credential-nostr`, `PATH` — are appended by the remote script. +The provider consumes the desktop-resolved `agent.launch` block, not the raw legacy command, +model, provider, or env fields. `launch.command` and `launch.args` select the remote harness; +`launch.policy_env` is written first and `launch.env` second, preserving the same override order +as local spawn. The provider then adds identity and access-control values plus values that must be +resolved on the host — the absolute harness path, `git-credential-nostr`, `PATH`, and the native +Claude launcher. Payloads without `launch` retain the legacy mapping for compatibility with older +Desktop versions. | var | value | |---|---| @@ -504,19 +509,13 @@ harness path, `git-credential-nostr`, `PATH` — are appended by the remote scri | `PATH` | the deploy script's own `$PATH`, which already leads with `$HOME/.local/bin`; **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) | -| `BUZZ_ACP_AGENT_ARGS` | comma-joined | +| `BUZZ_ACP_AGENT_ARGS` | comma-joined `launch.args` | | `BUZZ_ACP_MCP_COMMAND` | empty | -| `BUZZ_ACP_LAZY_POOL` | `true` — the pool warms on the first accepted event instead of at startup; queued work is not dropped, and a restarted or idle unit does not hold N harness subprocesses | -| `BUZZ_ACP_AGENTS` | payload `parallelism` | -| `BUZZ_ACP_MULTIPLE_EVENT_HANDLING` | `steer` | -| `BUZZ_ACP_DEDUP` | `queue` | -| `BUZZ_ACP_RELAY_OBSERVER` | `true` | +| `launch.policy_env` | desktop-resolved behavior defaults, including effective parallelism and lazy-pool policy | +| `launch.env` | desktop-resolved six-layer runtime/user env, written after policy defaults | | `BUZZ_ACP_RESPOND_TO` (+ `_ALLOWLIST`) | payload; `allowlist` mode with an empty list is refused | -| `BUZZ_ACP_SYSTEM_PROMPT`, `BUZZ_ACP_MODEL` | payload, omitted when empty | -| runtime model/provider env | payload `model` / `provider`, under the runtime's own names — see below | -| `BUZZ_ACP_IDLE_TIMEOUT`, `BUZZ_ACP_MAX_TURN_DURATION` | emitted only when set, so the harness's own defaults win | +| `BUZZ_ACP_AGENT_OWNER` | `launch.owner_pubkey` when present; required if no auth tag is available | | `NOSTR_PRIVATE_KEY`, `GIT_TERMINAL_PROMPT`, `GIT_CONFIG_*` | only when `git-credential-nostr` is on the host | -| user `env_vars` | written last, so they override — matching the local layering | **The `PATH` line is the remote half of the desktop's own PATH contract.** Local spawn prepends `/.local/bin` (and the bundled sidecar directory) to the spawned harness's `PATH` @@ -542,32 +541,15 @@ catalog as absent, and the deploy that follows refuses the pin with exit 91 on a adapter is installed and runnable. `scripts/provision-buzz-host.sh` does the same, so its report and the deploy's behavior cannot disagree. -**`BUZZ_ACP_RELAY_OBSERVER` is pinned `true`, but it only does anything once an owner resolves.** -Observer frames are addressed and encrypted to the owner's pubkey, so `buzz-acp` resolves one at -startup — from `BUZZ_AUTH_TAG` (the NIP-OA attestation, verified against the agent's own key) or -from `--agent-owner` — and logs `relay observer requested but no agent owner was resolved at -startup` and publishes nothing when it has neither. This is worth stating because dropping the -NIP-OA identity is the current workaround for the cross-machine mention gate (#3277, #2987): a -deploy with no `auth_tag` and `respond_to=allowlist` is a perfectly working agent that answers -mentions and pushes to repositories, and the only thing quietly missing is its observer telemetry. -Deploy does not warn, since a payload without an auth tag is a legitimate configuration; the -harness's own log is the place that says so. - -The runtime model/provider pair is the remote half of `runtime_metadata_env_vars`. `BUZZ_ACP_MODEL` -is what `buzz-acp` reads; these are what the harness underneath it reads, and local spawn writes -both. Without them a remote Goose would fall back to whatever `~/.config/goose/config.yaml` on the -host says, with the user's model pick silently ignored. - -| pinned command | model var | provider var | -|---|---|---| -| `goose` | `GOOSE_MODEL` | `GOOSE_PROVIDER` | -| `buzz-agent` | `BUZZ_AGENT_MODEL` | `BUZZ_AGENT_PROVIDER` | - -The lookup is keyed on the command's file name, so an absolute pin -(`/home/ubuntu/.local/bin/goose`) resolves to the same runtime — matching `known_acp_runtime` -locally. Runtimes absent from the table declare no such vars in `KNOWN_ACP_RUNTIMES` either: -Claude is `provider_locked`, and neither Claude nor Codex has a model env var. An unset payload -field writes no key at all. +**Observer ownership fails closed.** Observer frames are addressed and encrypted to the owner's +pubkey. A current Desktop supplies both `BUZZ_ACP_RELAY_OBSERVER=true` in `launch.policy_env` and +the resolved owner in `launch.owner_pubkey`; the provider writes the latter as +`BUZZ_ACP_AGENT_OWNER`. A launch payload with neither `auth_tag` nor `owner_pubkey` is refused. + +Runtime-specific model/provider variables are already resolved in `launch.env` by the Desktop's +runtime metadata table. The SSH provider deliberately does not maintain a second command-to-env +mapping: doing so would drift for provider-locked runtimes and could silently ignore a user's model +selection. Host paths remain the only runtime detail resolved by this provider. Claude has one additional executable binding. When the pinned harness is `claude-agent-acp` or `claude-code-acp`, deploy prefers the stable