diff --git a/Cargo.lock b/Cargo.lock index 6c46beedf2f..35ad04cc6c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -968,6 +968,19 @@ dependencies = [ "tower", ] +[[package]] +name = "buzz-backend-ssh" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "bech32 0.11.1", + "secp256k1 0.31.1", + "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 78816ff4827..32c299d04a6 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 9e471784275..64c6d461ab6 100644 --- a/Justfile +++ b/Justfile @@ -323,6 +323,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..e3b8ab05a88 --- /dev/null +++ b/crates/buzz-backend-ssh/Cargo.toml @@ -0,0 +1,39 @@ +[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 } +# 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/assets/buzz-acp@.service b/crates/buzz-backend-ssh/assets/buzz-acp@.service new file mode 100644 index 00000000000..bdd56c224cc --- /dev/null +++ b/crates/buzz-backend-ssh/assets/buzz-acp@.service @@ -0,0 +1,39 @@ +[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 +# 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 +# Absolute path, substituted at install time from the host's resolved +# `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 + +[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..f1d326407c6 --- /dev/null +++ b/crates/buzz-backend-ssh/src/deploy.rs @@ -0,0 +1,2320 @@ +//! `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", + "BUZZ_ACP_AGENTS", + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_ALLOWED_RESPOND_TO", + "BUZZ_ACP_AGENT_OWNER", + "BUZZ_ACP_DISPLAY_NAME", + "BUZZ_ACP_EXIT_AFTER_INACTIVITY", + "BUZZ_ACP_IDLE_POOL_SLEEP", + "BUZZ_ACP_NO_PRESENCE", + "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, + /// 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, + 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, + /// 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 + /// 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 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 + // 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." + // + // 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 = crate::identity::reconcile( + 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, + 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: 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"), + // `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: 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 + // 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 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 + .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 }; + // Hex and lowercased by `from_request`, so this is already unit-safe. + format!("{stem}-{}", &self.pubkey[..PUBKEY_FRAGMENT]) + } + + pub fn agent_id(&self) -> String { + format!("buzz-acp@{}", self.slug()) + } +} + +/// 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 + .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", "")?; + 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 + // 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()); + + // `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 + // *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` 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"); + // 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 -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"%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 + 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::*; + + /// 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 = "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!({ + "op": "deploy", + "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", + "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(); + + // 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. + /// + /// `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 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 + // `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", + "BUZZ_ACP_DISPLAY_NAME", + ] { + 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_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 two_agents_with_one_name_get_distinct_units() { + // 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 (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. + 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}")); + } + + /// 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_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"); + let derived = Agent::from_request(&request).unwrap(); + assert_eq!(derived.pubkey, PUBKEY); + assert_eq!(derived.slug(), format!("research-bot-{PUBKEY_SLUG}")); + + // 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("not a 64-character hex"), + "accepted {bad:?} with error {error}" + ); + } + + // 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(), + Agent::from_request(&self::request()).unwrap().slug() + ); + } + + /// 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(); + 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")); + // 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")); + 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(); + 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 +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 +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' "$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" +acp_unit=$(printf '%s' "$acp" | sed 's/[\\"]/\\&/g') +template=$(cat <<'BUZZ_UNIT_EOF' +{unit}BUZZ_UNIT_EOF +) +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 + 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 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 + // 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}" + ); + } + + /// `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}" + ); + } + + #[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")); + // 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)] + #[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..771e3ef6067 --- /dev/null +++ b/crates/buzz-backend-ssh/src/discover.rs @@ -0,0 +1,1353 @@ +//! `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: &[], + }, +]; + +/// 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. +/// +/// 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 +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 = 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 { + 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")); + + // The same preamble the discover script carries: this one `exec`s + // `buzz-acp`, which spawns the pinned harness by name, so both lookups need + // the install destination on `PATH`. + let mut script = script_preamble(); + // 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"#))); + } + + /// The reported bug, end to end: an adapter installed to `~/.local/bin` on + /// a host whose `PATH` is the stock non-interactive Debian one. + /// + /// Substring assertions cannot see this. The prepend has to run, resolve + /// `$HOME` on the *host*, and take effect before `probe` — and the payoff + /// is the resolved absolute path in the record, which is what the desktop + /// shows and what `deploy` later pins. + #[cfg(unix)] + #[test] + fn an_adapter_installed_to_local_bin_is_discovered() { + use std::os::unix::fs::PermissionsExt; + + let root = std::env::temp_dir().join(format!("buzz-local-bin-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let local_bin = root.join(".local/bin"); + std::fs::create_dir_all(&local_bin).unwrap(); + let adapter = local_bin.join("codex-acp"); + std::fs::write(&adapter, "#!/bin/sh\necho 'codex-acp 0.4.2'\n").unwrap(); + std::fs::set_permissions(&adapter, std::fs::Permissions::from_mode(0o755)).unwrap(); + + // Stock Debian's non-interactive PATH, verbatim. `/bin` is on it, so + // the script's own `sh` builtins and `head`/`tr` still resolve. + let output = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(discover_script(&config())) + .env_clear() + .env("HOME", &root) + .env("PATH", "/usr/local/bin:/usr/bin:/bin:/usr/games") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + let probe = parse_probes(&stdout) + .into_iter() + .find(|probe| probe.key == "codex") + .unwrap_or_else(|| panic!("codex-acp went undiscovered:\n{stdout}")); + assert_eq!(probe.path, adapter.to_str().unwrap()); + assert_eq!(probe.version, "codex-acp 0.4.2"); + + let _ = std::fs::remove_dir_all(&root); + } + + /// 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 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. +//! +//! 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; +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() + )); + } + + // `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::>()); + 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())) +} + +/// 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); + } + + /// `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(); + 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/install.rs b/crates/buzz-backend-ssh/src/install.rs new file mode 100644 index 00000000000..dabf613a75e --- /dev/null +++ b/crates/buzz-backend-ssh/src/install.rs @@ -0,0 +1,817 @@ +//! Installing the host-side tools from copies that live on the desktop. +//! +//! Deploy used to *verify* `buzz-acp` and fail with guidance when the host had +//! none. It now verifies **or installs**: when the deploy payload carries +//! `buzz_acp_binary` — a path on the desktop machine to a Linux `buzz-acp` — +//! and the host resolves none, the binary rides along inside the same script +//! that already carries the agent's identity and is installed to +//! `~/.local/bin/buzz-acp`. There is no second op, no provisioning step, and no +//! new UI state: install is an invisible, idempotent property of deploy. +//! +//! The same machinery ships a second tool: the **`buzz` CLI**. A local agent +//! gets it because the desktop bundles it as a sidecar and prepends +//! `~/.local/bin` and the bundle directory to the spawned harness's `PATH` +//! (`managed_agents::runtime::path::build_augmented_path`). A remote agent is +//! told by its own system prompt to answer with `buzz messages send +//! --reply-to …`, so a host without the CLI produces an agent that hunts the +//! filesystem for a command that is not there. [`CLI`] closes that gap: same +//! probe, same base64 transport, same sha256-before-`mv` rule, installed to +//! `~/.local/bin/buzz`, resolvable at runtime because the unit's env file pins +//! a `PATH` that leads with `~/.local/bin` (`deploy::deploy_script`). +//! +//! **The two tools differ on exactly one axis: what "the host has none, and the +//! desktop pushed none" means.** `buzz-acp` *is* the agent — no binary, no +//! unit worth starting — so it is fail-closed ([`Missing::Fatal`], exit 90). +//! The CLI makes an agent faster and more capable but nothing about the harness +//! depends on it, so its absence is a `WARNING:` line on stderr and the deploy +//! continues ([`Missing::Warn`]). Everything else — the encoding, the digest, +//! the atomic install, the push-when-missing staleness rule — is one shared +//! code path, because a second copy of it would be a second place to get the +//! integrity rules wrong. +//! +//! Integrity failures are fatal for **both** tools. A payload that fails to +//! decode or fails its digest is evidence that the stream itself was damaged — +//! and that stream is also carrying the minted nsec and the unit — so +//! continuing is not obviously safe, and "nothing is ever installed unverified" +//! stays one rule rather than two. Only the absent-payload case is asymmetric. +//! +//! The transport is the constraint that shapes everything here. The script +//! travels on the SSH stdin channel (`ssh.rs`) as text, so raw bytes cannot be +//! embedded: a NUL, a heredoc delimiter, or a stray newline in the middle of an +//! ELF section would corrupt the *script*, not just the payload. base64 makes +//! that impossible by construction — the encoded alphabet is +//! `A-Za-z0-9+/=`, which contains no shell metacharacter, no newline, and (the +//! detail that keeps the heredoc safe) no `_`, so no encoded line can collide +//! with a `BUZZ_…_B64_EOF` delimiter. +//! +//! **Resolution is `PATH` *or* `~/.local/bin/`,** never `PATH` alone. +//! A non-interactive SSH command reads no profile, so the install destination +//! 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 +//! 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 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 +/// 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 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) +} + +/// 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..3b1ff40c397 --- /dev/null +++ b/crates/buzz-backend-ssh/src/main.rs @@ -0,0 +1,162 @@ +//! `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 identity; +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..2e2ddd94574 --- /dev/null +++ b/crates/buzz-backend-ssh/src/protocol.rs @@ -0,0 +1,417 @@ +//! 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, + /// 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, +} + +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"), + known_hosts_file: string("ssh_known_hosts_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 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` +/// 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"), + "protocol_version": PROTOCOL_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", + }, + "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)", + "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")); + 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 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 + /// `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 + // 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..bf96b7ebe5d --- /dev/null +++ b/crates/buzz-backend-ssh/src/ssh.rs @@ -0,0 +1,546 @@ +//! 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()); + } + // 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. + 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()), + 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 { + 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")); + } + } +} 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, diff --git a/docs/remote-agents-ssh.md b/docs/remote-agents-ssh.md new file mode 100644 index 00000000000..b7f3635269f --- /dev/null +++ b/docs/remote-agents-ssh.md @@ -0,0 +1,715 @@ +# 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`, 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 | +|---|---|---| +| `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. | +| `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 +`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. 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. +- **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. + +**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 +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 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 | +|---|---| +| `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 `launch.args` | +| `BUZZ_ACP_MCP_COMMAND` | empty | +| `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_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 | + +**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. + +**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 +`~/.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. 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 +`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. diff --git a/scripts/provision-buzz-host.sh b/scripts/provision-buzz-host.sh new file mode 100755 index 00000000000..80994ea7711 --- /dev/null +++ b/scripts/provision-buzz-host.sh @@ -0,0 +1,292 @@ +#!/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 +# +# 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. +# +# 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. +# 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 + +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="" +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`, 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 + *":${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" "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" "OK" \ + "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. 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 +# 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" + +# ---- 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. +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 + +# ---- 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 — +# 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