diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4826d985f..f86c99438f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -968,6 +968,13 @@ jobs: # Serial: windows_resolver_tests mutate process-global env # (BUZZ_SHELL/GIT_BASH/SystemRoot) that SharedState::new reads. run: cargo test -p buzz-dev-mcp --target $env:TARGET -- --test-threads=1 + - name: Test (buzz-backend-ssh) + # The provider ships to a user PATH on every desktop platform, so its + # path resolution (tailscale.exe candidates, ssh.exe lookup) and JSON + # parsing only gate if the crate is tested ON Windows. Its shell-script + # execution tests are #[cfg(unix)] and cover the remote side, which is + # always POSIX. + run: cargo test -p buzz-backend-ssh --target $env:TARGET # Smoke-test the new host-prereq contract: Git for Windows (which provides # bash) is available on the runner, a shell command round-trips, and bash # does NOT resolve from System32 (so WSL's launcher is never picked up). diff --git a/Cargo.lock b/Cargo.lock index 3b60dc4579..930c34569b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -879,6 +879,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-backend-ssh" +version = "0.1.0" +dependencies = [ + "base64", + "serde", + "serde_json", + "sha2 0.11.0", + "zeroize", +] + [[package]] name = "buzz-cli" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3ac7ee4cce..852be6c72b 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 bcef8983bc..14b7c7bd31 100644 --- a/Justfile +++ b/Justfile @@ -291,6 +291,12 @@ test-unit: # Gateway unit and black-box HTTP tests are infra-free. Postgres-backed # contract/race tests run in the dedicated CI job below. cargo nextest run -p buzz-push-gateway + # 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 0000000000..ebc58b2627 --- /dev/null +++ b/crates/buzz-backend-ssh/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "buzz-backend-ssh" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Buzz backend provider that deploys managed agents to a remote host over SSH" + +# Deliberately binary-only and deliberately NOT bundled with the desktop app +# (not in `tauri.conf.json` externalBin, not in `scripts/bundle-sidecars.sh`). +# `discover_provider_candidates` prepends the app bundle's own directory to the +# provider search path, so shipping this inside the bundle would give every +# install an auto-discovered SSH-deploy capability and quietly undermine the +# "Only use providers from trusted sources" warning the desktop shows. It is a +# release artifact the user installs to `~/.local/bin`, which is already on the +# discovery path. +[[bin]] +name = "buzz-backend-ssh" +path = "src/main.rs" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +zeroize = { workspace = true } +# Deploy can install `buzz-acp` on the host by streaming it inside the script +# that already travels on the SSH stdin channel. base64 is what keeps raw bytes +# from corrupting that stream; sha2 is what lets the host refuse a payload that +# arrived damaged. Both are already workspace dependencies. +base64 = { workspace = true } +sha2 = { workspace = true } diff --git a/crates/buzz-backend-ssh/assets/buzz-acp@.service b/crates/buzz-backend-ssh/assets/buzz-acp@.service new file mode 100644 index 0000000000..fb1f7414b8 --- /dev/null +++ b/crates/buzz-backend-ssh/assets/buzz-acp@.service @@ -0,0 +1,24 @@ +[Unit] +Description=Buzz agent %i +After=network-online.target +Wants=network-online.target +# A long-running agent must never be rate-limited into staying down: a unit +# held by the start limiter looks exactly like an agent that silently died, +# and only `systemctl reset-failed` clears it. +StartLimitIntervalSec=0 + +[Service] +Type=simple +# Holds the agent's minted nsec. Written by the provider with umask 077 and +# chmod 600; systemd reads it as the owning user. +EnvironmentFile=%h/.config/buzz-acp/%i.env +# Absolute path, substituted at install time from the host's resolved +# `buzz-acp`. systemd does not expand environment variables in the program +# position, and the shell indirection that would work around that is not worth +# adding to a unit whose environment carries a private key. +ExecStart=@BUZZ_ACP_BIN@ +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/crates/buzz-backend-ssh/src/deploy.rs b/crates/buzz-backend-ssh/src/deploy.rs new file mode 100644 index 0000000000..39508829bc --- /dev/null +++ b/crates/buzz-backend-ssh/src/deploy.rs @@ -0,0 +1,1962 @@ +//! `deploy`: provision the agent as a `systemd --user` unit on the host. +//! +//! `--user` rather than a system unit keeps the flow root-free and puts the env +//! file beside the harness credentials that already live in the deploying +//! user's home (`~/.claude`, `~/.config/goose`). +//! +//! Deploy is also the *start* path — `start_managed_agent` re-enters +//! `deploy_to_provider` — so everything here must be idempotent. + +use std::collections::BTreeMap; +use std::time::Duration; + +use crate::install::{self, Payload, Tool}; +use crate::protocol::{Failure, Secret, SshConfig}; +use crate::ssh::{quote, Session}; + +/// The templated unit, installed once per host and instantiated per agent. +const UNIT_TEMPLATE: &str = include_str!("../assets/buzz-acp@.service"); + +/// Verbatim copy of the desktop's `env_vars::RESERVED_ENV_KEYS`. The desktop +/// already strips these from user env; re-checking here means a leak needs two +/// independent failures rather than one, and this binary ships and updates +/// separately from the desktop that fills the payload. +const RESERVED_ENV_KEYS: &[&str] = &[ + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", + "BUZZ_RELAY_URL", + "BUZZ_ACP_AGENT_COMMAND", + "BUZZ_ACP_AGENT_ARGS", + "BUZZ_ACP_MCP_COMMAND", + "CLAUDE_CODE_EXECUTABLE", + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_AGENT_OWNER", + "BUZZ_ACP_SETUP_PAYLOAD", + "BUZZ_MANAGED_AGENT", + "BUZZ_MANAGED_AGENT_START_NONCE", +]; + +/// The deploy payload, as `deploy_payload_json` serializes it. +/// +/// Deliberately not `Debug`. `Secret` redacts itself, but `env_vars` routinely +/// holds `ANTHROPIC_API_KEY` and friends in plain `String`s, so a derived +/// `Debug` would put provider credentials one `{:?}` away from a log line. +pub struct Agent { + pub name: String, + pub relay_url: String, + pub private_key_nsec: Secret, + pub auth_tag: Option, + /// The pinned harness command. See [`Agent::from_request`]. + pub agent_command: String, + pub agent_args: Vec, + pub system_prompt: Option, + pub model: Option, + pub provider: Option, + pub idle_timeout_seconds: Option, + pub max_turn_duration_seconds: Option, + pub parallelism: u64, + pub respond_to: String, + pub respond_to_allowlist: Vec, + pub env_vars: BTreeMap, + /// A path on the **desktop** machine to a Linux `buzz-acp` to install on + /// the host when the host resolves none. Optional, and absent it changes + /// nothing: deploy resolves `buzz-acp` on the host or fails with exit 90 + /// exactly as it always has. See [`crate::install`]. + pub buzz_acp_binary: Option, + /// The same, for the `buzz` CLI. A remote agent's own system prompt tells + /// it to answer with `buzz messages send --reply-to …`, and a local agent + /// gets that command because the desktop bundles the CLI and prepends + /// `~/.local/bin` to the spawned harness's `PATH`. This field is how the + /// remote side reaches the same parity. + /// + /// Unlike `buzz_acp_binary`, its absence on a host that has no CLI is a + /// warning rather than a failure: the harness runs without it. + pub buzz_cli_binary: Option, +} + +impl Agent { + pub fn from_request(request: &serde_json::Value) -> Result { + let agent = request.get("agent").ok_or("request is missing 'agent'")?; + let string = |key: &str| { + agent + .get(key) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) + }; + + let private_key_nsec: Secret = agent + .get("private_key_nsec") + .map(|v| serde_json::from_value(v.clone())) + .transpose() + .map_err(|_| "'private_key_nsec' must be a string".to_string())? + .unwrap_or_default(); + // Fail closed. A deploy that lets the host mint its own key produces an + // agent that looks deployed and is permanently unreachable: presence, + // mentions, `!shutdown`, badges and the NIP-OA auth tag all key off the + // pubkey the desktop minted. Mirrors the desktop's own + // `spawn_key_refusal`. + if private_key_nsec.is_empty() { + return Err( + "refusing to deploy without the agent's minted private key: the remote agent \ + would run under an identity no desktop surface recognizes" + .to_string(), + ); + } + + // The remote harness choice reaches the host ONLY as this pin — the + // desktop resolves it from the remote catalog at create time and ships + // it verbatim. A blank value means the pin was lost on the way, and the + // host would silently run `buzz-agent` instead of the harness the user + // picked, so refuse rather than substitute. + let agent_command = string("agent_command").ok_or( + "deploy payload carries no 'agent_command': the harness pin was lost before it \ + reached the host (see instanceInputForDefinition provider branch)", + )?; + + Ok(Self { + name: string("name").ok_or("'name' is required")?, + relay_url: string("relay_url").ok_or("'relay_url' is required")?, + private_key_nsec, + auth_tag: string("auth_tag"), + agent_command, + // `agent_args` must be the remote entry's default args. The + // desktop's local branch sends `[]` on purpose so spawn re-resolves + // them live, but a provider-backed record never spawns locally, so + // `[]` here would mean "no args" for any harness the local + // default-args table does not know. + agent_args: crate::discover::string_list(agent.get("agent_args")), + system_prompt: string("system_prompt"), + model: string("model"), + provider: string("provider"), + // `turn_timeout_seconds` is deliberately not read: the payload still + // carries it, but `BUZZ_ACP_TURN_TIMEOUT` is deprecated and ignored + // by the harness (`buzz-acp::config`), and local spawn does not + // write it either. `idle_timeout_seconds` and + // `max_turn_duration_seconds` are the live controls. + idle_timeout_seconds: agent.get("idle_timeout_seconds").and_then(|v| v.as_u64()), + max_turn_duration_seconds: agent + .get("max_turn_duration_seconds") + .and_then(|v| v.as_u64()), + parallelism: agent + .get("parallelism") + .and_then(|v| v.as_u64()) + .filter(|p| *p > 0) + .unwrap_or(1), + respond_to: string("respond_to").unwrap_or_else(|| "owner-only".to_string()), + respond_to_allowlist: crate::discover::string_list(agent.get("respond_to_allowlist")), + env_vars: env_map(agent.get("env_vars")), + // Read from the same `agent` block as everything else, but neither + // is agent configuration: nothing about them reaches the env file + // or the unit. They are the desktop handing the provider copies of + // the host-side tools to install if the host turns out not to have + // them. + buzz_acp_binary: string("buzz_acp_binary"), + buzz_cli_binary: string("buzz_cli_binary"), + }) + } + + /// The systemd instance name, and the `agent_id` the desktop persists in + /// `record.backend_agent_id`. + /// + /// It becomes both a filename and a unit instance name, so it follows the + /// desktop's own `util::slugify` rule. The hash suffix is not decoration: + /// the payload carries no stable agent identifier, and without it two + /// agents whose names differ only in punctuation would share one unit and + /// one env file. + pub fn slug(&self) -> String { + let sanitized: String = self + .name + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + // ASCII by construction, so a byte slice cannot split a character. + let stem = sanitized.trim_matches('-'); + let stem = &stem[..stem.len().min(32)]; + let stem = stem.trim_end_matches('-'); + let stem = if stem.is_empty() { "agent" } else { stem }; + format!("{stem}-{}", short_hash(&self.name)) + } + + pub fn agent_id(&self) -> String { + format!("buzz-acp@{}", self.slug()) + } +} + +/// FNV-1a, truncated. Only ever used to keep distinct names on distinct units. +fn short_hash(value: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in value.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{:08x}", hash as u32) +} + +pub fn env_map(value: Option<&serde_json::Value>) -> BTreeMap { + value + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(key, value)| Some((key.clone(), value.as_str()?.to_string()))) + .collect() + }) + .unwrap_or_default() +} + +/// A well-formed POSIX env var name. Mirrors the desktop's own boundary check; +/// a malformed key would let a value smuggle an extra assignment into the file, +/// or — on the left side of a shell `export`, where quoting cannot help — an +/// extra command. +pub fn is_well_formed_env_key(key: &str) -> bool { + !key.is_empty() + && !key.starts_with(|c: char| c.is_ascii_digit()) + && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// One `KEY="value"` line for a systemd `EnvironmentFile`. +/// +/// systemd unquotes C-style escapes inside double quotes, so `\` and `"` are +/// the two characters that must be escaped. Control characters are refused +/// outright: a newline would split the assignment into a second, attacker- +/// chosen line. +fn env_line(key: &str, value: &str) -> Result { + if value.chars().any(|c| c.is_control()) { + return Err(format!( + "env var '{key}' contains a control character and cannot be written to the unit's \ + environment file" + )); + } + let escaped = value.replace('\\', r"\\").replace('"', "\\\""); + Ok(format!("{key}=\"{escaped}\"\n")) +} + +/// The env file body: the local spawn contract from `runtime.rs`, transcribed. +/// +/// Values resolved on the host — the absolute harness path, `buzz-acp` itself, +/// `git-credential-nostr`, `PATH` — are appended by the remote script, not +/// here. Everything in this string is known locally. +fn env_file_body(agent: &Agent) -> Result { + let mut body = String::new(); + let mut push = |key: &str, value: &str| -> Result<(), String> { + body.push_str(&env_line(key, value)?); + Ok(()) + }; + + push("BUZZ_PRIVATE_KEY", agent.private_key_nsec.expose())?; + push("BUZZ_RELAY_URL", &agent.relay_url)?; + if let Some(auth_tag) = &agent.auth_tag { + push("BUZZ_AUTH_TAG", auth_tag)?; + } + push("BUZZ_ACP_AGENT_ARGS", &agent.agent_args.join(","))?; + // MCP does not reach the host yet: `mcp_command` is local catalog metadata, + // and mirroring that table here would drift. Empty rather than omitted, + // matching what local spawn writes when it does not apply. + push("BUZZ_ACP_MCP_COMMAND", "")?; + // Lazy defers the *pool warm*, not the process: buzz-acp connects, + // subscribes, and queues accepted work, then the first flushable event + // wakes all `BUZZ_ACP_AGENTS` slots. Nothing is dropped, and the one-shot + // cold start is cheaper than what eager costs here — a `Restart=always` + // unit re-pays N serial spawns on every restart, and a deployed-but-idle + // agent holds N harness subprocesses that are never reaped. That makes + // this the restore case (see `restore.rs`, "eager on restore buys + // nothing"), not the interactive-create case that spawns eager locally. + push("BUZZ_ACP_LAZY_POOL", "true")?; + push("BUZZ_ACP_AGENTS", &agent.parallelism.to_string())?; + push("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer")?; + push("BUZZ_ACP_DEDUP", "queue")?; + push("BUZZ_ACP_RELAY_OBSERVER", "true")?; + push("BUZZ_ACP_RESPOND_TO", &agent.respond_to)?; + if agent.respond_to == "allowlist" { + if agent.respond_to_allowlist.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist" + .to_string(), + ); + } + push( + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + &agent.respond_to_allowlist.join(","), + )?; + } + if let Some(prompt) = &agent.system_prompt { + push("BUZZ_ACP_SYSTEM_PROMPT", prompt)?; + } + if let Some(model) = &agent.model { + push("BUZZ_ACP_MODEL", model)?; + } + // The harness-native half of the same selection: `BUZZ_ACP_MODEL` is what + // buzz-acp reads, these are what the harness underneath it reads, and local + // spawn writes both. + for (key, value) in metadata_env(agent) { + push(key, value)?; + } + // Only when the user set them, so the harness's own defaults win otherwise. + if let Some(idle) = agent.idle_timeout_seconds { + push("BUZZ_ACP_IDLE_TIMEOUT", &idle.to_string())?; + } + if let Some(max_turn) = agent.max_turn_duration_seconds { + push("BUZZ_ACP_MAX_TURN_DURATION", &max_turn.to_string())?; + } + + // `BUZZ_MANAGED_AGENT` is deliberately absent: it is the desktop's marker + // for reclaiming orphaned local children, and systemd owns this lifecycle. + + // User env last, so it overrides everything above — systemd applies the + // later assignment for a repeated key, matching the local layering. + for (key, value) in &agent.env_vars { + if !is_well_formed_env_key(key) { + return Err(format!("env var name '{key}' is not a valid identifier")); + } + if RESERVED_ENV_KEYS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(key)) + { + return Err(format!( + "env var '{key}' is reserved and cannot be overridden" + )); + } + push(key, value)?; + } + Ok(body) +} + +/// The remote half of `runtime_metadata_env_vars` (`runtime.rs`). +/// +/// Local spawn writes the effective model and provider into each runtime's own +/// `model_env_var` / `provider_env_var`. Without this a remote Goose would see +/// `BUZZ_ACP_MODEL` but no `GOOSE_MODEL`, and fall back to whatever +/// `~/.config/goose/config.yaml` on the host says — the user's model pick +/// silently ignored. +/// +/// Keyed by command rather than harness id because the id is a create-time +/// desktop concept and the env file is written from the pin. Runtimes absent +/// here (Claude, Codex) declare no such vars in `KNOWN_ACP_RUNTIMES` either. +fn metadata_env(agent: &Agent) -> Vec<(&'static str, &str)> { + const RUNTIME_ENV: &[(&str, &str, &str)] = &[ + ("goose", "GOOSE_MODEL", "GOOSE_PROVIDER"), + ("buzz-agent", "BUZZ_AGENT_MODEL", "BUZZ_AGENT_PROVIDER"), + ]; + + // The pin may be a bare name or an absolute path; local spawn's + // `known_acp_runtime` matches on the file name either way. + let command = agent + .agent_command + .rsplit(['/', '\\']) + .next() + .unwrap_or(&agent.agent_command); + let Some((_, model_key, provider_key)) = + RUNTIME_ENV.iter().find(|(name, _, _)| *name == command) + else { + return Vec::new(); + }; + + [ + (*model_key, agent.model.as_deref()), + (*provider_key, agent.provider.as_deref()), + ] + .into_iter() + .filter_map(|(key, value)| Some((key, value?))) + .collect() +} + +/// `wss://relay` → `https://relay`, for the git credential helper's scope. +fn relay_http_base_url(relay_url: &str) -> String { + let trimmed = relay_url.trim().trim_end_matches('/'); + if let Some(rest) = trimmed.strip_prefix("wss://") { + format!("https://{rest}") + } else if let Some(rest) = trimmed.strip_prefix("ws://") { + format!("http://{rest}") + } else { + trimmed.to_string() + } +} + +/// The optional desktop-side copies of the host-side tools, already read and +/// encoded. Both default to absent, which is the case in which nothing about +/// the deploy changes. +#[derive(Default)] +struct Pushes { + acp: Option, + cli: Option, +} + +/// The `buzz-acp` command to resolve on the host: the operator's pin, or the +/// bare name. Shared by the probe and the deploy script so the two ask the same +/// question — see `install::resolve`. +fn acp_command(config: &SshConfig) -> String { + quote(config.buzz_acp_path.as_deref().unwrap_or(install::ACP.name)) +} + +/// The remote script. One round trip: resolve (or install), write, install, +/// start. +/// +/// Every secret reaches the host inside this script, which travels on the SSH +/// stdin channel. Nothing secret is ever an argument — not to `ssh`, and not to +/// any command the script runs — because the remote `ps` is world-readable. The +/// env file is written under `umask 077`, `chmod 600`, and moved into place +/// atomically. +/// +/// `push` carries the optional desktop-side copies of the two host-side tools. +/// `buzz-acp` is resolved *first*, so `$acp` — and therefore the unit's +/// `ExecStart` — names the copy this same pass installed. The pushed bytes are +/// not secret, but they share the stream with the minted nsec, so they travel +/// base64-encoded and never as raw bytes (`install`). +/// +/// The `buzz` CLI is resolved in the same pass and by the same machinery, with +/// one deliberate difference: a host that has neither the CLI nor a pushed copy +/// gets a `WARNING:` line and the deploy continues, because the harness does +/// not depend on the CLI (`install::Missing`). Nothing substitutes `$cli` into +/// the unit — the CLI is reached through the env file's `PATH`, which is what +/// makes the install destination resolvable for the harness's children. +fn deploy_script( + agent: &Agent, + config: &SshConfig, + unit: &str, + push: &Pushes, +) -> Result { + let slug = agent.slug(); + let acp = acp_command(config); + let command = quote(&agent.agent_command); + let relay_http = relay_http_base_url(&agent.relay_url); + let resolve_acp = install::resolve_or_install(install::ACP, &acp, push.acp.as_ref()); + let resolve_cli = + install::resolve_or_install(install::CLI, "e(install::CLI.name), push.cli.as_ref()); + + let mut script = String::from("set -eu\numask 077\n"); + // The harness name is bound once and thereafter referenced only as + // `"$harness_name"`. Interpolating it into the double-quoted error message + // would be a command-injection hole: `quote()` makes a value inert as an + // *argument*, but inside double quotes its single quotes are literal and a + // `$(...)` would still run. Expansion results are not re-scanned. + script.push_str(&format!( + r#"harness_name={command} +{resolve_acp} +harness=$(command -v "$harness_name" 2>/dev/null) || {{ echo "harness $harness_name not found on the server's PATH" >&2; exit 91; }} +claude_cli="" +case "${{harness##*/}}" in + claude-agent-acp|claude-code-acp) + if [ -x "$HOME/.local/bin/claude" ]; then + claude_cli="$HOME/.local/bin/claude" + else + claude_cli=$(command -v claude 2>/dev/null || true) + fi + if [ -z "$claude_cli" ]; then echo "Claude Code CLI not found in ~/.local/bin or on the server's PATH" >&2; exit 95; fi + ;; +esac +{resolve_cli} +cred=$(command -v git-credential-nostr 2>/dev/null || true) +conf="$HOME/.config/buzz-acp" +units="$HOME/.config/systemd/user" +mkdir -p "$conf" "$units" +env_file="$conf/{slug}.env" +tmp="$env_file.new" +"# + )); + + // No body line can terminate the heredoc: every line is `KEY="..."` and + // `env_line` refuses control characters. The quoted delimiter suppresses + // expansion, so a value is never interpreted by the shell. + script.push_str("{\n"); + script.push_str("printf 'BUZZ_ACP_AGENT_COMMAND=\"%s\"\\n' \"$harness\"\n"); + // The harness's `PATH`, and the reason an installed `buzz` is a command the + // agent can actually run. + // + // This is the remote half of the desktop's own contract: local spawn + // prepends `~/.local/bin` to the spawned harness's `PATH` so the agent can + // run the CLI its system prompt tells it to reply with + // (`managed_agents::runtime::path::build_augmented_path`). The unit runs + // under `systemd --user`, whose `PATH` is the user manager's — no profile, + // no login shell, and on many distributions no `~/.local/bin` — so without + // this line the install destination is a directory the harness cannot name, + // and both tools would be installed and unreachable. + // + // It is composed HERE, by the host's shell, and not written into the unit + // as `Environment=PATH=$HOME/.local/bin:$PATH`: systemd expands no variable + // in `Environment=` or an `EnvironmentFile`, so that form would hand the + // harness the five literal characters `$PATH`. `$PATH` on the right is the + // non-interactive SSH shell's, captured at deploy time, which is the same + // `PATH` `install::resolve` just searched — so anything `command -v` found + // stays findable for the agent. + // + // `install::resolve` can only land on this `PATH` or on `~/.local/bin`, so + // these two segments cover every copy either tool can resolve to; there is + // nothing further to add from `$acp` or `$cli`. + script.push_str("printf 'PATH=\"%s\"\\n' \"$HOME/.local/bin:$PATH\"\n"); + script.push_str("cat <<'BUZZ_ENV_EOF'\n"); + script.push_str(&env_file_body(agent)?); + script.push_str("BUZZ_ENV_EOF\n"); + // Match local desktop spawn's `configure_runtime_cli`: the adapter + // bundles a point-in-time Claude binary, while the native launcher follows + // Claude Code updates. Preserve the stable launcher path rather than + // resolving its symlink so every new ACP child inherits the current native + // version. This is emitted after the user-env heredoc as an authoritative + // provider binding; the key is also reserved so a payload cannot spoof it. + script.push_str( + "if [ -n \"$claude_cli\" ]; then\n\ +printf 'CLAUDE_CODE_EXECUTABLE=\"%s\"\\n' \"$claude_cli\"\n\ +fi\n", + ); + // Git over the relay's NIP-98 endpoint, only when the helper is installed. + // NOSTR_PRIVATE_KEY mirrors BUZZ_PRIVATE_KEY, as it does locally. + let helper_key = format!("credential.{relay_http}/git.helper"); + let use_http_path_key = format!("credential.{relay_http}/git.useHttpPath"); + let git_block: String = [ + ("NOSTR_PRIVATE_KEY", agent.private_key_nsec.expose()), + ("GIT_TERMINAL_PROMPT", "0"), + ("GIT_CONFIG_COUNT", "2"), + ("GIT_CONFIG_KEY_0", helper_key.as_str()), + ("GIT_CONFIG_KEY_1", use_http_path_key.as_str()), + ("GIT_CONFIG_VALUE_1", "true"), + ] + .into_iter() + .map(|(key, value)| env_line(key, value)) + .collect::>()?; + script.push_str(&format!( + r#"if [ -n "$cred" ]; then +printf 'GIT_CONFIG_VALUE_0="%s"\n' "$cred" +cat <<'BUZZ_GIT_EOF' +{git_block}BUZZ_GIT_EOF +fi +}} > "$tmp" +chmod 600 "$tmp" +mv "$tmp" "$env_file" +"# + )); + + // Without lingering the agent dies when this SSH session ends, which reads + // as a flaky agent rather than a configuration problem. It also creates + // `/run/user/$(id -u)`, so it must precede anything that talks to the user + // bus. Best-effort: some hosts gate it behind polkit, and failing it must + // not fail an otherwise good deploy. + // + // A non-interactive SSH command often gets no `XDG_RUNTIME_DIR`, without + // which every `systemctl --user` fails with "Failed to connect to bus". + script.push_str( + r#"loginctl enable-linger "$(id -un)" >/dev/null 2>&1 || true +if [ -z "${XDG_RUNTIME_DIR:-}" ]; then + XDG_RUNTIME_DIR="/run/user/$(id -u)" + export XDG_RUNTIME_DIR +fi +"#, + ); + + // Install the templated unit, reloading only when its content changed — a + // `daemon-reload` per start is noise, and a missing one after a change + // silently runs the old unit. + // + // `@BUZZ_ACP_BIN@` is substituted with parameter expansion rather than + // `sed`: `sed -i` is a GNU extension BSD and macOS hosts reject, and any + // `s///` would need a delimiter no resolved path can contain. + script.push_str(&format!( + r#"unit_file="$units/buzz-acp@.service" +template=$(cat <<'BUZZ_UNIT_EOF' +{unit}BUZZ_UNIT_EOF +) +printf '%s\n' "${{template%%@BUZZ_ACP_BIN@*}}$acp${{template#*@BUZZ_ACP_BIN@}}" > "$unit_file.new" +if cmp -s "$unit_file.new" "$unit_file"; then + rm -f "$unit_file.new" +else + mv "$unit_file.new" "$unit_file" + systemctl --user daemon-reload +fi +systemctl --user enable --now {service} >/dev/null +# Redeploy is also the start path, so an already-running unit must pick up the +# rewritten env file rather than be left on the old one. +systemctl --user restart {service} +"#, + service = quote(&format!("buzz-acp@{slug}.service")), + )); + Ok(script) +} + +/// The binaries to embed in this deploy's script, if any. +/// +/// Empty whenever the payload names no path — the default, and the case in +/// which nothing about deploy changes. Otherwise the host is asked first which +/// tools it already resolves, because **deploy is the start path**: without the +/// probe, a desktop with the seams engaged would encode and stream tens of +/// megabytes on every agent start, forever, to a host that has had the binaries +/// since the first deploy. Reading the files is skipped in that case too. +/// +/// One probe covers both tools, and it is skipped entirely when neither field +/// is set — so a payload with no push seams costs exactly the round trips it +/// always did. +/// +/// A probe that cannot be answered is not fatal: the binaries are embedded and +/// the script's own resolution makes the real decision on the host. +fn payloads_to_push( + agent: &Agent, + config: &SshConfig, + session: &Session, +) -> Result { + let candidates = [ + (install::ACP, acp_command(config), &agent.buzz_acp_binary), + ( + install::CLI, + quote(install::CLI.name), + &agent.buzz_cli_binary, + ), + ]; + let asked: Vec<(Tool, String)> = candidates + .iter() + .filter(|(_, _, path)| path.is_some()) + .map(|(tool, command, _)| (*tool, command.clone())) + .collect(); + if asked.is_empty() { + return Ok(Pushes::default()); + } + + let probe = session.run(&install::probe_script(&asked), Duration::from_secs(60))?; + // Read and validate before the deploy script is built: a bad path, a non-ELF + // file or an oversized one is the desktop's mistake, and it should be + // reported as that rather than as a remote failure mid-provisioning. That + // holds for the CLI too — the *absence* of a CLI is tolerable, but a + // desktop that pointed the seam at the wrong file has a bug worth naming. + let read = |tool: Tool, path: &Option| -> Result, String> { + match path.as_deref() { + Some(path) if !install::probe_found(&probe.stdout, tool) => { + Payload::read(tool, path).map(Some) + } + _ => Ok(None), + } + }; + Ok(Pushes { + acp: read(install::ACP, &agent.buzz_acp_binary)?, + cli: read(install::CLI, &agent.buzz_cli_binary)?, + }) +} + +pub fn deploy( + request: &serde_json::Value, + config: &SshConfig, + session: &Session, +) -> Result { + let agent = Agent::from_request(request)?; + let push = payloads_to_push(&agent, config, session)?; + let script = deploy_script(&agent, config, UNIT_TEMPLATE, &push)?; + let output = session.run(&script, Duration::from_secs(300))?; + if !output.ok() { + return Err(output.failure().into()); + } + // A successful deploy's remote stderr is otherwise dropped as host noise, + // so the script's non-fatal complaints — today, "this host has no buzz CLI" + // — would be invisible without this. They go to *this* process's stderr, + // which `invoke_provider` logs on success and shows in the error on + // failure, rather than into the response: the op succeeded, and a warning + // is not a result. + for warning in install::warnings(&output.stderr) { + eprintln!("buzz-backend-ssh: {warning}"); + } + Ok(serde_json::json!({ "ok": true, "agent_id": agent.agent_id() })) +} + +#[cfg(test)] +mod tests { + use super::*; + + const NSEC: &str = "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"; + + fn request() -> serde_json::Value { + serde_json::json!({ + "op": "deploy", + "provider_config": { "ssh_host": "vps", "ssh_user": "ubuntu" }, + "agent": { + "name": "Research Bot", + "relay_url": "wss://relay.example/ws", + "private_key_nsec": NSEC, + "auth_tag": "tag-abc", + "agent_command": "goose", + "agent_args": ["acp"], + "system_prompt": "be brief", + "model": "claude-sonnet-5", + "provider": "anthropic", + "parallelism": 3, + "respond_to": "owner-only", + "respond_to_allowlist": [], + "env_vars": { "ANTHROPIC_API_KEY": "sk-ant-secret" }, + }, + }) + } + + fn config() -> SshConfig { + SshConfig { + host: "vps".into(), + ..SshConfig::default() + } + } + + /// `Agent` is intentionally not `Debug` (see its doc comment), so tests + /// unwrap the error by hand rather than through `unwrap_err`. + fn rejection(request: &serde_json::Value) -> String { + match Agent::from_request(request) { + Err(error) => error, + Ok(agent) => panic!("expected a rejection, got agent {}", agent.agent_id()), + } + } + + #[test] + fn deploy_fails_closed_without_the_minted_key() { + let mut request = request(); + request["agent"]["private_key_nsec"] = serde_json::json!(""); + let error = rejection(&request); + assert!(error.contains("minted private key"), "{error}"); + + request["agent"] + .as_object_mut() + .unwrap() + .remove("private_key_nsec"); + assert!(rejection(&request).contains("minted private key")); + } + + #[test] + fn deploy_refuses_a_payload_whose_harness_pin_was_lost() { + // Without the pin the host would fall back to `buzz-agent` and the + // user's harness choice would vanish silently. + let mut request = request(); + request["agent"]["agent_command"] = serde_json::json!(""); + let error = rejection(&request); + assert!(error.contains("harness pin"), "{error}"); + } + + #[test] + fn the_pinned_harness_is_what_the_unit_runs() { + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + // Resolved to an absolute path on the HOST, and written into the env + // file systemd re-reads on every restart — that is what makes the pin + // durable rather than a one-shot argument. + assert!(script.contains("harness_name='goose'")); + assert!(script.contains(r#"harness=$(command -v "$harness_name""#)); + assert!(script.contains("printf 'BUZZ_ACP_AGENT_COMMAND=\"%s\"\\n' \"$harness\"")); + assert!(script.contains(r#"BUZZ_ACP_AGENT_ARGS="acp""#)); + // And a missing harness is a failure, never a substitution. + assert!(script.contains("exit 91")); + } + + #[cfg(unix)] + #[test] + fn remote_claude_adapters_prefer_the_stable_native_launcher() { + for (index, adapter) in ["claude-agent-acp", "claude-code-acp"] + .into_iter() + .enumerate() + { + let root = sandbox_host(&format!("claude-cli-{index}"), HostAcp::Installed); + let bin = root.join("bin"); + let adapter_path = seed_stub(&bin, adapter, "#!/bin/sh\nexit 0\n"); + seed_stub(&bin, "claude", "#!/bin/sh\nexit 0\n"); + let claude = seed_stub(&root.join(".local/bin"), "claude", "#!/bin/sh\nexit 0\n"); + + let mut request = request(); + request["agent"]["agent_command"] = if index == 0 { + serde_json::json!(adapter) + } else { + serde_json::json!(adapter_path) + }; + let agent = Agent::from_request(&request).unwrap(); + let script = + deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + let output = run_in_sandbox(&root, &script); + assert!( + output.status.success(), + "deploy script failed for {adapter}: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let env_file = root + .join(".config/buzz-acp") + .join(format!("{}.env", agent.slug())); + let written = std::fs::read_to_string(env_file).unwrap(); + assert!( + written.contains(&format!("CLAUDE_CODE_EXECUTABLE=\"{}\"", claude.display())), + "{written}" + ); + } + } + + #[cfg(unix)] + #[test] + fn a_remote_claude_adapter_falls_back_to_the_hosts_path() { + let root = sandbox_host("claude-cli-path", HostAcp::Installed); + let bin = root.join("bin"); + seed_stub(&bin, "claude-agent-acp", "#!/bin/sh\nexit 0\n"); + let claude = seed_stub(&bin, "claude", "#!/bin/sh\nexit 0\n"); + + let mut request = request(); + request["agent"]["agent_command"] = serde_json::json!("claude-agent-acp"); + let agent = Agent::from_request(&request).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + let output = run_in_sandbox(&root, &script); + assert!(output.status.success()); + + let written = std::fs::read_to_string( + root.join(".config/buzz-acp") + .join(format!("{}.env", agent.slug())), + ) + .unwrap(); + assert!( + written.contains(&format!("CLAUDE_CODE_EXECUTABLE=\"{}\"", claude.display())), + "{written}" + ); + } + + #[cfg(unix)] + #[test] + fn a_remote_claude_adapter_requires_the_vendor_cli() { + let root = sandbox_host("claude-cli-missing", HostAcp::Installed); + seed_stub(&root.join("bin"), "claude-agent-acp", "#!/bin/sh\nexit 0\n"); + + let mut request = request(); + request["agent"]["agent_command"] = serde_json::json!("claude-agent-acp"); + let agent = Agent::from_request(&request).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + let output = run_in_sandbox(&root, &script); + + assert_eq!(output.status.code(), Some(95)); + assert!(String::from_utf8_lossy(&output.stderr).contains("Claude Code CLI not found")); + assert!(!root.join(".config/buzz-acp").exists()); + } + + /// A Hermes per-profile pin, end to end through the deploy path. + /// + /// `discover_harnesses` emits `["--profile", , "acp"]`, and the args + /// reach the host as ONE comma-joined `BUZZ_ACP_AGENT_ARGS` that `buzz-acp` + /// re-splits on `,` (`config.rs`, `value_delimiter`). That round trip is + /// only lossless because a profile name cannot contain a comma — which is + /// what `is_hermes_profile_name` guarantees — so pin the whole chain here + /// rather than trusting the two halves independently. + #[test] + fn a_hermes_profile_pin_reaches_the_host_intact() { + let mut request = request(); + request["agent"]["agent_command"] = serde_json::json!("hermes"); + request["agent"]["agent_args"] = + serde_json::json!(["--profile", "msig-web-analyst", "acp"]); + let agent = Agent::from_request(&request).unwrap(); + assert_eq!( + agent.agent_args, + ["--profile", "msig-web-analyst", "acp"], + "provider args are pinned verbatim, never re-resolved" + ); + + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + assert!(script.contains("harness_name='hermes'")); + assert!( + script.contains(r#"BUZZ_ACP_AGENT_ARGS="--profile,msig-web-analyst,acp""#), + "{script}" + ); + } + + #[test] + fn secrets_travel_in_the_script_body_and_never_on_an_argv() { + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + assert!(script.contains(&format!("BUZZ_PRIVATE_KEY=\"{NSEC}\""))); + assert!(script.contains(&format!("NOSTR_PRIVATE_KEY=\"{NSEC}\""))); + assert!(script.contains("ANTHROPIC_API_KEY=\"sk-ant-secret\"")); + + // Every secret-bearing line lives inside a quoted heredoc, so the + // remote shell never expands it and it never becomes an argument to + // anything. The commands the script *runs* carry no secret at all. + for line in script.lines() { + if line.contains(NSEC) || line.contains("sk-ant-secret") { + assert!( + line.starts_with("BUZZ_") + || line.starts_with("NOSTR_") + || line.starts_with("ANTHROPIC_"), + "secret escaped the heredoc body: {line}" + ); + } + } + assert!(script.contains("umask 077")); + assert!(script.contains("chmod 600 \"$tmp\"")); + } + + #[test] + fn the_env_file_transcribes_the_local_spawn_contract() { + let agent = Agent::from_request(&request()).unwrap(); + let body = env_file_body(&agent).unwrap(); + for expected in [ + r#"BUZZ_RELAY_URL="wss://relay.example/ws""#, + r#"BUZZ_AUTH_TAG="tag-abc""#, + r#"BUZZ_ACP_LAZY_POOL="true""#, + r#"BUZZ_ACP_AGENTS="3""#, + r#"BUZZ_ACP_MULTIPLE_EVENT_HANDLING="steer""#, + r#"BUZZ_ACP_DEDUP="queue""#, + r#"BUZZ_ACP_RELAY_OBSERVER="true""#, + r#"BUZZ_ACP_RESPOND_TO="owner-only""#, + r#"BUZZ_ACP_SYSTEM_PROMPT="be brief""#, + r#"BUZZ_ACP_MODEL="claude-sonnet-5""#, + r#"BUZZ_ACP_MCP_COMMAND="""#, + ] { + assert!(body.contains(expected), "missing {expected}"); + } + // Local process-ownership marker: meaningless where systemd owns the + // lifecycle, so it is never written. + assert!(!body.contains("BUZZ_MANAGED_AGENT")); + // Unset timeouts are omitted so the harness's own defaults win. + assert!(!body.contains("BUZZ_ACP_IDLE_TIMEOUT")); + assert!(!body.contains("BUZZ_ACP_MAX_TURN_DURATION")); + // No allowlist key unless the mode asks for one. + assert!(!body.contains("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + } + + #[test] + fn the_harness_sees_the_same_model_env_local_spawn_would_set() { + // `runtime_metadata_env_vars` parity: buzz-acp reads BUZZ_ACP_MODEL, + // but Goose itself reads GOOSE_MODEL/GOOSE_PROVIDER. Emitting only the + // former leaves the host's ~/.config/goose/config.yaml deciding the + // model, silently overriding the user's pick. + let body = env_file_body(&Agent::from_request(&request()).unwrap()).unwrap(); + assert!(body.contains(r#"GOOSE_MODEL="claude-sonnet-5""#), "{body}"); + assert!(body.contains(r#"GOOSE_PROVIDER="anthropic""#), "{body}"); + + // An absolute pin resolves to the same runtime — `known_acp_runtime` + // matches on the file name locally, so this must too. + let mut request = request(); + request["agent"]["agent_command"] = serde_json::json!("/home/ubuntu/.local/bin/goose"); + let body = env_file_body(&Agent::from_request(&request).unwrap()).unwrap(); + assert!(body.contains(r#"GOOSE_MODEL="claude-sonnet-5""#), "{body}"); + + // Runtimes that declare no model/provider env upstream get none here. + request["agent"]["agent_command"] = serde_json::json!("claude-code-acp"); + let body = env_file_body(&Agent::from_request(&request).unwrap()).unwrap(); + assert!(!body.contains("GOOSE_MODEL")); + assert!(body.contains(r#"BUZZ_ACP_MODEL="claude-sonnet-5""#)); + + // And an unset field writes no key at all, so the harness default wins. + request["agent"]["agent_command"] = serde_json::json!("goose"); + request["agent"]["provider"] = serde_json::json!(""); + let body = env_file_body(&Agent::from_request(&request).unwrap()).unwrap(); + assert!(body.contains("GOOSE_MODEL")); + assert!(!body.contains("GOOSE_PROVIDER")); + } + + #[test] + fn the_deprecated_turn_timeout_is_never_written() { + // The payload still carries `turn_timeout_seconds` (upstream + // `deploy_payload_json`), but `BUZZ_ACP_TURN_TIMEOUT` is deprecated and + // ignored by the harness, and local spawn does not write it either. + let mut request = request(); + request["agent"]["turn_timeout_seconds"] = serde_json::json!(320); + let body = env_file_body(&Agent::from_request(&request).unwrap()).unwrap(); + assert!(!body.contains("TURN_TIMEOUT"), "{body}"); + } + + #[test] + fn timeouts_are_emitted_only_when_set() { + let mut request = request(); + request["agent"]["idle_timeout_seconds"] = serde_json::json!(900); + request["agent"]["max_turn_duration_seconds"] = serde_json::json!(3600); + let body = env_file_body(&Agent::from_request(&request).unwrap()).unwrap(); + assert!(body.contains(r#"BUZZ_ACP_IDLE_TIMEOUT="900""#)); + assert!(body.contains(r#"BUZZ_ACP_MAX_TURN_DURATION="3600""#)); + } + + #[test] + fn user_env_is_written_last_so_it_overrides() { + let mut request = request(); + request["agent"]["env_vars"] = serde_json::json!({ "GOOSE_MODE": "auto" }); + let body = env_file_body(&Agent::from_request(&request).unwrap()).unwrap(); + let user = body.find("GOOSE_MODE").unwrap(); + assert!(body.find("BUZZ_ACP_MODEL").unwrap() < user); + } + + #[test] + fn reserved_and_malformed_env_keys_are_refused() { + for key in [ + "BUZZ_PRIVATE_KEY", + "buzz_relay_url", + "BUZZ_MANAGED_AGENT", + "CLAUDE_CODE_EXECUTABLE", + ] { + let mut request = request(); + request["agent"]["env_vars"] = serde_json::json!({ key: "x" }); + let error = env_file_body(&Agent::from_request(&request).unwrap()).unwrap_err(); + assert!(error.contains("reserved"), "{key}: {error}"); + } + let mut request = request(); + request["agent"]["env_vars"] = serde_json::json!({ "BAD KEY": "x" }); + assert!(env_file_body(&Agent::from_request(&request).unwrap()) + .unwrap_err() + .contains("not a valid identifier")); + } + + #[test] + fn env_values_cannot_forge_an_extra_assignment() { + // A newline would end the assignment and start a line of the value's + // own choosing — including a line that re-sets a reserved key. + let error = env_line("X", "a\nBUZZ_PRIVATE_KEY=nsec1evil").unwrap_err(); + assert!(error.contains("control character")); + // Quotes and backslashes are escaped rather than refused. + assert_eq!(env_line("X", r#"a"b\c"#).unwrap(), "X=\"a\\\"b\\\\c\"\n"); + } + + #[test] + fn allowlist_mode_requires_an_allowlist() { + let mut request = request(); + request["agent"]["respond_to"] = serde_json::json!("allowlist"); + assert!(env_file_body(&Agent::from_request(&request).unwrap()).is_err()); + + request["agent"]["respond_to_allowlist"] = serde_json::json!(["abc123"]); + let body = env_file_body(&Agent::from_request(&request).unwrap()).unwrap(); + assert!(body.contains(r#"BUZZ_ACP_RESPOND_TO_ALLOWLIST="abc123""#)); + } + + #[test] + fn slugs_are_unit_safe_and_stable_across_redeploys() { + let agent = Agent::from_request(&request()).unwrap(); + let slug = agent.slug(); + assert!( + slug.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'), + "{slug}" + ); + assert!(slug.starts_with("research-bot-")); + // Redeploy is the start path: the same name must yield the same unit + // and the same agent_id, or start would provision a duplicate. + assert_eq!(slug, Agent::from_request(&request()).unwrap().slug()); + assert_eq!(agent.agent_id(), format!("buzz-acp@{slug}")); + } + + #[test] + fn names_that_sanitize_alike_still_get_distinct_units() { + let named = |name: &str| { + let mut request = request(); + request["agent"]["name"] = serde_json::json!(name); + Agent::from_request(&request).unwrap().slug() + }; + assert_ne!(named("Bot!"), named("Bot?")); + // A name with nothing usable still produces a legal instance name. + let empty = named("!!!"); + assert!(empty.starts_with("agent-"), "{empty}"); + } + + #[test] + fn redeploy_is_idempotent() { + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + // One templated unit per host, reloaded only when its content changed. + assert!(script.contains(r#"unit_file="$units/buzz-acp@.service""#)); + assert!(script.contains(r#"if cmp -s "$unit_file.new" "$unit_file""#)); + assert_eq!(script.matches("daemon-reload").count(), 1); + // Enable is idempotent; restart makes an already-running unit adopt the + // rewritten env file. + assert!(script.contains("systemctl --user enable --now 'buzz-acp@research-bot-")); + assert!(script.contains("systemctl --user restart 'buzz-acp@research-bot-")); + // The env file is replaced atomically, so a failed write never leaves a + // half-written identity behind. + assert!(script.contains(r#"mv "$tmp" "$env_file""#)); + } + + #[test] + fn the_unit_template_substitutes_a_resolved_buzz_acp() { + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + assert!(UNIT_TEMPLATE.contains("ExecStart=@BUZZ_ACP_BIN@")); + assert!(UNIT_TEMPLATE.contains("EnvironmentFile=%h/.config/buzz-acp/%i.env")); + // Substitution is parameter expansion, not `sed`: `sed -i` is a GNU + // extension that BSD and macOS hosts reject. + assert!(!script.contains("sed ")); + assert!(script.contains("${template%%@BUZZ_ACP_BIN@*}$acp${template#*@BUZZ_ACP_BIN@}")); + // Lingering, or the agent dies when this SSH session ends. It also + // creates /run/user/$(id -u), so it must precede any bus traffic. + let linger = script.find("loginctl enable-linger").unwrap(); + assert!(linger < script.find("systemctl --user").unwrap()); + // A non-interactive SSH command often has no XDG_RUNTIME_DIR, and + // without it every `systemctl --user` fails to reach the bus. + assert!(script.contains(r#"if [ -z "${XDG_RUNTIME_DIR:-}" ]; then"#)); + } + + /// Run the generated script against a real `/bin/sh` in a sandbox, with + /// `systemctl`/`loginctl` stubbed out. + /// + /// Substring assertions prove the script *says* the right things; only + /// executing it proves it *is* a valid shell program that produces the + /// right files. Everything below — quoting, heredoc framing, the + /// `@BUZZ_ACP_BIN@` expansion, `set -eu` interactions — is the kind of + /// defect no `contains` check catches. + fn run_deploy_script( + sandbox: &str, + request: &serde_json::Value, + ) -> (std::process::Output, std::path::PathBuf) { + let root = sandbox_host(sandbox, HostAcp::Installed); + let agent = Agent::from_request(request).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + (run_in_sandbox(&root, &script), root) + } + + /// Whether the sandboxed "host" already has `buzz-acp` on its PATH. The + /// install path only engages on a host that does not. + #[derive(PartialEq)] + enum HostAcp { + Installed, + Missing, + } + + /// Build the fake host: a `$HOME` with a stubbed `bin` on its PATH. + fn sandbox_host(sandbox: &str, acp: HostAcp) -> std::path::PathBuf { + // Named per test rather than keyed on the thread id, which the test + // harness recycles once a thread finishes. + let root = + std::env::temp_dir().join(format!("buzz-deploy-{}-{sandbox}", std::process::id())); + let bin = root.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + // Stub every host binary the script resolves, so the run is hermetic. + let mut stubs = vec![ + ("goose", "#!/bin/sh\nexit 0\n"), + ("git-credential-nostr", "#!/bin/sh\nexit 0\n"), + // Record the systemd calls instead of making them. + ( + "systemctl", + "#!/bin/sh\nprintf 'systemctl %s\\n' \"$*\" >> \"$HOME/systemd.log\"\n", + ), + ( + "loginctl", + "#!/bin/sh\nprintf 'loginctl %s\\n' \"$*\" >> \"$HOME/systemd.log\"\n", + ), + ]; + if acp == HostAcp::Installed { + stubs.push(("buzz-acp", "#!/bin/sh\nexit 0\n")); + } + // Note `buzz` is NOT stubbed: the sandbox host has no CLI unless a test + // seeds one with `seed_stub`, so every run through here also exercises + // the degradation path — a warning, and a deploy that still succeeds. + for (name, body) in stubs { + seed_stub(&bin, name, body); + } + root + } + + /// Drop an executable stub into `dir`. + fn seed_stub(dir: &std::path::Path, name: &str, body: &str) -> std::path::PathBuf { + std::fs::create_dir_all(dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, body).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + path + } + + /// Feed `script` to a real `/bin/sh` exactly as `ssh` feeds it to the + /// remote one: on stdin, with the sandbox as `$HOME`. + fn run_in_sandbox(root: &std::path::Path, script: &str) -> std::process::Output { + let bin = root.join("bin"); + std::process::Command::new("/bin/sh") + .arg("-s") + .env_clear() + .env("HOME", root) + .env("PATH", format!("{}:/usr/bin:/bin", bin.display())) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(script.as_bytes()) + .unwrap(); + child.wait_with_output() + }) + .unwrap() + } + + #[cfg(unix)] + #[test] + fn the_generated_script_actually_runs_and_provisions_the_host() { + let (output, root) = run_deploy_script("provision", &request()); + assert!( + output.status.success(), + "deploy script failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let slug = Agent::from_request(&request()).unwrap().slug(); + let env_file = root.join(".config/buzz-acp").join(format!("{slug}.env")); + let written = std::fs::read_to_string(&env_file).unwrap(); + + // The harness pin, resolved to an absolute path on the host, and + // written where systemd re-reads it on every restart. + assert!( + written.contains(&format!( + "BUZZ_ACP_AGENT_COMMAND=\"{}\"", + root.join("bin/goose").display() + )), + "{written}" + ); + assert!(written.contains(&format!("BUZZ_PRIVATE_KEY=\"{NSEC}\""))); + assert!(written.contains("ANTHROPIC_API_KEY=\"sk-ant-secret\"")); + assert!(!written.contains("CLAUDE_CODE_EXECUTABLE")); + // The git block only lands because the stub helper exists, and it + // carries the helper's resolved path. + assert!(written.contains(&format!( + "GIT_CONFIG_VALUE_0=\"{}\"", + root.join("bin/git-credential-nostr").display() + ))); + assert!(written.contains(&format!("NOSTR_PRIVATE_KEY=\"{NSEC}\""))); + // Every line is a well-formed assignment: no heredoc marker leaked in, + // and no value split across lines. + for line in written.lines() { + assert!( + line.split_once('=') + .is_some_and(|(_, v)| v.starts_with('"') && v.ends_with('"') && v.len() >= 2), + "malformed env line: {line}" + ); + } + + // Only the owner can read the minted key. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&env_file).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "env file is group/world accessible"); + } + + // The unit landed with a real path in ExecStart, and no placeholder. + let unit = + std::fs::read_to_string(root.join(".config/systemd/user/buzz-acp@.service")).unwrap(); + assert!(unit.contains(&format!( + "ExecStart={}", + root.join("bin/buzz-acp").display() + ))); + assert!(!unit.contains("@BUZZ_ACP_BIN@")); + assert!(!root + .join(".config/systemd/user/buzz-acp@.service.new") + .exists()); + + let calls = std::fs::read_to_string(root.join("systemd.log")).unwrap(); + assert!(calls.contains("loginctl enable-linger")); + assert!(calls.contains("systemctl --user daemon-reload")); + assert!(calls.contains(&format!( + "systemctl --user enable --now buzz-acp@{slug}.service" + ))); + assert!(calls.contains(&format!("systemctl --user restart buzz-acp@{slug}.service"))); + } + + #[cfg(unix)] + #[test] + fn a_second_deploy_reuses_the_unit_and_skips_the_reload() { + let (first, root) = run_deploy_script("redeploy", &request()); + assert!(first.status.success()); + std::fs::remove_file(root.join("systemd.log")).unwrap(); + + // Redeploy is the start path, so this is what `start_managed_agent` + // does on every start. The unit content is unchanged, so systemd must + // not be reloaded — but the env file must still be rewritten and the + // service restarted onto it. + let (second, _) = run_deploy_script("redeploy", &request()); + assert!( + second.status.success(), + "redeploy failed: {}", + String::from_utf8_lossy(&second.stderr) + ); + let calls = std::fs::read_to_string(root.join("systemd.log")).unwrap(); + assert!(!calls.contains("daemon-reload"), "{calls}"); + assert!(calls.contains("restart"), "{calls}"); + } + + #[cfg(unix)] + #[test] + fn a_missing_harness_stops_the_deploy_before_anything_is_written() { + let mut request = request(); + request["agent"]["agent_command"] = serde_json::json!("not-installed-anywhere"); + let (output, root) = run_deploy_script("missing-harness", &request); + assert_eq!(output.status.code(), Some(91)); + assert!(String::from_utf8_lossy(&output.stderr).contains("not-installed-anywhere")); + // `set -e` plus ordering: nothing is provisioned on a failed resolve. + assert!(!root.join(".config/buzz-acp").exists()); + } + + #[cfg(unix)] + #[test] + fn shell_metacharacters_in_the_payload_stay_inert() { + // Regression: `quote()` makes a value inert as an *argument*, but + // interpolating that quoted form into a double-quoted string (an error + // message, say) leaves its single quotes literal and lets a `$(...)` + // in the payload execute. Every field below is attacker-influenced, so + // this test runs the script for real and checks that none of the + // command substitutions fired. + let canary = std::env::temp_dir().join(format!("buzz-pwned-{}", std::process::id())); + let _ = std::fs::remove_file(&canary); + let payload = format!("$(touch {})", canary.display()); + + let mut request = request(); + request["agent"]["name"] = serde_json::json!(format!("bot {payload}")); + request["agent"]["agent_command"] = serde_json::json!(payload); + request["agent"]["relay_url"] = serde_json::json!(format!("wss://relay/{payload}")); + request["agent"]["model"] = serde_json::json!(payload.clone()); + request["agent"]["env_vars"] = serde_json::json!({ "EVIL": payload.clone() }); + + let (output, _) = run_deploy_script("injection", &request); + // The harness does not exist, so the deploy stops — the point is that + // it stops without having executed the payload. + assert_eq!(output.status.code(), Some(91)); + assert!( + !canary.exists(), + "payload executed on the host: command injection in the deploy script" + ); + + // And the slug stays a legal systemd instance name regardless. + let agent = Agent::from_request(&request).unwrap(); + assert!(agent + .slug() + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')); + } + + /// A binary to push: a legal ELF header followed by every byte sequence + /// that would end a heredoc, escape the script, or run a command if the + /// transport were anything other than base64. Both tools' delimiters are in + /// there, so the same bytes are hostile to whichever tool carries them. + fn canary_binary(canary: &std::path::Path) -> Vec { + let mut bytes = b"\x7fELF\x02\x01\x01\x00".to_vec(); + bytes.extend_from_slice(format!("$(touch {})\n", canary.display()).as_bytes()); + bytes.extend_from_slice(format!("`touch {}`\n", canary.display()).as_bytes()); + bytes.extend_from_slice(b"BUZZ_ACP_B64_EOF\nrm -rf \"$HOME\"\n"); + bytes.extend_from_slice(b"BUZZ_CLI_B64_EOF\nrm -rf \"$HOME\"\n"); + bytes.extend_from_slice(b"\0'\"\r\n$HOME ${HOME}\n"); + bytes.extend_from_slice(&(0u8..=255).collect::>()); + bytes + } + + fn push_payload(tool: Tool, name: &str, bytes: &[u8]) -> Payload { + let path = std::env::temp_dir().join(format!( + "buzz-push-{}-{}-{name}", + tool.name, + std::process::id() + )); + std::fs::write(&path, bytes).unwrap(); + Payload::read(tool, &path.display().to_string()).unwrap() + } + + /// The `Pushes` a desktop that set only `BUZZ_ACP_PUSH_BINARY` produces. + fn acp_push(payload: Payload) -> Pushes { + Pushes { + acp: Some(payload), + cli: None, + } + } + + #[test] + fn the_pushed_binaries_are_optional_fields_that_change_nothing_when_absent() { + // The seams must be invisible: a payload without the fields produces + // the script the crate produced before they existed. + let mut request = request(); + let agent = Agent::from_request(&request).unwrap(); + assert!(agent.buzz_acp_binary.is_none()); + assert!(agent.buzz_cli_binary.is_none()); + let without = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + assert!(without.contains("exit 90")); + assert!(!without.contains("base64 -d")); + + // A blank string is "absent", not "push nothing". + for field in ["buzz_acp_binary", "buzz_cli_binary"] { + request["agent"][field] = serde_json::json!(" "); + } + let agent = Agent::from_request(&request).unwrap(); + assert!(agent.buzz_acp_binary.is_none()); + assert!(agent.buzz_cli_binary.is_none()); + + request["agent"]["buzz_acp_binary"] = serde_json::json!("/opt/buzz-acp"); + request["agent"]["buzz_cli_binary"] = serde_json::json!("/opt/buzz"); + let agent = Agent::from_request(&request).unwrap(); + assert_eq!(agent.buzz_acp_binary, Some("/opt/buzz-acp".to_string())); + assert_eq!(agent.buzz_cli_binary, Some("/opt/buzz".to_string())); + } + + /// The exact-equality pin: with both fields absent the script is *byte* + /// identical to the one the crate emitted before either seam existed — + /// modulo the CLI resolution block, which is unconditional and therefore + /// spelled out here in full rather than asserted about. + /// + /// Substring assertions cannot see an accidental extra line; this can. + #[test] + fn the_script_for_a_payload_with_neither_field_is_pinned_exactly() { + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + let slug = agent.slug(); + + let expected = format!( + r#"set -eu +umask 077 +harness_name='goose' +acp=$(command -v 'buzz-acp' 2>/dev/null || true) +if [ -z "$acp" ] && [ -x "$HOME/.local/bin/buzz-acp" ]; then acp="$HOME/.local/bin/buzz-acp"; fi +if [ -z "$acp" ]; then echo "buzz-acp not found on the server's PATH or in ~/.local/bin — install it, or set 'buzz-acp path on the server'" >&2; exit 90; fi +harness=$(command -v "$harness_name" 2>/dev/null) || {{ echo "harness $harness_name not found on the server's PATH" >&2; exit 91; }} +claude_cli="" +case "${{harness##*/}}" in + claude-agent-acp|claude-code-acp) + if [ -x "$HOME/.local/bin/claude" ]; then + claude_cli="$HOME/.local/bin/claude" + else + claude_cli=$(command -v claude 2>/dev/null || true) + fi + if [ -z "$claude_cli" ]; then echo "Claude Code CLI not found in ~/.local/bin or on the server's PATH" >&2; exit 95; fi + ;; +esac +cli=$(command -v 'buzz' 2>/dev/null || true) +if [ -z "$cli" ] && [ -x "$HOME/.local/bin/buzz" ]; then cli="$HOME/.local/bin/buzz"; fi +if [ -z "$cli" ]; then echo "WARNING: no 'buzz' CLI on the server's PATH or in ~/.local/bin — agents on this host cannot reply with 'buzz messages send' and will degrade to slower replies; install it there, or set BUZZ_CLI_PUSH_BINARY on the desktop and redeploy" >&2; fi +cred=$(command -v git-credential-nostr 2>/dev/null || true) +conf="$HOME/.config/buzz-acp" +units="$HOME/.config/systemd/user" +mkdir -p "$conf" "$units" +env_file="$conf/{slug}.env" +tmp="$env_file.new" +{{ +printf 'BUZZ_ACP_AGENT_COMMAND="%s"\n' "$harness" +printf 'PATH="%s"\n' "$HOME/.local/bin:$PATH" +cat <<'BUZZ_ENV_EOF' +{env}BUZZ_ENV_EOF +if [ -n "$claude_cli" ]; then +printf 'CLAUDE_CODE_EXECUTABLE="%s"\n' "$claude_cli" +fi +if [ -n "$cred" ]; then +printf 'GIT_CONFIG_VALUE_0="%s"\n' "$cred" +cat <<'BUZZ_GIT_EOF' +NOSTR_PRIVATE_KEY="{NSEC}" +GIT_TERMINAL_PROMPT="0" +GIT_CONFIG_COUNT="2" +GIT_CONFIG_KEY_0="credential.https://relay.example/ws/git.helper" +GIT_CONFIG_KEY_1="credential.https://relay.example/ws/git.useHttpPath" +GIT_CONFIG_VALUE_1="true" +BUZZ_GIT_EOF +fi +}} > "$tmp" +chmod 600 "$tmp" +mv "$tmp" "$env_file" +loginctl enable-linger "$(id -un)" >/dev/null 2>&1 || true +if [ -z "${{XDG_RUNTIME_DIR:-}}" ]; then + XDG_RUNTIME_DIR="/run/user/$(id -u)" + export XDG_RUNTIME_DIR +fi +unit_file="$units/buzz-acp@.service" +template=$(cat <<'BUZZ_UNIT_EOF' +{unit}BUZZ_UNIT_EOF +) +printf '%s\n' "${{template%%@BUZZ_ACP_BIN@*}}$acp${{template#*@BUZZ_ACP_BIN@}}" > "$unit_file.new" +if cmp -s "$unit_file.new" "$unit_file"; then + rm -f "$unit_file.new" +else + mv "$unit_file.new" "$unit_file" + systemctl --user daemon-reload +fi +systemctl --user enable --now 'buzz-acp@{slug}.service' >/dev/null +# Redeploy is also the start path, so an already-running unit must pick up the +# rewritten env file rather than be left on the old one. +systemctl --user restart 'buzz-acp@{slug}.service' +"#, + env = env_file_body(&agent).unwrap(), + unit = UNIT_TEMPLATE, + ); + assert_eq!(script, expected); + } + + #[test] + fn a_pushed_binary_never_displaces_the_secret_discipline() { + let canary = std::env::temp_dir().join("buzz-never"); + let payload = push_payload(install::ACP, "discipline", &canary_binary(&canary)); + let sha256 = payload.sha256().to_string(); + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &acp_push(payload)).unwrap(); + + // The install block is additive: everything the secret path relies on + // is still exactly where it was. + assert!(script.starts_with("set -eu\numask 077\n")); + assert!(script.contains("chmod 600 \"$tmp\"")); + assert!(script.contains(&format!("BUZZ_PRIVATE_KEY=\"{NSEC}\""))); + // The binary is resolved/installed BEFORE the unit is templated, so + // `$acp` — and therefore ExecStart — names the copy just installed. + let install = script.find("base64 -d").unwrap(); + assert!(install < script.find("unit_file=").unwrap()); + // The hash travels in the clear (it is a fingerprint, not a secret) and + // the encoded bytes carry nothing the shell reads as syntax. + assert!(script.contains(&sha256)); + } + + #[cfg(unix)] + #[test] + fn a_pushed_binary_installs_atomically_and_only_after_it_verifies() { + let canary = std::env::temp_dir().join(format!("buzz-push-pwned-{}", std::process::id())); + let _ = std::fs::remove_file(&canary); + let bytes = canary_binary(&canary); + let payload = push_payload(install::ACP, "install", &bytes); + + // A host with no `buzz-acp` at all — the only case the push engages. + let root = sandbox_host("install", HostAcp::Missing); + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &acp_push(payload)).unwrap(); + let output = run_in_sandbox(&root, &script); + assert!( + output.status.success(), + "install deploy failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Byte-identical after a round trip through base64, a heredoc, and a + // real `/bin/sh` — including the NULs, quotes, `$(...)` and the literal + // heredoc delimiter embedded in the payload. + let installed = root.join(".local/bin/buzz-acp"); + assert_eq!(std::fs::read(&installed).unwrap(), bytes); + assert!( + !canary.exists(), + "the pushed binary's contents executed on the host" + ); + + // Executable, and no temp file left behind. + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&installed).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o755, "installed binary is not 755"); + assert!(!leftover_temp_files(&root.join(".local/bin"), install::ACP)); + + // And the unit points at the copy this pass installed, in the same + // deploy — install first, resolve second. + let unit = + std::fs::read_to_string(root.join(".config/systemd/user/buzz-acp@.service")).unwrap(); + assert!( + unit.contains(&format!("ExecStart={}", installed.display())), + "{unit}" + ); + } + + #[cfg(unix)] + #[test] + fn a_corrupted_push_aborts_before_the_mv_and_leaves_nothing_runnable() { + let canary = std::env::temp_dir().join("buzz-never"); + let payload = push_payload(install::ACP, "mismatch", &canary_binary(&canary)); + let sha256 = payload.sha256().to_string(); + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &acp_push(payload)).unwrap(); + // Stand in for a payload damaged in flight: the host is told to expect + // a digest the decoded bytes cannot produce. + let script = script.replace(&sha256, &"a".repeat(64)); + + let root = sandbox_host("mismatch", HostAcp::Missing); + let output = run_in_sandbox(&root, &script); + assert_eq!(output.status.code(), Some(94)); + assert!(String::from_utf8_lossy(&output.stderr).contains("sha256")); + + // Nothing installed, and — the property that matters — no half-written + // executable left in the directory systemd's ExecStart would name. + assert!(!root.join(".local/bin/buzz-acp").exists()); + assert!(!leftover_temp_files(&root.join(".local/bin"), install::ACP)); + // The deploy stopped there: no env file, no unit. + assert!(!root.join(".config/buzz-acp").exists()); + assert!(!root.join(".config/systemd").exists()); + } + + #[cfg(unix)] + #[test] + fn a_host_without_buzz_acp_and_no_pushed_binary_still_fails_with_todays_guidance() { + // The un-pushed path is unchanged: exit 90 and the same message, so a + // user who never sets the seam sees exactly what they saw before. + let root = sandbox_host("no-acp", HostAcp::Missing); + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + let output = run_in_sandbox(&root, &script); + assert_eq!(output.status.code(), Some(90)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("buzz-acp not found on the server's PATH"), + "{stderr}" + ); + assert!(!root.join(".local/bin/buzz-acp").exists()); + assert!(!root.join(".config/buzz-acp").exists()); + } + + #[cfg(unix)] + #[test] + fn an_existing_host_binary_is_never_replaced_by_the_pushed_one() { + // Staleness rule: push-when-missing only. Deploy is the start path, so + // a version-comparing rule would reinstall underneath a running fleet + // on every start — and a desktop pinned to an older artifact would + // downgrade the host. + let canary = std::env::temp_dir().join("buzz-never"); + let payload = push_payload(install::ACP, "keep", &canary_binary(&canary)); + let root = sandbox_host("keep", HostAcp::Installed); + let existing = std::fs::read(root.join("bin/buzz-acp")).unwrap(); + + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &acp_push(payload)).unwrap(); + let output = run_in_sandbox(&root, &script); + assert!( + output.status.success(), + "deploy failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + assert_eq!(std::fs::read(root.join("bin/buzz-acp")).unwrap(), existing); + assert!( + !root.join(".local/bin/buzz-acp").exists(), + "a host that already had buzz-acp got a second copy installed" + ); + let unit = + std::fs::read_to_string(root.join(".config/systemd/user/buzz-acp@.service")).unwrap(); + assert!(unit.contains(&format!( + "ExecStart={}", + root.join("bin/buzz-acp").display() + ))); + } + + #[cfg(unix)] + #[test] + fn a_second_deploy_keeps_the_binary_the_first_one_installed() { + // The install destination — `~/.local/bin` — is NOT on a + // non-interactive SSH PATH, which is exactly why the env file below + // pins `PATH="$HOME/.local/bin:$PATH"` itself. Resolution has to say so + // too: with a bare `command -v`, the probe answered "missing" forever + // and every deploy re-streamed and replaced the binary. Deploy is the + // start path, so that is every agent start, underneath a running fleet. + // + // `an_existing_host_binary_is_never_replaced_by_the_pushed_one` cannot + // see this: it seeds the stub into the sandbox's `bin`, which IS on the + // sandbox PATH. + use std::os::unix::fs::MetadataExt; + + let canary = std::env::temp_dir().join("buzz-never"); + let payload = push_payload(install::ACP, "twice", &canary_binary(&canary)); + let root = sandbox_host("twice", HostAcp::Missing); + let agent = Agent::from_request(&request()).unwrap(); + let installed = root.join(".local/bin/buzz-acp"); + + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &acp_push(payload)).unwrap(); + let first = run_in_sandbox(&root, &script); + assert!( + first.status.success(), + "first deploy failed: {}", + String::from_utf8_lossy(&first.stderr) + ); + let inode = std::fs::metadata(&installed).unwrap().ino(); + + // What the desktop asks before deploy #2. It must now answer "the host + // has it", which is what keeps the payload off the wire — the file is + // not even read, let alone encoded and streamed. + let probe = run_in_sandbox( + &root, + &install::probe_script(&[(install::ACP, acp_command(&config()))]), + ); + assert!( + install::probe_found(&String::from_utf8_lossy(&probe.stdout), install::ACP), + "the probe did not see the binary the previous deploy installed" + ); + + // And even the worst case — a script that still carries the payload — + // resolves to the installed copy instead of replacing it. + let second = run_in_sandbox(&root, &script); + assert!( + second.status.success(), + "second deploy failed: {}", + String::from_utf8_lossy(&second.stderr) + ); + assert_eq!( + std::fs::metadata(&installed).unwrap().ino(), + inode, + "the second deploy replaced the binary the first one installed" + ); + + // The unit still names it, so idempotence is real and not just quiet. + let unit = + std::fs::read_to_string(root.join(".config/systemd/user/buzz-acp@.service")).unwrap(); + assert!( + unit.contains(&format!("ExecStart={}", installed.display())), + "{unit}" + ); + } + + #[cfg(unix)] + #[test] + fn a_configured_absolute_path_still_resolves_the_copy_deploy_installed() { + // `buzz_acp_path` is an absolute path the operator picked, but an + // install always lands in `~/.local/bin`. Resolving only what was + // configured would never find it, so the host would re-install on every + // single start, forever. + use std::os::unix::fs::MetadataExt; + + let canary = std::env::temp_dir().join("buzz-never"); + let payload = push_payload(install::ACP, "configured", &canary_binary(&canary)); + let root = sandbox_host("configured", HostAcp::Missing); + let config = SshConfig { + buzz_acp_path: Some("/opt/buzz-acp".into()), + ..config() + }; + let agent = Agent::from_request(&request()).unwrap(); + let installed = root.join(".local/bin/buzz-acp"); + + let script = deploy_script(&agent, &config, UNIT_TEMPLATE, &acp_push(payload)).unwrap(); + assert!(run_in_sandbox(&root, &script).status.success()); + let inode = std::fs::metadata(&installed).unwrap().ino(); + + let probe = run_in_sandbox( + &root, + &install::probe_script(&[(install::ACP, acp_command(&config))]), + ); + assert!( + install::probe_found(&String::from_utf8_lossy(&probe.stdout), install::ACP), + "the probe missed the install because the configured path is elsewhere" + ); + assert!(run_in_sandbox(&root, &script).status.success()); + assert_eq!(std::fs::metadata(&installed).unwrap().ino(), inode); + } + + #[cfg(unix)] + #[test] + fn a_payload_that_decodes_to_garbage_aborts_before_anything_is_installed() { + let canary = std::env::temp_dir().join("buzz-never"); + let payload = push_payload(install::ACP, "decode", &canary_binary(&canary)); + let head = payload.encoded()[..8].to_string(); + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &acp_push(payload)).unwrap(); + // Stand in for a stream truncated in flight. `!` is outside the base64 + // alphabet, so `base64 -d` rejects the body outright — the exit-93 + // branch, which the sha256 test cannot reach because a payload that + // fails to decode never gets as far as being hashed. + let corrupt = script.replacen(&head, "!!!!!!!!", 1); + assert_ne!(corrupt, script, "the encoded body was not corrupted"); + + let root = sandbox_host("decode", HostAcp::Missing); + let output = run_in_sandbox(&root, &corrupt); + assert_eq!(output.status.code(), Some(93)); + assert!(String::from_utf8_lossy(&output.stderr).contains("decode")); + assert!(!root.join(".local/bin/buzz-acp").exists()); + assert!(!leftover_temp_files(&root.join(".local/bin"), install::ACP)); + // The `|| { ... }` really does bind to the heredoc-fed command: the + // deploy stopped here rather than running on with a corrupt file. + assert!(!root.join(".config/systemd").exists()); + } + + #[test] + fn a_payload_that_names_both_binaries_carries_both() { + // The two tools share one script and one pass. They must not share a + // heredoc delimiter, a temp file or a shell variable, or the second + // body would terminate the first and the host would be handed a + // half-decoded binary as commands. + let canary = std::env::temp_dir().join("buzz-never"); + let bytes = canary_binary(&canary); + let acp = push_payload(install::ACP, "both-acp", &bytes); + let cli = push_payload(install::CLI, "both-cli", &bytes); + let agent = Agent::from_request(&request()).unwrap(); + let push = Pushes { + acp: Some(acp), + cli: Some(cli), + }; + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &push).unwrap(); + + for delimiter in ["BUZZ_ACP_B64_EOF", "BUZZ_CLI_B64_EOF"] { + assert_eq!( + script.matches(&format!("<<'{delimiter}'")).count(), + 1, + "one heredoc opener per tool" + ); + } + assert!(script.contains(r#"acp_tmp="$acp_dir/.buzz-acp.tmp.$$""#)); + assert!(script.contains(r#"cli_tmp="$cli_dir/.buzz.tmp.$$""#)); + assert!(script.contains(r#"mv "$acp_tmp" "$acp_dir/buzz-acp""#)); + assert!(script.contains(r#"mv "$cli_tmp" "$cli_dir/buzz""#)); + // Order still holds: the harness is resolved (or installed) before the + // unit is templated from `$acp`, and the CLI never reaches the unit. + let acp_install = script.find(r#"mv "$acp_tmp""#).unwrap(); + let cli_install = script.find(r#"mv "$cli_tmp""#).unwrap(); + let unit = script.find("unit_file=").unwrap(); + assert!(acp_install < cli_install); + assert!(cli_install < unit); + // Nothing substitutes the CLI into the unit — it is reached through the + // env file's PATH, not through `ExecStart`. + assert!(!script.contains("@BUZZ_CLI_BIN@")); + assert!(!script[unit..].contains("$cli")); + } + + #[cfg(unix)] + #[test] + fn a_pushed_cli_lands_where_the_harness_can_run_it() { + // The gap this whole change exists to close: a remote agent is told by + // its own system prompt to answer with `buzz messages send`, and the + // SSH deploy shipped only `buzz-acp`. Install it, and — the half that + // makes the install worth anything — leave it on the `PATH` the unit + // hands the harness. + let canary = std::env::temp_dir().join(format!("buzz-cli-pwned-{}", std::process::id())); + let _ = std::fs::remove_file(&canary); + let bytes = canary_binary(&canary); + let payload = push_payload(install::CLI, "cli-install", &bytes); + + // A host that HAS buzz-acp: the CLI install is the only thing under + // test, and the deploy must run all the way through to the restart. + let root = sandbox_host("cli-install", HostAcp::Installed); + let agent = Agent::from_request(&request()).unwrap(); + // The `Pushes` a desktop that set only `BUZZ_CLI_PUSH_BINARY` produces. + let pushes = Pushes { + acp: None, + cli: Some(payload), + }; + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &pushes).unwrap(); + let output = run_in_sandbox(&root, &script); + assert!( + output.status.success(), + "cli deploy failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + // Nothing to warn about: the CLI is there now. + assert!( + install::warnings(&String::from_utf8_lossy(&output.stderr)).is_empty(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + + // Byte-identical through base64, a heredoc and a real `/bin/sh` — + // NULs, quotes, `$(...)` and both delimiters included. + let installed = root.join(".local/bin/buzz"); + assert_eq!(std::fs::read(&installed).unwrap(), bytes); + assert!( + !canary.exists(), + "the pushed CLI's contents executed on the host" + ); + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&installed).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o755, "the installed CLI is not executable"); + assert!(!leftover_temp_files(&root.join(".local/bin"), install::CLI)); + + // The CLI is reached through the env file, never through the unit: the + // harness's `PATH` starts at the install destination. + let slug = agent.slug(); + let env_file = root.join(".config/buzz-acp").join(format!("{slug}.env")); + let written = std::fs::read_to_string(&env_file).unwrap(); + let path_line = written + .lines() + .find(|line| line.starts_with("PATH=")) + .unwrap_or_else(|| panic!("no PATH line in the env file:\n{written}")); + assert!( + path_line.starts_with(&format!("PATH=\"{}", root.join(".local/bin").display())), + "{path_line}" + ); + let unit = + std::fs::read_to_string(root.join(".config/systemd/user/buzz-acp@.service")).unwrap(); + assert!(!unit.contains("/.local/bin/buzz\""), "{unit}"); + } + + #[cfg(unix)] + #[test] + fn the_env_file_path_is_the_install_destination_ahead_of_the_hosts_own() { + // Local spawn prepends `~/.local/bin` to the harness's PATH + // (`managed_agents::runtime::path::build_augmented_path`); this is the + // remote half of that contract, and it is the only reason an installed + // tool is a command the agent can name. `systemd --user` expands + // nothing in an `EnvironmentFile`, so the line has to be composed by + // the host's shell at deploy time — which is what this proves: the + // value is literal, resolved, and still carries the host's own PATH. + let (output, root) = run_deploy_script("path-line", &request()); + assert!( + output.status.success(), + "deploy failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let slug = Agent::from_request(&request()).unwrap().slug(); + let written = + std::fs::read_to_string(root.join(".config/buzz-acp").join(format!("{slug}.env"))) + .unwrap(); + let path_line = written + .lines() + .find(|line| line.starts_with("PATH=")) + .unwrap_or_else(|| panic!("no PATH line in the env file:\n{written}")); + assert_eq!( + path_line, + format!( + "PATH=\"{}:{}:/usr/bin:/bin\"", + root.join(".local/bin").display(), + root.join("bin").display() + ), + "{written}" + ); + // Not the five literal characters systemd would hand through verbatim. + assert!(!written.contains("$PATH")); + assert!(!written.contains("$HOME")); + } + + #[cfg(unix)] + #[test] + fn a_host_without_the_cli_deploys_anyway_and_says_what_was_lost() { + // The asymmetry, end to end. `buzz-acp` missing stops the deploy; the + // CLI missing must not — the harness does not depend on it — but it + // cannot be silent either, or the operator learns about it the way this + // change was discovered: by watching an agent hunt the filesystem. + let root = sandbox_host("no-cli", HostAcp::Installed); + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + let output = run_in_sandbox(&root, &script); + assert!( + output.status.success(), + "a missing CLI stopped the deploy: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Provisioned in full: the agent runs, it just replies the slow way. + let slug = agent.slug(); + assert!(root + .join(".config/buzz-acp") + .join(format!("{slug}.env")) + .exists()); + let calls = std::fs::read_to_string(root.join("systemd.log")).unwrap(); + assert!(calls.contains(&format!("systemctl --user restart buzz-acp@{slug}.service"))); + + // And the one line `deploy` forwards to the desktop names both what is + // missing and how to fix it. + let stderr = String::from_utf8_lossy(&output.stderr); + let warnings = install::warnings(&stderr); + assert_eq!(warnings.len(), 1, "{stderr}"); + assert!(warnings[0].contains("buzz messages send"), "{stderr}"); + assert!(warnings[0].contains("BUZZ_CLI_PUSH_BINARY"), "{stderr}"); + assert!(!root.join(".local/bin/buzz").exists()); + } + + /// Any `..tmp.*` still sitting in `dir`. A half-written binary that + /// survives a failed install is the failure mode the temp-file dance exists + /// to prevent, and it is per-tool because the two install into the same + /// directory. + fn leftover_temp_files(dir: &std::path::Path, tool: Tool) -> bool { + let prefix = format!(".{}.tmp.", tool.name); + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + entries + .filter_map(Result::ok) + .any(|entry| entry.file_name().to_string_lossy().starts_with(&prefix)) + } + + #[test] + fn relay_urls_map_to_their_http_origin_for_git_auth() { + assert_eq!( + relay_http_base_url("wss://relay.example/"), + "https://relay.example" + ); + assert_eq!( + relay_http_base_url("ws://localhost:8080"), + "http://localhost:8080" + ); + assert_eq!( + relay_http_base_url("https://relay.example"), + "https://relay.example" + ); + } + + #[test] + fn git_credential_env_is_emitted_only_when_the_helper_exists() { + let agent = Agent::from_request(&request()).unwrap(); + let script = deploy_script(&agent, &config(), UNIT_TEMPLATE, &Pushes::default()).unwrap(); + assert!(script.contains(r#"cred=$(command -v git-credential-nostr 2>/dev/null || true)"#)); + assert!(script.contains(r#"if [ -n "$cred" ]; then"#)); + assert!( + script.contains(r#"GIT_CONFIG_KEY_0="credential.https://relay.example/ws/git.helper""#) + ); + } +} diff --git a/crates/buzz-backend-ssh/src/discover.rs b/crates/buzz-backend-ssh/src/discover.rs new file mode 100644 index 0000000000..da9700b74b --- /dev/null +++ b/crates/buzz-backend-ssh/src/discover.rs @@ -0,0 +1,1266 @@ +//! `check`, `discover_harnesses` and `probe_models`: everything that reads the +//! remote host without changing it. +//! +//! All three are **one SSH round trip**. `discover_harnesses` in particular +//! probes every candidate harness from a single generated script — N sequential +//! `ssh` invocations would spend the whole 45s budget on handshakes over a +//! 200 ms link, and the harness picker would visibly hang. + +use std::time::Duration; + +use crate::protocol::{snippet, Failure, SshConfig}; +use crate::ssh::{quote, Session}; + +/// Harnesses the desktop knows how to render, in the same vocabulary its local +/// catalog uses (`KNOWN_ACP_RUNTIMES` + `PRESET_HARNESSES` in +/// `managed_agents/discovery.rs`). Ids must satisfy `[a-z0-9_][a-z0-9_-]*` or +/// `validate_harness_definition` drops the entry desktop-side. +struct Candidate { + id: &'static str, + label: &'static str, + /// Accepted command names, most preferred first. + commands: &'static [&'static str], + args: &'static [&'static str], + /// The runtime's `default_env` (`discovery.rs`). Locally these are applied + /// at spawn time from the catalog; a remote agent never spawns locally, so + /// they must ride along in the `HarnessDefinition` the desktop pins, or + /// they are simply lost. + env: &'static [(&'static str, &'static str)], +} + +const CANDIDATES: &[Candidate] = &[ + Candidate { + id: "buzz-agent", + label: "Buzz Agent", + commands: &["buzz-agent"], + args: &[], + env: &[], + }, + Candidate { + id: "goose", + label: "Goose", + commands: &["goose"], + args: &["acp"], + // Without this a remote Goose blocks on tool approvals that nobody is + // present to answer, and the agent silently stops making progress. + env: &[("GOOSE_MODE", "auto")], + }, + Candidate { + id: "claude", + label: "Claude Code", + commands: &["claude-agent-acp", "claude-code-acp"], + args: &[], + env: &[], + }, + Candidate { + id: "codex", + label: "Codex", + commands: &["codex-acp"], + args: &[], + env: &[], + }, + Candidate { + id: "cursor", + label: "Cursor", + commands: &["cursor-agent"], + args: &["acp"], + env: &[], + }, + Candidate { + id: "omp", + label: "Oh My Pi", + commands: &["omp"], + args: &["acp"], + env: &[], + }, + Candidate { + id: "grok", + label: "Grok Build", + commands: &["grok"], + args: &["agent", "--always-approve", "stdio"], + env: &[], + }, + Candidate { + id: "opencode", + label: "OpenCode", + commands: &["opencode"], + args: &["acp"], + env: &[], + }, + Candidate { + id: "kimi", + label: "Kimi Code", + commands: &["kimi"], + args: &["acp"], + env: &[], + }, + Candidate { + id: "amp", + label: "Amp", + commands: &["amp-acp"], + args: &[], + env: &[], + }, + Candidate { + id: "hermes", + label: "Hermes Agent", + commands: &["hermes-acp"], + args: &[], + env: &[], + }, + Candidate { + id: "openclaw", + label: "OpenClaw", + commands: &["openclaw"], + args: &["acp"], + env: &[], + }, +]; + +/// Preamble shared by every remote script. +/// +/// `probe` writes one tab-separated record per resolved command, rather than +/// JSON assembled in `sh`: quoting arbitrary `--version` output into valid JSON +/// from a POSIX shell is a bug farm, and the parsing belongs on the Rust side +/// where it is testable. +/// +/// Two details are load-bearing. `/dev/null 2>&1; then _t="timeout 5"; else _t=""; fi +probe() { + _p=$(command -v "$2" 2>/dev/null) || return 0 + [ -n "$_p" ] || return 0 + _v=$($_t "$2" --version /dev/null | head -n 1 | tr -d '\t\r') || _v="" + printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$_p" "$_v" +} +"#; + +/// Probe key for the Hermes CLI itself, as opposed to the `hermes-acp` shim the +/// `hermes` [`Candidate`] resolves. Deliberately not any candidate's id, so the +/// twelve catalog entries are unaffected by its presence or absence. +const HERMES_CLI_KEY: &str = "hermes-cli"; + +/// Prefix of a profile record: `hermes-profile`. +/// +/// Two fields, not the four a `probe` record carries, so [`parse_probes`] drops +/// these on its own (`path` is `None`) and the two streams cannot be confused. +const HERMES_PROFILE_PREFIX: &str = "hermes-profile\t"; + +/// Per-profile entries emitted for one host. A pathological (or hostile) host +/// with thousands of directories under `profiles/` must not turn the harness +/// picker into an unusable wall, and `Output` caps the whole response at 1 MB +/// regardless — which would fail the op rather than truncate it. +/// +/// Enforced twice on purpose: the script stops early so the bytes are never +/// sent, and [`hermes_profiles`] re-applies it because remote stdout is +/// untrusted input and a host is free to ignore the script it was handed. +const MAX_HERMES_PROFILES: usize = 32; + +/// Hermes profile enumeration, appended to the probe script. +/// +/// Hermes runs N isolated instances out of one install — a profile is a whole +/// `HERMES_HOME`, selected by the **global** pre-subcommand flag +/// (`hermes --profile matt acp`). The operator's fleet is one profile per +/// teammate, so a host has to advertise one catalog entry per profile or nine +/// of the ten agents on it are unreachable from the picker. +/// +/// The profile store is read from the filesystem rather than from +/// `hermes profile list`: the directory layout **is** what Hermes resolves a +/// profile against (`hermes_cli/profiles.py`: `get_profile_dir` → +/// `/profiles/`), while the CLI output is a human table with a +/// unicode default marker and no `--json`. Parsing that table would be reading +/// a rendering of the truth instead of the truth. +/// +/// `` is normally `~/.hermes`. `HERMES_HOME` overrides it for Docker and +/// custom deployments, and may itself already point *at* a profile — hence the +/// `*/profiles/*` trim, which recovers the root from both layouts exactly as +/// `hermes_constants.get_default_hermes_root` does. +/// +/// The `default` profile is the root directory itself and never appears under +/// `profiles/`, so it is emitted separately. It earns its own entry because the +/// plain `hermes-acp` entry runs whatever profile is *sticky*: once the operator +/// runs `hermes profile use matt`, nothing else can pin the built-in profile. +/// +/// The shell never *evaluates* a name (only `printf '%s'`), and +/// [`is_hermes_profile_name`] re-checks every name that survives — but the +/// `case` charset arms are **not** merely a prefilter. They are the only thing +/// that stops a name containing a newline from printing a second, unlabeled +/// line that [`parse_probes`] accepts as a four-field probe record for any +/// candidate it names: such a line carries no `hermes-profile\t` prefix, so +/// [`hermes_profiles`] never sees it and no Rust-side check applies. Removing +/// them lets a hostile directory name pin an arbitrary +/// `BUZZ_ACP_AGENT_COMMAND`. Pinned by +/// `a_profile_directory_name_cannot_forge_a_probe_record`. +fn hermes_profiles_block() -> String { + format!( + r#"if _hb=$(command -v hermes 2>/dev/null) && [ -n "$_hb" ]; then + _hr=${{HERMES_HOME:-${{HOME:-}}/.hermes}} + case "$_hr" in */profiles/*) _hr=${{_hr%/profiles/*}} ;; esac + _hc=0 + if [ -d "$_hr" ]; then + printf 'hermes-profile\tdefault\n' + _hc=1 + fi + for _hd in "$_hr"/profiles/*/; do + [ "$_hc" -lt {cap} ] || break + [ -d "$_hd" ] || continue + _hn=${{_hd%/}} + _hn=${{_hn##*/}} + case "$_hn" in + default) continue ;; + *[!abcdefghijklmnopqrstuvwxyz0123456789_-]*) continue ;; + [!abcdefghijklmnopqrstuvwxyz0123456789]*) continue ;; + esac + _hc=$((_hc + 1)) + printf 'hermes-profile\t%s\n' "$_hn" + done +fi +: +"#, + cap = MAX_HERMES_PROFILES, + ) +} + +/// The one probe script: `buzz-acp`, every harness candidate, and — only where +/// `hermes` resolves — that host's Hermes profiles. +fn discover_script(config: &SshConfig) -> String { + let mut script = String::from(PROBE_PREAMBLE); + let acp = config.buzz_acp_path.as_deref().unwrap_or("buzz-acp"); + script.push_str(&format!("probe 'buzz-acp' {}\n", quote(acp))); + for candidate in CANDIDATES { + for command in candidate.commands { + script.push_str(&format!( + "probe {} {}\n", + quote(candidate.id), + quote(command) + )); + } + } + // The Hermes CLI, which is what a per-profile entry runs — the `hermes-acp` + // shim takes no arguments of its own, so it cannot carry `--profile`. + // + // `hermes --version` costs ~0.7s against the 40s budget. Fine for one, but + // the probes are sequential: further CLI probes need to be weighed against + // that budget rather than simply appended. + script.push_str(&format!("probe {} 'hermes'\n", quote(HERMES_CLI_KEY))); + script.push_str(&hermes_profiles_block()); + script +} + +/// A profile name this crate is willing to put in a catalog id and in +/// `agent_args`. +/// +/// The authority for the rule the script only prefilters. Names arrive from a +/// directory listing on a machine the desktop does not control, so they are +/// untrusted input on their way into a JSON document and then into an argument +/// vector — the exact shape of a name is the whole security surface. +/// +/// The charset is Hermes's own `_PROFILE_ID_RE` (`^[a-z0-9][a-z0-9_-]{0,63}$`), +/// which is also, not by coincidence, a subset of the desktop's harness-id rule +/// `[a-z0-9_][a-z0-9_-]*` — so `hermes-` is always a legal id and +/// `validate_harness_definition` cannot silently drop the entry. Anything else +/// is skipped rather than sanitized: a mangled name would name a profile that +/// does not exist, and deploy an agent pointing at nothing. +fn is_hermes_profile_name(name: &str) -> bool { + if name.len() > 64 { + return false; + } + let mut chars = name.chars(); + // `let-else` also covers the empty name: no first character, no match. + let Some(first) = chars.next() else { + return false; + }; + if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { + return false; + } + chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') +} + +/// The accepted profile names from one probe run, in host order, deduplicated +/// and capped. +/// +/// Returns `(names, skipped)` — `skipped` counts records the charset rule +/// refused, which is worth surfacing rather than swallowing: it is the only +/// signal that a host has profiles the picker deliberately did not offer. +fn hermes_profiles(stdout: &str) -> (Vec<&str>, usize) { + let mut names: Vec<&str> = Vec::new(); + let mut skipped = 0usize; + for line in stdout.lines() { + let Some(name) = line.strip_prefix(HERMES_PROFILE_PREFIX) else { + continue; + }; + if !is_hermes_profile_name(name) { + skipped += 1; + continue; + } + if names.contains(&name) { + continue; + } + if names.len() >= MAX_HERMES_PROFILES { + skipped += 1; + continue; + } + names.push(name); + } + (names, skipped) +} + +/// One `probe` record: `keycommandpathversion`. +struct Probe<'a> { + key: &'a str, + command: &'a str, + path: &'a str, + version: &'a str, +} + +fn parse_probes(stdout: &str) -> Vec> { + stdout + .lines() + .filter_map(|line| { + let mut fields = line.splitn(4, '\t'); + let probe = Probe { + key: fields.next()?, + command: fields.next()?, + path: fields.next()?, + version: fields.next().unwrap_or(""), + }; + (!probe.key.is_empty() && !probe.path.is_empty()).then_some(probe) + }) + .collect() +} + +/// Shape the probe records into the `discover_harnesses` response. +/// +/// Every element is a `HarnessDefinition` (camelCase, exactly the desktop's +/// own wire type) plus `available` / `binaryPath` / `version`. Unresolved +/// candidates are still reported, with `available: false`, so the picker can +/// say "install this on the host" instead of hiding the option. +fn harnesses_response(stdout: &str) -> serde_json::Value { + let probes = parse_probes(stdout); + let buzz_acp = probes + .iter() + .find(|probe| probe.key == "buzz-acp") + .map(|probe| serde_json::json!({ "path": probe.path, "version": probe.version })) + .unwrap_or(serde_json::Value::Null); + + let mut harnesses: Vec = CANDIDATES + .iter() + .map(|candidate| { + let found = probes.iter().find(|probe| probe.key == candidate.id); + serde_json::json!({ + "id": candidate.id, + "label": candidate.label, + // The remote command name. This is what the desktop pins as the + // create-time harness override, so it must name a binary on the + // HOST, never one resolved locally. + "command": found.map_or(candidate.commands[0], |probe| probe.command), + "args": candidate.args, + "env": candidate + .env + .iter() + .map(|(key, value)| ((*key).to_string(), serde_json::Value::from(*value))) + .collect::>(), + "installInstructionsUrl": "", + "installHint": "", + "available": found.is_some(), + "binaryPath": found.map(|probe| probe.path), + "version": found.map(|probe| probe.version).filter(|v| !v.is_empty()), + }) + }) + .collect(); + + harnesses.extend(hermes_profile_harnesses(&probes, stdout)); + + // `buzz_acp: null` with `ok: true` is deliberate: the UI can then render an + // actionable "install buzz-acp on this host" instead of a bare failure. + serde_json::json!({ "ok": true, "buzz_acp": buzz_acp, "harnesses": harnesses }) +} + +/// One extra catalog entry per Hermes profile on the host. +/// +/// A profile is a separate `HERMES_HOME` — its own SOUL, memory, skills, +/// credentials and gateway — so "Hermes (matt)" and "Hermes (paul)" are two +/// different agents, not one agent configured twice. The operator's whole fleet +/// is one profile per teammate, and without these entries the picker can only +/// ever pin the *sticky* profile, leaving the rest unreachable through the +/// normal create flow. +/// +/// The command is `hermes` with `["--profile", , "acp"]` rather than the +/// `hermes-acp` shim: `--profile` is a global pre-subcommand flag, and the shim +/// forwards no arguments of its own. The pin therefore has to name the CLI +/// directly, which is also why the entries only appear when `hermes` itself +/// resolved — a host with only the shim gets exactly the plain entry. +/// +/// The plain `hermes` candidate stays as-is and remains the default option: it +/// runs whichever profile is sticky, which is what a single-profile host wants. +/// +/// These are the only entries that carry `exclusive: true`. `claude`, `codex` +/// and the plain `hermes-acp` shim are ephemeral runners — deploying one of +/// them N times against a host is the normal, intended shape. A profile is the +/// opposite: it is a persistent IDENTITY (its own memory, sessions, credentials +/// and nostr history), so two Buzz agents pinned to the same profile are two +/// puppeteers driving one body — they interleave turns into the same session +/// store. The flag is what lets the desktop refuse the second one; the provider +/// only states the fact, and says nothing about how it is rendered. +fn hermes_profile_harnesses(probes: &[Probe<'_>], stdout: &str) -> Vec { + // No Hermes CLI on the host means no way to pass `--profile`, so no + // per-profile entries — regardless of what the profile records claim. + let Some(cli) = probes.iter().find(|probe| probe.key == HERMES_CLI_KEY) else { + return Vec::new(); + }; + let (names, skipped) = hermes_profiles(stdout); + if skipped > 0 { + // stderr, never stdout: stdout is this provider's single JSON response. + eprintln!( + "buzz-backend-ssh: skipped {skipped} Hermes profile(s) whose names are not \ + [a-z0-9][a-z0-9_-]* or that exceeded the {MAX_HERMES_PROFILES}-profile cap" + ); + } + + names + .into_iter() + .map(|name| { + serde_json::json!({ + // `hermes-` + a validated name, so this always satisfies the + // desktop's `[a-z0-9_][a-z0-9_-]*` harness-id rule. + "id": format!("hermes-{name}"), + "label": format!("Hermes ({name})"), + "command": cli.command, + "args": ["--profile", name, "acp"], + "env": serde_json::Map::new(), + "installInstructionsUrl": "", + "installHint": "", + // A persistent identity, not an ephemeral runner: at most one + // agent may be pinned to this exact command+args. Emitted ONLY + // here — every other entry omits the key, and an absent key + // means "deploy as many as you like". + "exclusive": true, + // The profile directory was listed and the CLI resolved this + // pass, so the entry is as available as the plain one. + "available": true, + "binaryPath": cli.path, + "version": Some(cli.version).filter(|v| !v.is_empty()), + }) + }) + .collect() +} + +pub fn discover_harnesses( + config: &SshConfig, + session: &Session, +) -> Result { + let output = session.run(&discover_script(config), Duration::from_secs(40))?; + if !output.ok() { + return Err(output.failure().into()); + } + Ok(harnesses_response(&output.stdout)) +} + +/// `check`: the preflight the create dialog runs before Deploy goes live. +pub fn check(session: &Session) -> Result { + let output = session.run("echo buzz-ok\n", Duration::from_secs(8))?; + if output.stdout.trim() == "buzz-ok" { + return Ok(serde_json::json!({ "ok": true, "detail": "Connected" })); + } + Err(guidance(&output.failure()).into()) +} + +/// Turn ssh's own diagnosis into something the user can act on. The classified +/// causes are the ones that actually happen; everything else passes through +/// verbatim rather than being flattened into a generic message. +/// +/// Deliberately no entry for the Tailscale re-auth prompt: `run` returns that +/// as a typed [`Failure`] carrying the URL, so it never reaches this classifier +/// — which is the point of the typed carrier. +fn guidance(failure: &str) -> String { + const GUIDANCE: &[(&str, &str)] = &[ + ("permission denied", "add your public key to ~/.ssh/authorized_keys on the server, or run `tailscale set --ssh` there."), + ("host key verification failed", "the server's host key is not in your known_hosts. Connect once with `ssh` to review and accept it."), + ("could not resolve hostname", "check the address, or confirm the device is on your tailnet."), + ("connection refused", "confirm the server is reachable and running an SSH daemon."), + ("connection timed out", "confirm the server is reachable and running an SSH daemon."), + ]; + + let lower = failure.to_lowercase(); + match GUIDANCE.iter().find(|(cause, _)| lower.contains(cause)) { + Some((_, advice)) => format!("{failure} — {advice}"), + None => failure.to_string(), + } +} + +/// `probe_models`: run `buzz-acp models --json` on the host and hand the raw +/// document back untouched. +/// +/// Verbatim is the point: the desktop feeds it straight into the same +/// `normalize_agent_models` the local path uses, so the model picker needs no +/// remote-specific code at all. +pub fn probe_models( + request: &serde_json::Value, + config: &SshConfig, + session: &Session, +) -> Result { + let output = session.run(&models_script(request, config)?, Duration::from_secs(110))?; + if !output.ok() { + return Err(output.failure().into()); + } + let models_raw: serde_json::Value = + serde_json::from_str(output.stdout.trim()).map_err(|e| { + format!( + "`buzz-acp models --json` did not return JSON ({e}): {}", + snippet(&output.stdout) + ) + })?; + Ok(serde_json::json!({ "ok": true, "models_raw": models_raw })) +} + +fn models_script(request: &serde_json::Value, config: &SshConfig) -> Result { + let harness = request + .get("harness") + .ok_or("probe_models request is missing 'harness'")?; + let command = harness + .get("command") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or("probe_models harness is missing 'command'")?; + let args = string_list(harness.get("args")); + + // `agent.env_vars` is the only place `env_secrets_from_request` + // (backend.rs) looks when scrubbing these values out of an error surface, + // so a top-level `model_env` would travel unredacted through any failure + // message. Still accepted, since the transport is safe either way; it just + // loses that second layer. + let model_env = request + .get("agent") + .and_then(|agent| agent.get("env_vars")) + .or_else(|| request.get("model_env")); + + let mut script = String::from("set -u\n"); + // Model-probe env carries provider API keys, set inside the + // stdin-delivered script so they never appear in the remote argv. + // + // Names are validated rather than quoted: on the left of an assignment + // quoting has no effect, so an unchecked name is a straight command + // injection (`X=1; touch /tmp/pwn`). Quoting is sufficient for values. + for (key, value) in crate::deploy::env_map(model_env) { + if !crate::deploy::is_well_formed_env_key(&key) { + return Err(format!("env var name '{key}' is not a valid identifier")); + } + script.push_str(&format!("export {}={}\n", key, quote(&value))); + } + script.push_str(&format!( + "export BUZZ_ACP_AGENT_COMMAND={}\nexport BUZZ_ACP_AGENT_ARGS={}\n", + quote(command), + quote(&args.join(",")) + )); + script.push_str(&format!( + "exec {} models --json ) -> Vec { + value + .and_then(|v| v.as_array()) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str()) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> SshConfig { + SshConfig { + host: "vps".into(), + ..SshConfig::default() + } + } + + #[test] + fn discover_is_one_script_covering_every_candidate() { + let script = discover_script(&config()); + assert!(script.contains("probe 'buzz-acp' 'buzz-acp'")); + for candidate in CANDIDATES { + for command in candidate.commands { + assert!( + script.contains(&format!("probe '{}' '{command}'", candidate.id)), + "missing probe for {command}" + ); + } + } + // Children must not read the script off the shell's own stdin. + assert!(script.contains(" String { + let mut stdout = String::from( + "hermes\thermes-acp\t/home/ubuntu/.local/bin/hermes-acp\t0.19.0\n\ + hermes-cli\thermes\t/home/ubuntu/.local/bin/hermes\tHermes Agent v0.19.0\n", + ); + for profile in profiles { + stdout.push_str(&format!("hermes-profile\t{profile}\n")); + } + stdout + } + + fn entry<'a>(response: &'a serde_json::Value, id: &str) -> &'a serde_json::Value { + response["harnesses"] + .as_array() + .unwrap() + .iter() + .find(|h| h["id"] == id) + .unwrap_or_else(|| panic!("no harness entry {id}")) + } + + #[test] + fn each_hermes_profile_becomes_its_own_catalog_entry() { + let response = harnesses_response(&hermes_stdout(&["default", "matt", "paul"])); + let harnesses = response["harnesses"].as_array().unwrap(); + assert_eq!(harnesses.len(), CANDIDATES.len() + 3); + + let matt = entry(&response, "hermes-matt"); + assert_eq!(matt["label"], "Hermes (matt)"); + // The CLI, not the shim: `--profile` is a global pre-subcommand flag + // and the shim forwards no arguments of its own. + assert_eq!(matt["command"], "hermes"); + assert_eq!( + matt["args"], + serde_json::json!(["--profile", "matt", "acp"]) + ); + assert_eq!(matt["available"], true); + assert_eq!(matt["binaryPath"], "/home/ubuntu/.local/bin/hermes"); + assert_eq!(matt["version"], "Hermes Agent v0.19.0"); + assert_eq!(matt["env"], serde_json::json!({})); + + // The sticky default keeps its own entry — once `hermes profile use` + // moves the sticky pointer, nothing else can pin the built-in profile. + assert_eq!( + entry(&response, "hermes-default")["args"], + serde_json::json!(["--profile", "default", "acp"]) + ); + // And the plain shim entry is untouched, still the default option. + let plain = entry(&response, "hermes"); + assert_eq!(plain["command"], "hermes-acp"); + assert_eq!(plain["args"], serde_json::json!([])); + } + + #[test] + fn only_per_profile_entries_are_marked_exclusive() { + // A profile is a persistent identity: at most one agent may be pinned + // to it. Every other entry is an ephemeral runner and must OMIT the key + // entirely — an absent field is what the desktop reads as "no limit", + // so emitting `false` would be a different (and needless) contract. + let response = harnesses_response(&hermes_stdout(&["default", "matt"])); + for id in ["hermes-default", "hermes-matt"] { + assert_eq!(entry(&response, id)["exclusive"], true, "{id}"); + } + for entry_value in response["harnesses"].as_array().unwrap() { + let id = entry_value["id"].as_str().unwrap(); + if id.starts_with("hermes-") { + continue; + } + assert!( + entry_value.get("exclusive").is_none(), + "{id} must not advertise 'exclusive'" + ); + } + } + + #[test] + fn a_host_without_hermes_gets_byte_identical_todays_catalog() { + // The degradation contract: absent Hermes, the response is exactly what + // it was before per-profile entries existed. + let stdout = "buzz-acp\tbuzz-acp\t/usr/local/bin/buzz-acp\t0.4.26\n\ + goose\tgoose\t/usr/bin/goose\tgoose 1.9.0\n"; + let response = harnesses_response(stdout); + assert_eq!( + response["harnesses"].as_array().unwrap().len(), + CANDIDATES.len() + ); + let expected: Vec = CANDIDATES + .iter() + .map(|candidate| { + let available = candidate.id == "goose"; + serde_json::json!({ + "id": candidate.id, + "label": candidate.label, + "command": candidate.commands[0], + "args": candidate.args, + "env": candidate + .env + .iter() + .map(|(k, v)| ((*k).to_string(), serde_json::Value::from(*v))) + .collect::>(), + "installInstructionsUrl": "", + "installHint": "", + "available": available, + "binaryPath": available.then_some("/usr/bin/goose"), + "version": available.then_some("goose 1.9.0"), + }) + }) + .collect(); + assert_eq!(response["harnesses"], serde_json::Value::from(expected)); + } + + #[test] + fn hermes_with_no_profile_records_is_just_the_plain_entry() { + // A stdout carrying no profile records at all — what a *missing* Hermes + // root produces — is not an error: the shim entry still runs the sticky + // profile. A root that exists without a `profiles/` store is a + // different stdout, pinned by + // `a_hermes_root_without_a_profiles_store_still_advertises_default`. + let response = harnesses_response(&hermes_stdout(&[])); + assert_eq!(response["ok"], true); + assert_eq!( + response["harnesses"].as_array().unwrap().len(), + CANDIDATES.len() + ); + assert_eq!(entry(&response, "hermes")["available"], true); + } + + #[test] + fn profile_records_without_the_hermes_cli_produce_nothing() { + // Only the shim resolved, so there is no binary that accepts + // `--profile` — pinning one would deploy an agent that cannot start. + let stdout = "hermes\thermes-acp\t/usr/bin/hermes-acp\t0.19.0\n\ + hermes-profile\tmatt\n"; + let response = harnesses_response(stdout); + assert_eq!( + response["harnesses"].as_array().unwrap().len(), + CANDIDATES.len() + ); + } + + #[test] + fn hostile_profile_names_are_skipped_never_sanitized() { + // A mangled name would pin a profile that does not exist. Every one of + // these must be dropped whole. + for hostile in [ + "a b", + "a'b", + "$(touch /tmp/pwn)", + "`id`", + "a;b", + "../escape", + ".hidden", + "-leading-dash", + "_leading-underscore", + "UPPER", + "naïve", + "a/b", + "a\\b", + "", + &"x".repeat(65), + ] { + assert!( + !is_hermes_profile_name(hostile), + "{hostile:?} must be refused" + ); + } + for ok in ["default", "matt", "msig-web-analyst", "a_b", "x9", "9x"] { + assert!(is_hermes_profile_name(ok), "{ok:?} must be accepted"); + } + } + + #[test] + fn a_hostile_name_that_reaches_stdout_is_dropped_from_the_catalog() { + // Belt and braces: even if the script's own prefilter were bypassed, + // nothing hostile reaches the JSON. + let response = harnesses_response(&hermes_stdout(&["matt", "$(id)", "Bad", "paul"])); + let ids: Vec<&str> = response["harnesses"] + .as_array() + .unwrap() + .iter() + .map(|h| h["id"].as_str().unwrap()) + .collect(); + assert!(ids.contains(&"hermes-matt") && ids.contains(&"hermes-paul")); + assert!(!ids.iter().any(|id| id.contains("$(") || id.contains("Bad"))); + // No mangled survivor either — the count is exactly the two good ones. + assert_eq!(ids.len(), CANDIDATES.len() + 2); + } + + #[test] + fn the_profile_count_is_capped_against_a_pathological_host() { + let many: Vec = (0..200).map(|i| format!("p{i}")).collect(); + let refs: Vec<&str> = many.iter().map(String::as_str).collect(); + let response = harnesses_response(&hermes_stdout(&refs)); + assert_eq!( + response["harnesses"].as_array().unwrap().len(), + CANDIDATES.len() + MAX_HERMES_PROFILES + ); + } + + #[test] + fn a_repeated_profile_name_yields_one_entry() { + // Duplicate ids would collide in the desktop's catalog. + let response = harnesses_response(&hermes_stdout(&["matt", "matt"])); + assert_eq!( + response["harnesses"].as_array().unwrap().len(), + CANDIDATES.len() + 1 + ); + } + + #[test] + fn every_profile_entry_id_satisfies_the_desktop_harness_id_rule() { + // `validate_harness_definition` drops any entry whose id does not match + // `[a-z0-9_][a-z0-9_-]*`, so a legal profile name must always produce a + // legal id — otherwise the entry silently vanishes desktop-side. + for name in ["default", "matt", "msig-web-analyst", "a_b", "9x"] { + let id = format!("hermes-{name}"); + let mut chars = id.chars(); + let first = chars.next().unwrap(); + assert!(first.is_ascii_lowercase() || first.is_ascii_digit() || first == '_'); + assert!( + chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + ); + } + } + + #[test] + fn profile_records_never_masquerade_as_probe_records() { + // Two fields, not four, so `parse_probes` drops them on its own and a + // profile can never be mistaken for a resolved binary. + let stdout = hermes_stdout(&["matt"]); + let probes = parse_probes(&stdout); + assert_eq!(probes.len(), 2); + assert!(probes.iter().all(|p| p.key != "hermes-profile")); + } + + /// The script must only enumerate profiles on a host that has `hermes`, + /// and must never let a name be evaluated by the shell. + #[test] + fn the_script_gates_profile_enumeration_on_hermes_being_present() { + let script = discover_script(&config()); + assert!(script.contains("probe 'hermes-cli' 'hermes'")); + assert!(script.contains(r#"if _hb=$(command -v hermes 2>/dev/null)"#)); + // Names are printed as data, never expanded or executed. + assert!(script.contains(r#"printf 'hermes-profile\t%s\n' "$_hn""#)); + // The shell-side cap tracks the Rust constant. + assert!(script.contains(&format!(r#"[ "$_hc" -lt {MAX_HERMES_PROFILES} ] || break"#))); + } + + /// Run the real generated script through `/bin/sh` against a fake host + /// rooted at `root`, with `HERMES_HOME` set to `hermes_home`. Returns the + /// script's stdout. + #[cfg(unix)] + fn run_discover_script(root: &std::path::Path, hermes_home: &std::path::Path) -> String { + // Stub `hermes` so `command -v hermes` resolves on the fake host. + let bin = root.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let hermes = bin.join("hermes"); + std::fs::write(&hermes, "#!/bin/sh\nexit 0\n").unwrap(); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&hermes, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let output = std::process::Command::new("/bin/sh") + .arg("-s") + .env_clear() + .env("HOME", root) + .env("HERMES_HOME", hermes_home) + .env("PATH", format!("{}:/usr/bin:/bin", bin.display())) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(discover_script(&config()).as_bytes()) + .unwrap(); + child.wait_with_output() + }) + .unwrap(); + assert!( + output.status.success(), + "script failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() + } + + /// Every probe key the script is allowed to emit. Anything else on stdout + /// in four-field shape is a forged record. + #[cfg(unix)] + fn expected_probe_keys() -> Vec<&'static str> { + let mut keys = vec!["buzz-acp", HERMES_CLI_KEY]; + keys.extend(CANDIDATES.iter().map(|candidate| candidate.id)); + keys + } + + /// Execute the real generated script against `/bin/sh` over a fake host + /// layout. Substring assertions prove the script *says* the right things; + /// only running it proves the `case` globs, the `${_hd%/}` trimming and the + /// `HERMES_HOME` recovery actually behave — and that a directory named + /// `$(touch …)` stays inert rather than being evaluated. + #[cfg(unix)] + #[test] + fn the_generated_script_enumerates_a_real_profile_directory_safely() { + let root = + std::env::temp_dir().join(format!("buzz-hermes-profiles-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let profiles = root.join("hermes/profiles"); + std::fs::create_dir_all(&profiles).unwrap(); + + let canary = root.join("pwn"); + for name in [ + // Normal names, including the operator's real fleet shapes. + "matt", + "paul", + "msig-web-analyst", + "codex_worker", + // Hostile: spaces, quotes, and a command substitution that must + // stay a literal directory name. + "evil name", + "ev'il", + &format!("$(touch {})", canary.display()), + // A dotfile, which is not a profile and is not matched by `*/`. + ".hidden", + // Uppercase, which Hermes itself would refuse. + "Upper", + ] { + std::fs::create_dir_all(profiles.join(name)).unwrap(); + } + // A plain file under profiles/ is not a profile. + std::fs::write(profiles.join("notes.md"), "x").unwrap(); + + // Exercises the Docker/custom layout AND the `*/profiles/*` trim by + // pointing HERMES_HOME at a profile rather than at the root. + let stdout = run_discover_script(&root, &root.join("hermes/profiles/matt")); + // Nothing under `profiles/` was ever executed. + assert!( + !canary.exists(), + "a directory name was evaluated by the shell" + ); + + let (names, _) = hermes_profiles(&stdout); + assert_eq!( + names, + vec![ + "default", + "codex_worker", + "matt", + "msig-web-analyst", + "paul" + ], + "stdout was: {stdout}" + ); + + // And the response built from that real stdout parses, with the args + // arrays the deploy pin will carry. + let response: serde_json::Value = + serde_json::from_str(&harnesses_response(&stdout).to_string()).unwrap(); + assert_eq!( + entry(&response, "hermes-msig-web-analyst")["args"], + serde_json::json!(["--profile", "msig-web-analyst", "acp"]) + ); + assert_eq!(entry(&response, "hermes-matt")["command"], "hermes"); + + std::fs::remove_dir_all(&root).unwrap(); + } + + /// The forging path, which the labeled `hermes-profile` stream never + /// exercises: a directory name carrying a newline plus three tab-separated + /// fields prints a *second*, unlabeled line that [`parse_probes`] would + /// accept as a four-field probe record for any candidate it names. Nothing + /// downstream can catch it — `hermes_profiles` only ever sees the labeled + /// prefix — so the script's charset `case` arms are the whole defense, and + /// this is what pins them. + #[cfg(unix)] + #[test] + fn a_profile_directory_name_cannot_forge_a_probe_record() { + let root = std::env::temp_dir().join(format!("buzz-hermes-forge-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let profiles = root.join("hermes/profiles"); + std::fs::create_dir_all(&profiles).unwrap(); + std::fs::create_dir_all(profiles.join("matt")).unwrap(); + // Reads on stdout as `hermes-profilex` followed by a complete + // `claudeevil-acptmp-evil-acp9.9.9` record. + std::fs::create_dir_all(profiles.join("x\nclaude\tevil-acp\ttmp-evil-acp\t9.9.9")).unwrap(); + + let stdout = run_discover_script(&root, &root.join("hermes")); + + let keys: Vec<&str> = parse_probes(&stdout).iter().map(|p| p.key).collect(); + let expected = expected_probe_keys(); + assert!( + keys.iter().all(|key| expected.contains(key)), + "a directory name forged a probe record; keys were {keys:?}, stdout was: {stdout:?}" + ); + + // The concrete consequence the record would have had: `claude` claiming + // to be installed, pinned as the deploy's BUZZ_ACP_AGENT_COMMAND. + let response = harnesses_response(&stdout); + let claude = entry(&response, "claude"); + assert_eq!(claude["available"], false, "stdout was: {stdout:?}"); + assert_eq!(claude["command"], "claude-agent-acp"); + assert!(claude["binaryPath"].is_null()); + + // The name is not a legal profile either, so it adds no entry. + let (names, _) = hermes_profiles(&stdout); + assert_eq!(names, vec!["default", "matt"], "stdout was: {stdout:?}"); + + std::fs::remove_dir_all(&root).unwrap(); + } + + /// A Hermes root that exists but has no `profiles/` store: the `default` + /// entry still ships, because the root directory *is* the default profile. + /// Distinct from a missing root, which emits nothing at all. + #[cfg(unix)] + #[test] + fn a_hermes_root_without_a_profiles_store_still_advertises_default() { + let root = + std::env::temp_dir().join(format!("buzz-hermes-noprofiles-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("hermes")).unwrap(); + + let stdout = run_discover_script(&root, &root.join("hermes")); + let (names, _) = hermes_profiles(&stdout); + assert_eq!(names, vec!["default"], "stdout was: {stdout:?}"); + + let response = harnesses_response(&stdout); + assert_eq!( + response["harnesses"].as_array().unwrap().len(), + CANDIDATES.len() + 1 + ); + assert_eq!( + entry(&response, "hermes-default")["args"], + serde_json::json!(["--profile", "default", "acp"]) + ); + + std::fs::remove_dir_all(&root).unwrap(); + } + + /// A missing Hermes root — the case `hermes_stdout(&[])` models — emits no + /// profile records at all, leaving only the plain shim entry. + #[cfg(unix)] + #[test] + fn a_missing_hermes_root_advertises_no_profiles() { + let root = std::env::temp_dir().join(format!("buzz-hermes-noroot-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + + let stdout = run_discover_script(&root, &root.join("hermes")); + let (names, _) = hermes_profiles(&stdout); + assert!(names.is_empty(), "stdout was: {stdout:?}"); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn guidance_is_actionable_for_the_cases_that_happen() { + assert!( + guidance("ssh failed (exit 255): Permission denied (publickey).") + .contains("authorized_keys") + ); + assert!( + guidance("ssh failed (exit 255): Host key verification failed.") + .contains("known_hosts") + ); + assert!( + guidance("ssh failed (exit 255): ssh: Could not resolve hostname vps") + .contains("tailnet") + ); + // Unclassified failures survive verbatim rather than being flattened. + assert_eq!( + guidance("ssh failed (exit 1): weird"), + "ssh failed (exit 1): weird" + ); + } + + #[test] + fn model_env_is_exported_inside_the_script_never_on_the_argv() { + // Nested under `agent.env_vars`, the one shape the desktop's + // `env_secrets_from_request` scrubber knows how to find. + let request = serde_json::json!({ + "harness": { "command": "goose", "args": ["acp"] }, + "agent": { "env_vars": { "ANTHROPIC_API_KEY": "sk-ant-secret" } }, + }); + let script = models_script(&request, &config()).unwrap(); + assert!(script.contains("export ANTHROPIC_API_KEY='sk-ant-secret'")); + assert!(script.contains("export BUZZ_ACP_AGENT_COMMAND='goose'")); + assert!(script.contains("export BUZZ_ACP_AGENT_ARGS='acp'")); + // The value is only ever in the script body, which travels on stdin — + // the remote argv is fixed at `sh -s` by `Session`. + assert!(script.contains("exec 'buzz-acp' models --json `,** never `PATH` alone. +//! A non-interactive SSH command reads no profile, so the install destination +//! is not on the ambient `PATH` — which is precisely why the unit's env file +//! pins `PATH="$HOME/.local/bin:$PATH"` itself. A `command -v`-only rule would +//! therefore never find the copy a previous deploy installed, and because +//! deploy is the start path, every start would re-stream the binary and swap it +//! underneath a running fleet. [`resolve`] is that rule, and the probe asks the +//! same question so the two can never disagree. +//! +//! **Staleness rule: push-when-missing only.** A host that already resolves a +//! tool keeps the copy it has, whatever its version. Deploy is also the start +//! path — `start_managed_agent` re-enters it — so a version-comparing rule +//! would silently reinstall underneath a running fleet on every start, and a +//! desktop pinned to an older artifact would *downgrade* the host. Refreshing +//! an existing install is a deliberate act and belongs to a follow-up that +//! fetches release artifacts by version; see `docs/remote-agents.md`. + +use base64::Engine as _; +use sha2::{Digest, Sha256}; + +/// Refuse to embed anything larger than this. A release `buzz-acp` is 10-30 MB +/// and the `buzz` CLI is smaller; base64 inflates either by a third and the +/// result travels as one script on the SSH stdin channel, so a wrong path (a +/// disk image, a core dump, a directory of them) must fail here rather than +/// stream for minutes and then fail on the host. +const MAX_BINARY_BYTES: usize = 200 * 1024 * 1024; + +/// base64 line width. GNU `base64` wraps at 76 by default and `-d` ignores +/// newlines; one 40 MB line is legal but pathological for anything that reads +/// the script line-wise — including this crate's own tests. +const LINE_WIDTH: usize = 76; + +/// Where an installed tool lands. Unexpanded on purpose: it is emitted into the +/// script and expanded by the *host's* shell, whose `$HOME` is the only one +/// that matters. +const INSTALL_DIR: &str = "$HOME/.local/bin"; + +/// The marker every non-fatal host-side complaint carries, so `deploy` can +/// forward exactly those lines and nothing else from a successful run's stderr +/// (`deploy::deploy`). Structural, not decorative: a successful deploy's remote +/// stderr is otherwise discarded, and a warning nobody sees is not a warning. +pub const WARNING_PREFIX: &str = "WARNING: "; + +/// What it means for the host to resolve no copy of a tool while the desktop +/// pushed none either. +/// +/// This is the *whole* difference between `buzz-acp` and the `buzz` CLI. See +/// the module docs: the harness cannot run without the former and runs fine +/// (just slower and blinder) without the latter. +#[derive(Clone, Copy)] +enum Missing { + /// Stop the deploy: `code` is the script's exit status. + Fatal { code: u16, message: &'static str }, + /// Say so on stderr and carry on. + Warn { message: &'static str }, +} + +impl Missing { + /// The shell that reacts to an empty `$var` after [`resolve`] ran. + fn block(self, var: &str) -> String { + match self { + Self::Fatal { code, message } => { + format!(r#"if [ -z "${var}" ]; then echo "{message}" >&2; exit {code}; fi"#) + } + Self::Warn { message } => { + format!(r#"if [ -z "${var}" ]; then echo "{WARNING_PREFIX}{message}" >&2; fi"#) + } + } + } +} + +/// One host-side tool this crate can verify or install. +/// +/// Constructible only through the two constants below: everything else in the +/// crate names [`ACP`] or [`CLI`] rather than describing a tool of its own, so +/// there is exactly one place where a tool's name, delimiter and +/// missing-on-host policy are decided together. +#[derive(Clone, Copy)] +pub struct Tool { + /// The binary's name — the `command -v` argument in the default case, and + /// the file name under [`INSTALL_DIR`]. + pub name: &'static str, + /// The heredoc delimiter for this tool's encoded payload. Contains `_`, + /// which is not in the base64 alphabet, so no data line can ever terminate + /// the heredoc early. `delimiters_cannot_appear_in_encoded_data` pins that. + delimiter: &'static str, + /// The shell variable the resolution block leaves the absolute path in. + var: &'static str, + /// What an unresolved, un-pushed tool means for the deploy. + missing: Missing, +} + +/// The harness itself. Fail-closed: an agent without it cannot exist. +pub const ACP: Tool = Tool { + name: "buzz-acp", + delimiter: "BUZZ_ACP_B64_EOF", + var: "acp", + missing: Missing::Fatal { + code: 90, + message: "buzz-acp not found on the server's PATH or in ~/.local/bin — install it, or set \ + 'buzz-acp path on the server'", + }, +}; + +/// The agent-facing CLI. An enhancement, never load-bearing — hence +/// [`Missing::Warn`]. The message avoids backticks and `$` on purpose: it is +/// interpolated into a double-quoted `echo` on the host, where either would be +/// a command substitution. +pub const CLI: Tool = Tool { + name: "buzz", + delimiter: "BUZZ_CLI_B64_EOF", + var: "cli", + missing: Missing::Warn { + message: "no 'buzz' CLI on the server's PATH or in ~/.local/bin — agents on this host \ + cannot reply with 'buzz messages send' and will degrade to slower replies; \ + install it there, or set BUZZ_CLI_PUSH_BINARY on the desktop and redeploy", + }, +}; + +/// Where an install of `tool` lands, and the second half of the resolution rule +/// — `~/.local/bin` is the documented convention and the destination below, but +/// it is **not** on a non-interactive SSH `PATH`: the remote shell reads no +/// profile, which is exactly why the unit's env file has to pin +/// `PATH="$HOME/.local/bin:$PATH"` itself. Resolving by `PATH` alone would +/// therefore never see the copy the previous deploy installed, and since deploy +/// is the start path, every agent start would re-stream tens of megabytes and +/// swap the binary underneath a running fleet. +fn install_path(tool: Tool) -> String { + format!("{INSTALL_DIR}/{}", tool.name) +} + +/// A binary read from the desktop's filesystem, encoded for the script and +/// fingerprinted for the host to check. +/// +/// The bytes are not secret — but they must not corrupt the script stream that +/// *is* carrying secrets, which is why only the encoded form is kept. +/// +/// Deliberately not `Debug`, like `deploy::Agent` and `ssh::Output`: a derived +/// one would put tens of megabytes of base64 one `{:?}` away from a log line. +pub struct Payload { + /// base64, wrapped to [`LINE_WIDTH`], one trailing newline per line. + encoded: String, + /// Lowercase hex SHA-256 of the raw bytes. Travels in the script in the + /// clear: it is a fingerprint, not a credential. + sha256: String, +} + +/// The size rejection, or `None` when `len` is within the cap. +/// +/// Split out so the boundary is testable without materializing a 200 MB file, +/// and applied twice in [`Payload::read`] — once to the metadata, once to the +/// bytes actually read. +fn oversized(tool: Tool, len: u64, path: &str) -> Option { + (len > MAX_BINARY_BYTES as u64).then(|| { + format!( + "the {} binary to push is {len} bytes, over the {MAX_BINARY_BYTES}-byte limit: {path}", + tool.name + ) + }) +} + +impl Payload { + /// Read, validate and encode the binary at `path` on the **desktop**. + /// + /// Every rejection here is a failure the host could only report as + /// something far less legible: an `Exec format error` from systemd five + /// seconds after a deploy that looked successful, or a multi-minute stream + /// of a file that was never a binary. `tool` names the offender in each + /// message — the desktop can push two, and "the binary to push" would leave + /// the reader guessing which env var to fix. + pub fn read(tool: Tool, path: &str) -> Result { + let name = tool.name; + let metadata = std::fs::metadata(path) + .map_err(|e| format!("cannot read the {name} binary to push ({path}): {e}"))?; + if !metadata.is_file() { + return Err(format!("the {name} binary to push is not a file: {path}")); + } + // Checked before the read, so a wrong path costs a `stat` rather than + // pulling a disk image into memory. + if let Some(error) = oversized(tool, metadata.len(), path) { + return Err(error); + } + + let bytes = std::fs::read(path) + .map_err(|e| format!("cannot read the {name} binary to push ({path}): {e}"))?; + // Re-checked against the bytes actually read: the metadata above is a + // separate syscall, and the file may have grown between the two. + if let Some(error) = oversized(tool, bytes.len() as u64, path) { + return Err(error); + } + if bytes.is_empty() { + return Err(format!("the {name} binary to push is empty: {path}")); + } + // Deploy targets are Linux + `systemd --user` throughout, and the + // desktop pushing the binary is routinely macOS or Windows. Without + // this check a Mach-O or PE binary installs cleanly and the unit then + // restart-loops on `Exec format error` every five seconds, with the + // deploy having reported success. + if !bytes.starts_with(b"\x7fELF") { + return Err(format!( + "the {name} binary to push is not a Linux (ELF) executable: {path}" + )); + } + + let sha256 = Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + Ok(Self { + encoded: wrap(&base64::engine::general_purpose::STANDARD.encode(&bytes)), + sha256, + }) + } + + /// The fingerprint the host checks the decoded file against. Tests assert + /// against it; the script embeds it through [`resolve_or_install`]. + #[cfg(test)] + pub fn sha256(&self) -> &str { + &self.sha256 + } + + /// The encoded body, so tests can corrupt it the way a truncated stream + /// would. The script embeds it through [`resolve_or_install`]. + #[cfg(test)] + pub fn encoded(&self) -> &str { + &self.encoded + } +} + +/// base64 output is ASCII, so every byte offset is a character boundary and +/// the line breaks can be taken on the `str` directly — no byte round trip, +/// and nothing to unwrap. +fn wrap(encoded: &str) -> String { + let mut out = String::with_capacity(encoded.len() + encoded.len() / LINE_WIDTH + 1); + let mut rest = encoded; + while !rest.is_empty() { + let (line, tail) = rest.split_at(LINE_WIDTH.min(rest.len())); + out.push_str(line); + out.push('\n'); + rest = tail; + } + out +} + +/// The script that asks the host which of `tools` it already resolves. +/// +/// Deploy is also the *start* path, so without this a desktop with the push +/// seams engaged would stream tens of megabytes of base64 on every single agent +/// start, to a host that has had the binaries since the first one. The probe is +/// one cheap round trip — one, whatever the number of tools — that keeps the +/// payloads off the wire in that case. +/// +/// It answers by *printing the name* of each tool it resolved rather than by an +/// exit status, because two tools cannot share one boolean. Each line applies +/// exactly the rule [`resolve_or_install`] applies on the host ([`resolve`]) — +/// `PATH` *or* [`install_path`] — because a probe that only consulted `PATH` +/// would answer "missing" forever for a binary this crate itself installed, and +/// the payload would ride along on every start. +/// +/// Each pair is `(tool, already-quoted command)`; the command differs from the +/// tool's own name only for `buzz-acp`, which the operator may pin to an +/// absolute path. Names are compile-time constants, so nothing attacker-shaped +/// reaches the `echo`. +/// +/// It remains an optimization and never the decision: the deploy script +/// re-checks on the host and installs only into an empty `$var`, so a host that +/// gains or loses a tool between the two round trips still ends up correct. +pub fn probe_script(tools: &[(Tool, String)]) -> String { + tools + .iter() + .map(|(tool, command)| { + format!( + "if command -v {command} >/dev/null 2>&1 || [ -x \"{path}\" ]; then echo {name}; \ + fi\n", + path = install_path(*tool), + name = tool.name, + ) + }) + .collect() +} + +/// Whether [`probe_script`]'s output says the host already has `tool`. +pub fn probe_found(stdout: &str, tool: Tool) -> bool { + stdout.lines().any(|line| line.trim() == tool.name) +} + +/// The `WARNING:` lines a successful deploy's remote stderr carries, scrubbed. +/// +/// A deploy that succeeded discards the rest of that stderr (it is host noise), +/// so this is the one channel by which the script can tell a human something +/// short of a failure — today, that the host has no `buzz` CLI. +pub fn warnings(stderr: &str) -> Vec { + stderr + .lines() + .map(str::trim) + .filter(|line| line.starts_with(WARNING_PREFIX)) + .map(crate::protocol::redact) + .collect() +} + +/// The host-side resolution rule, shared by every caller so the probe, the +/// push path and the un-pushed path can never disagree. +/// +/// Leaves the tool's `$var` holding an absolute path, or empty when the host +/// has none. `command -v` covers a `PATH` install and an absolute configured +/// path; [`install_path`] covers the documented `~/.local/bin` convention, +/// which a non-interactive SSH `PATH` does not contain. +fn resolve(tool: Tool, command: &str) -> String { + let var = tool.var; + let install_path = install_path(tool); + format!( + r#"{var}=$(command -v {command} 2>/dev/null || true) +if [ -z "${var}" ] && [ -x "{install_path}" ]; then {var}="{install_path}"; fi"# + ) +} + +/// The deploy script's resolution block for one tool. +/// +/// `command` is the already-`quote()`d command or path to resolve. Resolution +/// is [`resolve`] in both cases — `PATH`, then the `~/.local/bin` convention — +/// so a deploy that carries no binary still finds one an earlier deploy (or the +/// operator, following the documented convention) put there. +/// +/// With a payload it becomes resolve-or-install, in that order: an installed +/// copy is never replaced, and a host that had none ends the block with `$var` +/// holding the absolute path of the copy just installed — which, for [`ACP`], +/// is what the unit's `ExecStart` is substituted from later in the same pass. +/// +/// Without one, the tool's [`Missing`] policy decides: exit for the harness, +/// a warning for the CLI. +pub fn resolve_or_install(tool: Tool, command: &str, push: Option<&Payload>) -> String { + let resolve = resolve(tool, command); + let var = tool.var; + + let Some(payload) = push else { + let missing = tool.missing.block(var); + return format!( + r#"{resolve} +{missing}"# + ); + }; + + // Every failure below removes the temp file before exiting, and the file is + // only made executable *after* the digest matches, so no path through this + // block can leave a runnable half-written binary in `~/.local/bin`. + // + // The `|| { ... }` on the `base64` line binds to the whole redirected + // command; the heredoc body begins on the following line either way, so the + // decode is guarded rather than left to `set -e`, which would exit before + // the temp file could be removed. + // + // Integrity failures exit for BOTH tools, including the non-load-bearing + // CLI: a payload that arrives damaged says the stream is damaged, and that + // same stream carries the minted nsec and the unit. "Nothing is installed + // unverified" stays one rule; only the *absent-payload* case is asymmetric. + format!( + r#"{resolve} +if [ -z "${var}" ]; then +command -v base64 >/dev/null 2>&1 || {{ echo "the server has no 'base64' (coreutils), so the desktop cannot install {name} on it" >&2; exit 92; }} +command -v sha256sum >/dev/null 2>&1 || {{ echo "the server has no 'sha256sum' (coreutils), and {name} is never installed unverified" >&2; exit 92; }} +{var}_dir="{INSTALL_DIR}" +mkdir -p "${var}_dir" +{var}_tmp="${var}_dir/.{name}.tmp.$$" +base64 -d > "${var}_tmp" <<'{delimiter}' || {{ rm -f "${var}_tmp"; echo "the pushed {name} did not decode on the server" >&2; exit 93; }} +{encoded}{delimiter} +printf '%s %s\n' '{sha256}' "${var}_tmp" | sha256sum -c - >/dev/null 2>&1 || {{ rm -f "${var}_tmp"; echo "the pushed {name} failed its sha256 check on the server — refusing to install it" >&2; exit 94; }} +chmod 755 "${var}_tmp" +mv "${var}_tmp" "${var}_dir/{name}" +{var}="${var}_dir/{name}" +fi"#, + name = tool.name, + delimiter = tool.delimiter, + encoded = payload.encoded, + sha256 = payload.sha256, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A file that is a legal ELF header followed by everything that would + /// break a shell script if it ever reached one unencoded — including both + /// tools' heredoc delimiters. + fn hostile_binary() -> Vec { + let mut bytes = b"\x7fELF\x02\x01\x01\x00".to_vec(); + bytes.extend_from_slice(b"\0\0'\"$(touch /tmp/buzz-should-not-exist)`id`\r\n"); + bytes.extend_from_slice(format!("{}\n", ACP.delimiter).as_bytes()); + bytes.extend_from_slice(format!("{}\n", CLI.delimiter).as_bytes()); + bytes.extend_from_slice(b"\\x00 \x00 ${HOME} $(id -u)\n"); + bytes.extend_from_slice(&(0u8..=255).collect::>()); + bytes + } + + fn write_temp(name: &str, bytes: &[u8]) -> String { + let path = std::env::temp_dir().join(format!("buzz-push-{}-{name}", std::process::id())); + std::fs::write(&path, bytes).unwrap(); + path.display().to_string() + } + + /// `Payload` is intentionally not `Debug` (see its doc comment), so the + /// rejection comes out by hand — the same pattern `deploy::tests` uses for + /// `Agent`. + fn rejection(tool: Tool, path: &str) -> String { + match Payload::read(tool, path) { + Err(error) => error, + Ok(_) => panic!("expected {path} to be rejected, it was accepted"), + } + } + + #[test] + fn encoding_round_trips_bytes_that_would_break_a_shell_script() { + let bytes = hostile_binary(); + let payload = Payload::read(ACP, &write_temp("hostile", &bytes)).unwrap(); + + // The encoded form carries nothing a shell reads as syntax, which is + // the whole reason a binary can travel inside the script at all. + for line in payload.encoded.lines() { + assert!( + line.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'='), + "encoded line left the base64 alphabet: {line}" + ); + assert!(line.len() <= LINE_WIDTH, "unwrapped line: {}", line.len()); + } + + let decoded = base64::engine::general_purpose::STANDARD + .decode(payload.encoded.replace('\n', "")) + .unwrap(); + assert_eq!(decoded, bytes, "base64 round trip lost bytes"); + } + + #[test] + fn delimiters_cannot_appear_in_encoded_data() { + // The heredocs are only safe because `_` is outside the base64 + // alphabet: a payload that could emit its own terminator would end the + // heredoc early and hand the rest of the binary to the shell as + // commands. Both tools' delimiters must hold that property, and they + // must differ so one script can carry both bodies unambiguously. + assert!(ACP.delimiter.contains('_')); + assert!(CLI.delimiter.contains('_')); + assert_ne!(ACP.delimiter, CLI.delimiter); + let payload = Payload::read(CLI, &write_temp("delimiter", &hostile_binary())).unwrap(); + // Even though the *source bytes* literally contain both delimiters. + assert!(!payload.encoded.contains(ACP.delimiter)); + assert!(!payload.encoded.contains(CLI.delimiter)); + } + + #[test] + fn the_digest_is_the_sha256_of_the_raw_bytes() { + let bytes = hostile_binary(); + let payload = Payload::read(ACP, &write_temp("digest", &bytes)).unwrap(); + let expected: String = Sha256::digest(&bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + assert_eq!(payload.sha256(), expected); + assert_eq!(payload.sha256().len(), 64); + } + + #[test] + fn only_a_linux_executable_is_accepted() { + // A Mach-O binary from the desktop installs cleanly and then + // restart-loops on the host with `Exec format error`, five seconds at a + // time, after a deploy that reported success. + let error = rejection(ACP, &write_temp("macho", b"\xcf\xfa\xed\xfe rest")); + assert!(error.contains("ELF"), "{error}"); + + let error = rejection(ACP, &write_temp("empty", b"")); + assert!(error.contains("empty"), "{error}"); + + let missing = std::env::temp_dir().join("buzz-push-does-not-exist"); + let error = rejection(ACP, &missing.display().to_string()); + assert!(error.contains("cannot read"), "{error}"); + + // A directory `stat`s fine and `read` would fail with something far + // less legible, so it is refused by shape rather than by errno. + let error = rejection(ACP, &std::env::temp_dir().display().to_string()); + assert!(error.contains("not a file"), "{error}"); + } + + #[test] + fn every_rejection_names_the_tool_it_is_about() { + // The desktop can push two binaries from two env vars. "the binary to + // push is not a Linux (ELF) executable" would leave the reader guessing + // which one to fix. + let macho = write_temp("macho-named", b"\xcf\xfa\xed\xfe rest"); + assert!(rejection(ACP, &macho).contains("the buzz-acp binary")); + assert!(rejection(CLI, &macho).contains("the buzz binary")); + + let error = oversized(CLI, u64::MAX, "/tmp/wrong").unwrap(); + assert!(error.contains("the buzz binary to push"), "{error}"); + } + + #[test] + fn the_size_cap_rejects_at_the_boundary_and_names_the_path() { + // Exercised through `oversized` rather than by writing a 200 MB file: + // the boundary is the whole content of the rule, and a real artifact + // (10-30 MB) must pass it untouched. + assert_eq!(oversized(ACP, MAX_BINARY_BYTES as u64, "/x"), None); + assert_eq!(oversized(ACP, 30 * 1024 * 1024, "/x"), None); + let error = oversized(ACP, MAX_BINARY_BYTES as u64 + 1, "/tmp/wrong-file").unwrap(); + assert!(error.contains("limit"), "{error}"); + assert!(error.contains("/tmp/wrong-file"), "{error}"); + // A `u64` length from a huge file must not wrap on the way to the + // comparison, which an `as usize` on a 32-bit target would do. + assert!(oversized(ACP, u64::MAX, "/x").is_some()); + } + + #[test] + fn no_payload_still_resolves_the_install_destination_and_then_fails_with_exit_90() { + let resolved = resolve_or_install(ACP, "'buzz-acp'", None); + assert_eq!( + resolved, + r#"acp=$(command -v 'buzz-acp' 2>/dev/null || true) +if [ -z "$acp" ] && [ -x "$HOME/.local/bin/buzz-acp" ]; then acp="$HOME/.local/bin/buzz-acp"; fi +if [ -z "$acp" ]; then echo "buzz-acp not found on the server's PATH or in ~/.local/bin — install it, or set 'buzz-acp path on the server'" >&2; exit 90; fi"# + ); + } + + #[test] + fn a_missing_cli_with_no_payload_warns_and_lets_the_deploy_continue() { + // The asymmetry, pinned to the byte. `buzz-acp` missing is exit 90; + // the CLI missing is a stderr line and nothing else, because an agent + // without it still runs — it just cannot answer with the CLI its own + // system prompt tells it to use. + let resolved = resolve_or_install(CLI, "'buzz'", None); + assert_eq!( + resolved, + r#"cli=$(command -v 'buzz' 2>/dev/null || true) +if [ -z "$cli" ] && [ -x "$HOME/.local/bin/buzz" ]; then cli="$HOME/.local/bin/buzz"; fi +if [ -z "$cli" ]; then echo "WARNING: no 'buzz' CLI on the server's PATH or in ~/.local/bin — agents on this host cannot reply with 'buzz messages send' and will degrade to slower replies; install it there, or set BUZZ_CLI_PUSH_BINARY on the desktop and redeploy" >&2; fi"# + ); + // No exit, no `set -e` trip: the deploy carries on past this block. + assert!(!resolved.contains("exit")); + } + + #[cfg(unix)] + #[test] + fn a_warning_never_stops_a_script_running_under_set_e() { + // `echo … >&2` returns 0, but the surrounding `if` is what makes that + // true of the whole block. Prove it against a real shell rather than by + // reading it: a non-zero last command here would abort every deploy to + // a host without the CLI. + let script = format!( + "set -eu\n{}\necho reached\n", + resolve_or_install(CLI, "'buzz'", None) + ); + let output = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(&script) + .env("HOME", std::env::temp_dir().join("buzz-no-such-home")) + .env("PATH", "/nonexistent") + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "reached"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.starts_with(WARNING_PREFIX), "{stderr}"); + } + + #[test] + fn warnings_are_lifted_out_of_remote_stderr_and_scrubbed() { + let stderr = format!( + "some host noise\n{WARNING_PREFIX}no 'buzz' CLI\nfailed with nsec1leakedleaked\n" + ); + let lifted = warnings(&stderr); + assert_eq!(lifted, vec![format!("{WARNING_PREFIX}no 'buzz' CLI")]); + // Anything that reaches the desktop goes through the same scrubber the + // failure path uses — a warning is not an exemption. + let leaky = format!("{WARNING_PREFIX}key nsec1leakedleaked rejected"); + assert!(!warnings(&leaky)[0].contains("nsec1leakedleaked")); + assert!(warnings("").is_empty()); + } + + #[cfg(unix)] + #[test] + fn resolution_finds_the_install_destination_that_is_not_on_a_non_interactive_path() { + // The bug this pins: `~/.local/bin` is where every install lands and is + // NOT on a non-interactive SSH PATH, so a `command -v`-only rule + // reported "missing" for a binary this crate itself installed — and + // since deploy is the start path, re-streamed and re-installed it on + // every single agent start. + let home = std::env::temp_dir().join(format!("buzz-resolve-{}", std::process::id())); + let local_bin = home.join(".local/bin"); + std::fs::create_dir_all(&local_bin).unwrap(); + let installed = local_bin.join("buzz-acp"); + std::fs::write(&installed, "#!/bin/sh\nexit 0\n").unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&installed, std::fs::Permissions::from_mode(0o755)).unwrap(); + + // `sh -c` with an EMPTY PATH: nothing but the explicit check can find + // it, which is exactly the remote shell's situation. + let run = |script: &str| { + let output = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(format!("{script}\nprintf '%s' \"$acp\"\n")) + .env("HOME", &home) + .env("PATH", "/nonexistent") + .output() + .unwrap(); + ( + output.status.success(), + String::from_utf8_lossy(&output.stdout).to_string(), + ) + }; + + // Both the un-pushed path and the shared rule land on the installed + // copy rather than exiting 90. + let (ok, acp) = run(&resolve_or_install(ACP, "'buzz-acp'", None)); + assert!(ok, "resolution failed on a host that has the binary"); + assert_eq!(acp, installed.display().to_string()); + let (ok, acp) = run(&resolve(ACP, "'buzz-acp'")); + assert!(ok); + assert_eq!(acp, installed.display().to_string()); + + // And it is still a real answer, not an unconditional one: remove the + // file and the same block exits 90. + std::fs::remove_file(&installed).unwrap(); + let (ok, _) = run(&resolve_or_install(ACP, "'buzz-acp'", None)); + assert!(!ok, "resolution succeeded on a host with no binary at all"); + } + + #[cfg(unix)] + #[test] + fn the_probe_answers_which_tools_the_host_already_has() { + // The probe is what keeps megabytes-large payloads off the wire on + // every start of every agent, so its answer has to be right in both + // directions — and for two tools it has to be per-tool, which is why it + // prints names rather than exiting 0/1. Run against a real `/bin/sh`, + // since the whole content of the script is shell. + // + // `$HOME` is pinned to an empty sandbox: the probe also consults + // `$HOME/.local/bin`, and the developer running these tests may well + // have a `buzz` there. + let home = std::env::temp_dir().join(format!("buzz-probe-home-{}", std::process::id())); + let local_bin = home.join(".local/bin"); + std::fs::create_dir_all(&local_bin).unwrap(); + let run = |tools: &[(Tool, String)], path: &str| { + let output = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(probe_script(tools)) + .env("HOME", &home) + .env("PATH", path) + .output() + .unwrap(); + String::from_utf8_lossy(&output.stdout).to_string() + }; + + // `sh` itself is in /bin on every unix, so it stands in for an + // installed binary without creating one. + let both = [(ACP, "'sh'".to_string()), (CLI, "'buzz'".to_string())]; + let answer = run(&both, "/bin:/usr/bin"); + assert!(probe_found(&answer, ACP), "{answer}"); + assert!(!probe_found(&answer, CLI), "{answer}"); + + // An absolute configured path is answered by existence, not by PATH. + let answer = run(&[(ACP, "'/bin/sh'".to_string())], "/nonexistent"); + assert!(probe_found(&answer, ACP), "{answer}"); + + // The install destination answers too, with nothing on PATH — the case + // every host is in after its first deploy, and the one a `command -v` + // probe got wrong forever. + use std::os::unix::fs::PermissionsExt; + let installed = local_bin.join("buzz"); + std::fs::write(&installed, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&installed, std::fs::Permissions::from_mode(0o755)).unwrap(); + let answer = run(&both, "/nonexistent"); + assert!(probe_found(&answer, CLI), "{answer}"); + assert!(!probe_found(&answer, ACP), "{answer}"); + // A non-executable leftover is not an install: `-x`, not `-e`. + std::fs::set_permissions(&installed, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(!probe_found(&run(&both, "/nonexistent"), CLI)); + std::fs::remove_file(&installed).unwrap(); + + // The command is interpolated already-quoted, so a hostile configured + // path is inert rather than executed. + let canary = std::env::temp_dir().join(format!("buzz-probe-{}", std::process::id())); + let _ = std::fs::remove_file(&canary); + let hostile = crate::ssh::quote(&format!("$(touch {})", canary.display())); + let answer = run(&[(ACP, hostile)], "/bin:/usr/bin"); + assert!(!probe_found(&answer, ACP), "{answer}"); + assert!(!canary.exists(), "the probe executed its own argument"); + + // No tools to ask about is no script at all, not an empty round trip + // that still says something. + assert!(probe_script(&[]).is_empty()); + } + + #[test] + fn the_install_block_verifies_before_it_installs() { + // Both tools share this code path, so both are checked: the CLI is the + // enhancement, but "nothing becomes executable before it verifies" is + // not something an enhancement gets to opt out of. + for tool in [ACP, CLI] { + let payload = Payload::read(tool, &write_temp("order", &hostile_binary())).unwrap(); + let script = resolve_or_install(tool, "'x'", Some(&payload)); + let name = tool.name; + let var = tool.var; + + let decode = script.find("base64 -d").unwrap(); + let verify = script.find("sha256sum -c").unwrap(); + let chmod = script.find("chmod 755").unwrap(); + let install = script.find(&format!(r#"mv "${var}_tmp""#)).unwrap(); + assert!(decode < verify, "decode must precede verification"); + assert!( + verify < chmod, + "nothing becomes executable before it verifies" + ); + assert!(chmod < install, "the file is executable before it is moved"); + + // Same directory as the target, so the `mv` is a rename and never a + // cross-device copy that could be observed half-written. + assert!(script.contains(&format!(r#"{var}_tmp="${var}_dir/.{name}.tmp.$$""#))); + assert!(script.contains(&format!(r#"mv "${var}_tmp" "${var}_dir/{name}""#))); + // Resolution wins over installation, so an existing binary is kept. + assert!(script.contains(&format!(r#"if [ -z "${var}" ]; then"#))); + // And the freshly installed path is what the rest of the deploy uses. + assert!(script.contains(&format!(r#"{var}="${var}_dir/{name}""#))); + // Missing coreutils is a clear message, never a silent skip. + assert!(script.contains("command -v base64")); + assert!(script.contains("command -v sha256sum")); + // Every host-side message names the tool it is about. + assert!(script.contains(&format!("the pushed {name} did not decode"))); + assert!(script.contains(&format!("the pushed {name} failed its sha256 check"))); + + // Where the install lands and where resolution looks are two + // expressions, so they can drift apart — and if they ever do, every + // deploy silently re-installs forever. Pin them together. + assert_eq!(install_path(tool), format!("{INSTALL_DIR}/{name}")); + assert!(script.contains(&format!(r#"{var}_dir="{INSTALL_DIR}""#))); + assert!(script.contains(&format!(r#"[ -x "{}" ]"#, install_path(tool)))); + + // The heredoc delimiter is QUOTED, so the remote shell performs no + // expansion on the body. The base64 alphabet already contains + // nothing expandable, so this is the crate's usual second + // independent failure rather than the only one — but an unquoted + // delimiter would make the payload's inertness depend entirely on + // the encoder, and no behavioural test could see the difference. + assert!(script.contains(&format!("<<'{}'", tool.delimiter))); + assert!(!script.contains(&format!("<<{}", tool.delimiter))); + } + } +} diff --git a/crates/buzz-backend-ssh/src/main.rs b/crates/buzz-backend-ssh/src/main.rs new file mode 100644 index 0000000000..2f53facba8 --- /dev/null +++ b/crates/buzz-backend-ssh/src/main.rs @@ -0,0 +1,161 @@ +//! `buzz-backend-ssh` — run managed agents on a remote host over SSH. +//! +//! A backend provider: the desktop spawns it, writes one JSON request to its +//! stdin, and reads one JSON response from its stdout (`managed_agents::backend +//! ::invoke_provider`). One process per op, no daemon, no state. +//! +//! It is deliberately **not** bundled with the desktop app. It installs to +//! `~/.local/bin` and is found on PATH by `discover_provider_candidates`, so +//! remote execution ships and updates independently of the desktop release. +//! +//! Three invariants hold across every op in this crate: +//! +//! 1. Secrets never reach a log, an error string, a `Debug` rendering, or a +//! process argument list — locally or on the host. They travel only inside +//! the script body on the SSH stdin channel (`ssh.rs`), and the one type +//! that holds one renders as `[REDACTED]` (`protocol::Secret`). +//! 2. A deploy without the desktop-minted `private_key_nsec` fails closed +//! (`deploy::Agent::from_request`). +//! 3. Tailscale is an enhancement, never a dependency: when it is absent, +//! logged out, or empty, the `info` schema is byte-identical to the plain +//! one (`protocol::info_response`). + +mod deploy; +mod discover; +mod install; +mod protocol; +mod ssh; +mod tailscale; + +use std::io::Read; + +use protocol::{Failure, SshConfig}; +use ssh::Session; + +fn main() { + let response = match read_request() + .map_err(Failure::from) + .and_then(|request| run(&request)) + { + Ok(response) => response, + Err(error) => { + // Human detail on stderr, machine-readable failure on stdout. The + // desktop needs the second; a developer reading a terminal needs + // the first. `error` is already credential-scrubbed by whoever + // produced it, and the desktop scrubs it again on the way in. + eprintln!("buzz-backend-ssh: {error}"); + let mut response = serde_json::json!({ "ok": false, "error": error.message }); + // An optional key, in both directions: a desktop that does not know + // it still renders `error`, which names the problem and carries the + // URL as text, and a desktop that does know it finds nothing here + // from an older provider. No negotiation, no flag. + if let Some(url) = error.auth_url { + response["recovery"] = serde_json::json!({ "action": "open_url", "url": url }); + } + response + } + }; + println!("{response}"); + // Always exit 0. A non-zero exit makes `invoke_provider` discard stdout + // entirely and report raw stderr, which would throw away the structured + // error above. + std::process::exit(0); +} + +fn read_request() -> Result { + let mut input = String::new(); + std::io::stdin() + .read_to_string(&mut input) + .map_err(|e| format!("failed to read the request from stdin: {e}"))?; + serde_json::from_str(input.trim()).map_err(|e| format!("request is not valid JSON: {e}")) +} + +fn run(request: &serde_json::Value) -> Result { + let op = request + .get("op") + .and_then(|v| v.as_str()) + .ok_or("request is missing 'op'")?; + + // `info` is the only op that runs before a host is configured — it is what + // *produces* the host field — so it never opens a session. An unknown op is + // rejected here too, so a typo costs a parse rather than an SSH handshake. + match op { + "info" => return Ok(protocol::info_response()), + "check" | "discover_harnesses" | "probe_models" | "deploy" => {} + _ => return Err(format!("unsupported op '{op}'").into()), + } + + let config = SshConfig::from_request(request)?; + let session = Session::new(&config, use_tailnet_host_key_policy(&config))?; + + match op { + "check" => discover::check(&session), + "discover_harnesses" => discover::discover_harnesses(&config, &session), + "probe_models" => discover::probe_models(request, &config, &session), + _ => deploy::deploy(request, &config, &session), + } +} + +/// Trust-on-first-use is allowed for exactly one class of address: a device +/// this machine's own Tailscale daemon lists as a peer. Reaching it already +/// required a WireGuard-authenticated tunnel, so `accept-new` adds no exposure +/// and removes the "paste the host, get `Host key verification failed`" cliff. +/// +/// For anything the user typed, `accept-new` would silently make the trust +/// decision on their behalf — a genuine MITM window — so the answer is no. +fn use_tailnet_host_key_policy(config: &SshConfig) -> bool { + tailscale::Tailnet::detect().contains(config.bare_host()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn info_needs_no_host_and_opens_no_session() { + // `info` is what the desktop calls to *build* the host field, so it + // must succeed with no configuration at all. + let response = run(&serde_json::json!({ "op": "info", "request_id": "abc" })).unwrap(); + assert_eq!(response["ok"], true); + assert_eq!(response["name"], "SSH"); + assert!(response["config_schema"]["properties"]["ssh_host"].is_object()); + } + + #[test] + fn an_unknown_op_fails_before_any_connection_is_attempted() { + let error = run(&serde_json::json!({ + "op": "teleport", + "provider_config": { "ssh_host": "vps.invalid" }, + })) + .unwrap_err(); + assert!(error.message.contains("teleport"), "{error}"); + } + + #[test] + fn a_request_without_an_op_is_a_structured_error() { + assert!(run(&serde_json::json!({})).is_err()); + } + + #[test] + fn ops_that_touch_the_host_require_a_host() { + // Validated before the session opens, so a misconfigured provider + // reports the missing field instead of a connection failure. + for op in ["check", "discover_harnesses", "probe_models", "deploy"] { + let error = run(&serde_json::json!({ "op": op })).unwrap_err(); + assert!(error.message.contains("provider_config"), "{op}: {error}"); + } + } + + #[test] + fn a_tailnet_host_is_the_only_thing_that_relaxes_host_key_checking() { + // Nothing in a test environment advertises a tailnet, so the policy + // must come back strict for every address. + let config = SshConfig { + host: "vps.example.com".into(), + ..SshConfig::default() + }; + if tailscale::Tailnet::detect().schema_options().is_empty() { + assert!(!use_tailnet_host_key_policy(&config)); + } + } +} diff --git a/crates/buzz-backend-ssh/src/protocol.rs b/crates/buzz-backend-ssh/src/protocol.rs new file mode 100644 index 0000000000..4c262df417 --- /dev/null +++ b/crates/buzz-backend-ssh/src/protocol.rs @@ -0,0 +1,369 @@ +//! Wire types shared by every op: the provider config, the `info` schema, and +//! the secret wrapper that keeps credentials out of logs. + +use std::fmt; + +use serde::Deserialize; +use zeroize::Zeroizing; + +/// A credential that must never reach a log, an error string, or a process +/// argument list. +/// +/// `Debug`/`Display` render `[REDACTED]` — the inner value is reachable only +/// through [`Secret::expose`], which every call site must name explicitly. The +/// backing buffer is zeroized on drop. +#[derive(Clone, Default)] +pub struct Secret(Zeroizing); + +impl<'de> Deserialize<'de> for Secret { + fn deserialize>(deserializer: D) -> Result { + Ok(Self(Zeroizing::new(String::deserialize(deserializer)?))) + } +} + +impl Secret { + /// Yield the raw credential. Only legal on the path that writes it to the + /// SSH stdin channel. + pub fn expose(&self) -> &str { + &self.0 + } + + pub fn is_empty(&self) -> bool { + self.0.trim().is_empty() + } +} + +impl fmt::Debug for Secret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("[REDACTED]") + } +} + +impl fmt::Display for Secret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("[REDACTED]") + } +} + +/// An op failure, plus the optional machine-readable recovery the desktop needs +/// to offer the user a way out. +/// +/// A struct rather than an error enum: exactly one failure in this crate has a +/// recovery, and an enum would make every unremarkable `format!` in five files +/// name a variant. The `From`/`From<&str>` conversions keep every +/// existing `?` compiling; there is deliberately **no** `From for +/// String`, which would silently drop `auth_url` at the first caller that +/// propagated one. +#[derive(Debug)] +pub struct Failure { + pub message: String, + /// A `https://login.tailscale.com/a/…` URL built by + /// [`crate::tailscale::auth_url_in`]. Never anything else. + pub auth_url: Option, +} + +impl Failure { + /// The tailnet's ACL asks for a browser re-auth (Tailscale SSH's `check` + /// action), which `BatchMode` cannot answer. + /// + /// The message stands alone: a desktop too old to read `recovery` still + /// tells the user what happened and where to go. + pub fn tailscale_auth(url: String) -> Self { + Self { + message: format!("this host requires Tailscale SSH authentication in a browser: {url}"), + auth_url: Some(url), + } + } +} + +impl From for Failure { + fn from(message: String) -> Self { + Self { + message, + auth_url: None, + } + } +} + +impl From<&str> for Failure { + fn from(message: &str) -> Self { + Self::from(message.to_string()) + } +} + +impl fmt::Display for Failure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +/// Strip credential-shaped tokens out of text that came from somewhere we do +/// not control (remote stderr, mostly). Mirrors the desktop's own prefix rule +/// in `managed_agents::backend::redact_secrets_with` so a leak needs two +/// independent failures rather than one. +pub fn redact(text: &str) -> String { + let mut out = text.to_string(); + for prefix in ["nsec1", "sprt_tok_"] { + while let Some(start) = out.find(prefix) { + let end = out[start..] + .find(|c: char| c.is_whitespace() || c == '"' || c == '\'') + .map(|offset| start + offset) + .unwrap_or(out.len()); + out.replace_range(start..end, "[REDACTED]"); + } + } + out +} + +/// Truncate remote output to something an error string can carry. +pub fn snippet(text: &str) -> String { + let redacted = redact(text.trim()); + match redacted.char_indices().nth(2048) { + Some((cut, _)) => format!("{}…", &redacted[..cut]), + None => redacted, + } +} + +/// The provider's `provider_config` block. +/// +/// Field names are constrained by the desktop's `validate_provider_config`, +/// which rejects any key whose word-split contains `secret`/`password`/ +/// `token`/`key`/`credential`. That is why the identity field is +/// `ssh_identity_file` and not `ssh_key_path`: the latter would be dropped on +/// the way in, silently. `schema_keys_are_accepted_by_the_desktop_validator` +/// pins this. +#[derive(Debug, Clone, Default)] +pub struct SshConfig { + pub host: String, + pub user: Option, + pub port: Option, + pub identity_file: Option, + pub buzz_acp_path: Option, +} + +impl SshConfig { + pub fn from_request(request: &serde_json::Value) -> Result { + let config = request + .get("provider_config") + .ok_or("request is missing 'provider_config'")?; + + let string = |key: &str| { + config + .get(key) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) + }; + + let host = string("ssh_host").ok_or("'ssh_host' is required")?; + // `ssh` reads a leading dash as an option, and whitespace would split + // the argument. Neither can appear in a real hostname. + if host.starts_with('-') || host.chars().any(|c| c.is_whitespace() || c.is_control()) { + return Err(format!("'ssh_host' is not a valid host: {host:?}")); + } + + let port = match config.get("ssh_port") { + Some(serde_json::Value::Number(n)) => n.as_u64(), + Some(serde_json::Value::String(s)) if !s.trim().is_empty() => Some( + s.trim() + .parse::() + .map_err(|_| format!("'ssh_port' is not a port number: {:?}", s.trim()))?, + ), + _ => None, + }; + let port = match port { + Some(p) if (1..=65535).contains(&p) => Some(p as u16), + Some(p) => return Err(format!("'ssh_port' is out of range: {p}")), + None => None, + }; + + Ok(Self { + host, + user: string("ssh_user"), + port, + identity_file: string("ssh_identity_file"), + buzz_acp_path: string("buzz_acp_path"), + }) + } + + /// The `[user@]host` argument handed to `ssh`. + pub fn target(&self) -> String { + match &self.user { + Some(user) if !self.host.contains('@') => format!("{user}@{}", self.host), + _ => self.host.clone(), + } + } + + /// The host without any `user@` prefix, for tailnet membership lookups. + pub fn bare_host(&self) -> &str { + self.host + .rsplit_once('@') + .map_or(self.host.as_str(), |(_, host)| host) + } +} + +/// The `info` response, including the Tailscale-decorated config schema. +/// +/// When Tailscale is absent, logged out, or has no usable peers, `ssh_host` +/// carries no `oneOf` and the desktop renders exactly the plain text field it +/// renders today. That degradation is structural, not a feature flag. +pub fn info_response() -> serde_json::Value { + let mut ssh_host = serde_json::json!({ + "type": "string", + "title": "Server", + "description": "hostname, IP, or user@host", + }); + let devices = crate::tailscale::Tailnet::detect().schema_options(); + if !devices.is_empty() { + ssh_host["oneOf"] = serde_json::Value::Array(devices); + } + + serde_json::json!({ + "ok": true, + "name": "SSH", + "version": env!("CARGO_PKG_VERSION"), + "description": "Run agents on a remote host over SSH, supervised by systemd --user.", + "config_schema": { + "type": "object", + "required": ["ssh_host"], + "properties": { + "ssh_host": ssh_host, + "ssh_user": { "type": "string", "title": "User" }, + "ssh_port": { "type": "integer", "title": "Port", "default": 22 }, + "ssh_identity_file": { + "type": "string", + "title": "SSH identity file (optional)", + "description": "Defaults to your ~/.ssh/config and agent", + }, + "buzz_acp_path": { + "type": "string", + "title": "buzz-acp path on the server (optional)", + "description": "Defaults to whatever is on the server's PATH", + }, + }, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mirrors `validate_provider_config` (`backend.rs`): a config key whose + /// word-split contains a forbidden word is rejected *by the desktop*, which + /// would silently drop the field. Reimplemented here so the schema cannot + /// drift into a name the desktop refuses to forward. + fn desktop_rejects_key(key: &str) -> bool { + let mut words = Vec::new(); + let mut current = String::new(); + let chars: Vec = key.chars().collect(); + for (i, &ch) in chars.iter().enumerate() { + if ch == '_' || ch == '-' || ch == '.' { + if !current.is_empty() { + words.push(current.to_lowercase()); + current.clear(); + } + } else if ch.is_uppercase() { + let prev_lower = current.chars().last().is_some_and(char::is_lowercase); + let acronym_end = current.chars().last().is_some_and(char::is_uppercase) + && chars.get(i + 1).is_some_and(|c| c.is_lowercase()); + if prev_lower || acronym_end { + words.push(current.to_lowercase()); + current.clear(); + } + current.push(ch); + } else { + current.push(ch); + } + } + if !current.is_empty() { + words.push(current.to_lowercase()); + } + ["secret", "password", "token", "key", "credential"] + .iter() + .any(|forbidden| words.iter().any(|word| word == forbidden)) + } + + #[test] + fn schema_keys_are_accepted_by_the_desktop_validator() { + let info = info_response(); + let properties = info["config_schema"]["properties"].as_object().unwrap(); + assert!(properties.contains_key("ssh_identity_file")); + for key in properties.keys() { + assert!( + !desktop_rejects_key(key), + "config key {key:?} would be rejected by validate_provider_config" + ); + } + // The name this field must never have. + assert!(desktop_rejects_key("ssh_key_path")); + } + + #[test] + fn info_schema_has_no_one_of_without_tailscale_devices() { + // Nothing in this test environment advertises a tailnet, so the schema + // must degrade to the plain text field. + let info = info_response(); + let host = &info["config_schema"]["properties"]["ssh_host"]; + if crate::tailscale::Tailnet::detect() + .schema_options() + .is_empty() + { + assert!(host.get("oneOf").is_none()); + } + } + + #[test] + fn secret_never_renders_its_value() { + let secret: Secret = serde_json::from_str("\"nsec1verysecretvalue\"").unwrap(); + assert_eq!(format!("{secret:?}"), "[REDACTED]"); + assert_eq!(format!("{secret}"), "[REDACTED]"); + assert_eq!(secret.expose(), "nsec1verysecretvalue"); + assert!(!secret.is_empty()); + } + + #[test] + fn redact_strips_credential_prefixes() { + let out = redact("failed: nsec1abc123 and sprt_tok_xyz789 rejected"); + assert!(!out.contains("nsec1abc123")); + assert!(!out.contains("sprt_tok_xyz789")); + assert_eq!(out, "failed: [REDACTED] and [REDACTED] rejected"); + } + + #[test] + fn config_parses_port_from_number_or_string() { + let request = serde_json::json!({ + "provider_config": { "ssh_host": "vps", "ssh_user": "ubuntu", "ssh_port": 2222 } + }); + let config = SshConfig::from_request(&request).unwrap(); + assert_eq!(config.port, Some(2222)); + assert_eq!(config.target(), "ubuntu@vps"); + + let request = serde_json::json!({ + "provider_config": { "ssh_host": "vps", "ssh_port": "2222" } + }); + assert_eq!(SshConfig::from_request(&request).unwrap().port, Some(2222)); + } + + #[test] + fn config_rejects_hosts_ssh_would_read_as_options() { + for host in ["-oProxyCommand=touch /tmp/pwn", "vps example", ""] { + let request = serde_json::json!({ "provider_config": { "ssh_host": host } }); + assert!( + SshConfig::from_request(&request).is_err(), + "accepted {host:?}" + ); + } + } + + #[test] + fn config_keeps_an_inline_user_over_the_user_field() { + let request = serde_json::json!({ + "provider_config": { "ssh_host": "root@vps", "ssh_user": "ubuntu" } + }); + let config = SshConfig::from_request(&request).unwrap(); + assert_eq!(config.target(), "root@vps"); + assert_eq!(config.bare_host(), "vps"); + } +} diff --git a/crates/buzz-backend-ssh/src/ssh.rs b/crates/buzz-backend-ssh/src/ssh.rs new file mode 100644 index 0000000000..b869a07afe --- /dev/null +++ b/crates/buzz-backend-ssh/src/ssh.rs @@ -0,0 +1,478 @@ +//! Transport: the system `ssh` binary, driven with a generated script on stdin. +//! +//! `ssh` rather than a Rust SSH library, because the alternative is +//! reimplementing `~/.ssh/config`, `ProxyJump`, agent forwarding, known-hosts +//! policy and Tailscale's `ProxyCommand` — badly, in a security-sensitive +//! place. The system client already has all of it, configured the way the user +//! configured it. +//! +//! **Every op sends its script over stdin to a remote `sh -s`.** That is what +//! makes the crate's central invariant true by construction: the remote `ps` is +//! world-readable and the desktop's redaction has no reach there, so a secret +//! on the remote argv would leak the agent identity to every user on the box. +//! With the script on stdin the remote argv is the literal string `sh -s` and +//! the local argv is the ssh options — neither ever carries a credential. + +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +use crate::protocol::{Failure, SshConfig}; + +/// Remote output is wrapped into this provider's own stdout, which the desktop +/// caps at 1 MB. Refusing to buffer more than that here makes the failure a +/// clear message instead of an OOM. +const OUTPUT_CAP: usize = 1_048_576; + +/// Deliberately not `Debug`: `stderr` is raw remote output, and only +/// [`Output::failure`] runs it through the credential scrubber. +pub struct Output { + pub status: Option, + pub stdout: String, + pub stderr: String, +} + +impl Output { + pub fn ok(&self) -> bool { + self.status == Some(0) + } + + /// A one-line failure description, credential-scrubbed. + pub fn failure(&self) -> String { + let detail = crate::protocol::snippet(&self.stderr); + let code = self + .status + .map(|c| format!("exit {c}")) + .unwrap_or_else(|| "killed by signal".to_string()); + if detail.is_empty() { + format!("ssh failed ({code})") + } else { + format!("ssh failed ({code}): {detail}") + } + } +} + +/// A configured `ssh` invocation. Holds no connection — each `run` is one +/// process, and each op is one `run`. +pub struct Session { + binary: PathBuf, + args: Vec, +} + +impl Session { + /// `accept_new_host_key` must be true only for addresses that came from the + /// Tailscale device list. Those are reached over an already + /// WireGuard-authenticated transport, so trust-on-first-use adds nothing. + /// For a manually typed host it would convert the user's own known-hosts + /// decision into a silent default, which is a real MITM window. + pub fn new(config: &SshConfig, accept_new_host_key: bool) -> Result { + let binary = resolve_ssh().ok_or("ssh client not found on PATH")?; + let mut args = vec![ + // Removes every interactive prompt structurally, which is what + // makes "this provider never asks for, transmits, or stores a + // password" a property of the code rather than a promise. + "-o".into(), + "BatchMode=yes".into(), + "-o".into(), + "ConnectTimeout=10".into(), + "-o".into(), + format!( + "StrictHostKeyChecking={}", + if accept_new_host_key { + "accept-new" + } else { + "ask" + } + ), + // With BatchMode, `ask` cannot prompt — it declines. Keep ssh's own + // diagnosis on stderr and nothing else. + "-o".into(), + "LogLevel=ERROR".into(), + ]; + if let Some(port) = config.port { + args.push("-p".into()); + args.push(port.to_string()); + } + if let Some(identity) = &config.identity_file { + args.push("-i".into()); + args.push(identity.clone()); + } + args.push("--".into()); + args.push(config.target()); + // The remote argv, in full. Everything else arrives on stdin. + args.push("sh -s".into()); + Ok(Self { binary, args }) + } + + /// Feed `script` to the remote shell and collect its output. + /// + /// A tailnet ACL asking for a browser re-auth is classified here rather + /// than at the five call sites: `ssh` prints the URL and then blocks, so + /// every op would otherwise burn its whole budget — 8s to 300s — and report + /// a bare timeout for something one click fixes. Owning it here also keeps + /// the marker out of the callers, so no one is tempted to match on the + /// string. + pub fn run(&self, script: &str, timeout: Duration) -> Result { + let mut command = Command::new(&self.binary); + command + .args(&self.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + configure_no_window(&mut command); + let mut child = command + .spawn() + .map_err(|e| format!("failed to run {}: {e}", self.binary.display()))?; + + // Write on a thread: a script larger than the pipe buffer would + // otherwise deadlock against a remote that is still starting up. + let payload = script.to_string(); + let mut stdin = child.stdin.take(); + let writer = std::thread::spawn(move || { + if let Some(stdin) = stdin.as_mut() { + let _ = stdin.write_all(payload.as_bytes()); + } + drop(stdin); + }); + + let stdout = Drain::start(child.stdout.take()); + let stderr = Drain::start(child.stderr.take()); + + // Poll to a deadline rather than blocking on `wait`, the repo's standard + // pattern (`discovery::probe_codex_acp_major_version`). + let deadline = Instant::now() + timeout; + let outcome = loop { + match child.try_wait() { + Ok(Some(status)) => break Ok(status.code()), + Ok(None) => { + // Scanning what has arrived, rather than waiting for EOF, + // is what turns the whole budget into one poll interval: + // the auth prompt is printed and *then* ssh blocks. Checked + // before the deadline so the timeout path cannot discard an + // answer already sitting in the buffer. + if let Some(url) = stderr.with_bytes(crate::tailscale::auth_url_in) { + break Err(Failure::tailscale_auth(url)); + } + if Instant::now() >= deadline { + break Err(stderr.with_bytes(|buffered| timed_out(timeout, buffered))); + } + std::thread::sleep(Duration::from_millis(25)); + } + Err(e) => break Err(format!("ssh wait failed: {e}").into()), + } + }; + if outcome.is_err() { + let _ = child.kill(); + let _ = child.wait(); + } + // Joined before the error is returned either way: the writer holds the + // stdin handle, and a live handle keeps a killed child's pipe open. + let _ = writer.join(); + + let status = outcome?; + let stderr = stderr.finish(); + // The same classification for a host that printed the URL and then + // exited on its own, so both shapes reach the caller as one failure. + if let Some(url) = crate::tailscale::auth_url_in(stderr.as_bytes()) { + return Err(Failure::tailscale_auth(url)); + } + Ok(Output { + status, + stdout: stdout.finish(), + stderr, + }) + } +} + +/// A pipe being read to EOF on its own thread, capped at [`OUTPUT_CAP`]. Both +/// pipes must be drained concurrently with the wait or a chatty remote fills +/// one and blocks. +/// +/// The buffer is shared rather than returned by the thread so the poll loop can +/// read what has arrived so far without waiting on the child — which is what +/// makes the Tailscale check detectable, and is also the only safe shape on the +/// timeout path, where a descendant holding the pipe open would leave a +/// `join()` blocked forever. +struct Drain { + buffer: Arc>>, + thread: std::thread::JoinHandle<()>, +} + +impl Drain { + fn start(pipe: Option) -> Self { + let buffer = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&buffer); + let thread = std::thread::spawn(move || { + let Some(mut pipe) = pipe else { return }; + let mut chunk = [0u8; 8192]; + loop { + // Read outside the lock: holding it across a blocking read + // would stall every peek for as long as the remote is quiet. + let Ok(read @ 1..) = pipe.read(&mut chunk) else { + return; + }; + let mut buffer = lock(&sink); + if buffer.len() >= OUTPUT_CAP { + return; + } + buffer.extend_from_slice(&chunk[..read]); + buffer.truncate(OUTPUT_CAP); + } + }); + Self { buffer, thread } + } + + /// Read what has arrived so far, in place. Never waits on the child, so it + /// is safe on the timeout path. + fn with_bytes(&self, f: impl FnOnce(&[u8]) -> T) -> T { + f(&lock(&self.buffer)) + } + + /// Everything, once the pipe closes. + fn finish(self) -> String { + let _ = self.thread.join(); + String::from_utf8_lossy(&lock(&self.buffer)).into_owned() + } +} + +/// A timeout that still reports whatever the child managed to say. The drained +/// stderr used to be dropped on this path — exactly when it is most wanted, on +/// a host that printed a diagnosis and then hung. Scrubbed through the same +/// `snippet` as [`Output::failure`], because it is raw remote output. +fn timed_out(timeout: Duration, buffered: &[u8]) -> Failure { + let detail = crate::protocol::snippet(&String::from_utf8_lossy(buffered)); + let seconds = timeout.as_secs(); + if detail.is_empty() { + format!("ssh timed out after {seconds}s").into() + } else { + format!("ssh timed out after {seconds}s: {detail}").into() + } +} + +/// The drain thread cannot panic while holding the buffer, so poisoning is +/// unreachable; recovering rather than unwrapping keeps that from ever becoming +/// a way to take the provider down. +fn lock(buffer: &Mutex>) -> std::sync::MutexGuard<'_, Vec> { + buffer.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Windows ships OpenSSH in System32 but does not always put it on a GUI +/// process's PATH, so look there first. +fn resolve_ssh() -> Option { + let exe = if cfg!(windows) { "ssh.exe" } else { "ssh" }; + let mut candidates = Vec::new(); + if cfg!(windows) { + let system_root = std::env::var_os("SystemRoot") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Windows")); + candidates.push(system_root.join("System32").join("OpenSSH").join(exe)); + } + if let Some(path) = std::env::var_os("PATH") { + candidates.extend(std::env::split_paths(&path).map(|dir| dir.join(exe))); + } + candidates.into_iter().find(|path| path.is_file()) +} + +/// The desktop's `util::configure_no_window`, transcribed. The desktop applies +/// it to its own spawn of this provider, but `CREATE_NO_WINDOW` does not +/// inherit, so every child spawned here must set it again or Windows flashes a +/// console window per op. +pub fn configure_no_window(command: &mut Command) { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + #[cfg(not(windows))] + let _ = command; +} + +/// Quote a value for POSIX `sh`. Single quotes suppress every expansion; the +/// only character needing care is `'` itself. +pub fn quote(value: &str) -> String { + format!("'{}'", value.replace('\'', r"'\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(host: &str) -> SshConfig { + SshConfig { + host: host.into(), + user: Some("ubuntu".into()), + port: Some(2222), + identity_file: Some("/home/me/.ssh/id_ed25519".into()), + buzz_acp_path: None, + } + } + + #[test] + fn every_invocation_is_batch_mode_with_the_script_on_stdin() { + let Ok(session) = Session::new(&config("vps"), false) else { + return; // no ssh client in this environment + }; + assert!(session + .args + .windows(2) + .any(|w| w == ["-o", "BatchMode=yes"])); + // The remote argv is exactly `sh -s`; nothing op-specific, and so + // nothing secret, is ever visible in the remote process table. + assert_eq!(session.args.last().unwrap(), "sh -s"); + assert!(session.args.contains(&"--".to_string())); + assert!(session.args.windows(2).any(|w| w == ["-p", "2222"])); + assert!(session + .args + .windows(2) + .any(|w| w == ["-i", "/home/me/.ssh/id_ed25519"])); + } + + #[test] + fn accept_new_host_key_is_scoped_to_tailnet_addresses() { + let Ok(manual) = Session::new(&config("vps.example.com"), false) else { + return; + }; + assert!(manual + .args + .contains(&"StrictHostKeyChecking=ask".to_string())); + assert!(!manual + .args + .contains(&"StrictHostKeyChecking=accept-new".to_string())); + + let tailnet = Session::new(&config("vps.tailcfd703.ts.net"), true).unwrap(); + assert!(tailnet + .args + .contains(&"StrictHostKeyChecking=accept-new".to_string())); + } + + #[test] + fn quote_neutralizes_shell_metacharacters() { + assert_eq!(quote("plain"), "'plain'"); + assert_eq!(quote("a b"), "'a b'"); + assert_eq!(quote("$(touch /tmp/pwn)"), "'$(touch /tmp/pwn)'"); + assert_eq!(quote("it's"), r"'it'\''s'"); + } + + // The two tests below stand a local `/bin/sh` in for the remote host to + // exercise the write/drain/wait loop without a network. Everything else in + // this file is platform-neutral and runs on the Windows CI job too. + #[cfg(unix)] + #[test] + fn run_reports_the_command_output() { + let Ok(session) = Session::new(&config("vps"), false) else { + return; + }; + // Point at a shell instead of a real host: `run` is transport + // plumbing, and this exercises the write/drain/wait loop without a + // network. `sh` ignores the ssh options it does not know. + let local = Session { + binary: PathBuf::from("/bin/sh"), + args: vec!["-s".into()], + }; + let _ = session; + let out = local + .run( + "printf hello; printf oops >&2; exit 3", + Duration::from_secs(10), + ) + .unwrap(); + assert_eq!(out.stdout, "hello"); + assert_eq!(out.stderr, "oops"); + assert_eq!(out.status, Some(3)); + assert!(!out.ok()); + assert!(out.failure().contains("exit 3")); + } + + #[cfg(unix)] + #[test] + fn run_kills_a_command_that_outlives_its_budget() { + let session = Session { + binary: PathBuf::from("/bin/sh"), + args: vec!["-s".into()], + }; + // `Output` is intentionally not `Debug` — it carries raw remote stderr, + // which only `failure()` scrubs — so the error comes out by hand. + let Err(error) = session.run("sleep 30", Duration::from_millis(300)) else { + panic!("a command past its deadline must be killed, not awaited"); + }; + assert!(error.message.contains("timed out"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn a_timeout_still_reports_what_the_host_managed_to_say() { + // The stderr of a host that diagnoses itself and *then* hangs is + // exactly the output worth keeping, and it used to be dropped. + let session = Session { + binary: PathBuf::from("/bin/sh"), + args: vec!["-s".into()], + }; + let Err(error) = session.run( + "printf 'disk is full\\n' >&2; sleep 30", + Duration::from_millis(300), + ) else { + panic!("a command past its deadline must be killed, not awaited"); + }; + assert!(error.message.contains("disk is full"), "{error}"); + assert!(error.auth_url.is_none(), "{error}"); + } + + #[cfg(unix)] + #[test] + fn a_tailscale_auth_prompt_fails_fast_instead_of_burning_the_budget() { + let session = Session { + binary: PathBuf::from("/bin/sh"), + args: vec!["-s".into()], + }; + let started = Instant::now(); + // The shape ssh prints under a `check`-action tailnet ACL: the URL, + // then a wait for a human who is not there. + let Err(error) = session.run( + "printf '# To authenticate, visit: https://login.tailscale.com/a/abc123\\n' >&2\nsleep 30\n", + Duration::from_secs(10), + ) else { + panic!("a host asking for browser auth must fail, not hang"); + }; + assert_eq!( + error.auth_url.as_deref(), + Some("https://login.tailscale.com/a/abc123") + ); + // The fail-fast IS the feature: waiting out the budget is the bug. + assert!(started.elapsed() < Duration::from_secs(5), "{error}"); + } + + #[cfg(unix)] + #[test] + fn a_tailscale_auth_prompt_on_a_failing_exit_is_classified_the_same_way() { + // ssh can also print the URL and give up on its own, which lands past + // the poll loop entirely. + let session = Session { + binary: PathBuf::from("/bin/sh"), + args: vec!["-s".into()], + }; + let Err(error) = session.run( + "printf 'visit: https://login.tailscale.com/a/xyz789\\n' >&2; exit 255", + Duration::from_secs(10), + ) else { + panic!("a host asking for browser auth must fail, not succeed"); + }; + assert_eq!( + error.auth_url.as_deref(), + Some("https://login.tailscale.com/a/xyz789") + ); + } + + #[test] + fn failure_text_scrubs_credentials_from_remote_stderr() { + let output = Output { + status: Some(1), + stdout: String::new(), + stderr: "refused key nsec1leakedleaked".into(), + }; + assert!(!output.failure().contains("nsec1leakedleaked")); + assert!(output.failure().contains("[REDACTED]")); + } +} diff --git a/crates/buzz-backend-ssh/src/tailscale.rs b/crates/buzz-backend-ssh/src/tailscale.rs new file mode 100644 index 0000000000..c365f37fa6 --- /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/playwright.config.ts b/desktop/playwright.config.ts index 459fa75743..52b8705bac 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -51,6 +51,7 @@ export default defineConfig({ "**/agent-readiness-screenshots.spec.ts", "**/agent-error-state-screenshots.spec.ts", "**/edit-agent.spec.ts", + "**/edit-agent-provider-routing.spec.ts", "**/doctor-cta-screenshots.spec.ts", "**/pubkey-display-screenshots.spec.ts", "**/file-attachment.spec.ts", diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index ca1fe9bdf6..132378362e 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -1097,13 +1097,13 @@ pub(super) fn normalize_agent_models( for o in options { if let Some(value) = o.get("value").and_then(|v| v.as_str()) { if seen_ids.insert(value.to_string()) { + let str_field = + |k: &str| o.get(k).and_then(|v| v.as_str()).map(str::to_string); models.push(AgentModelInfo { id: value.to_string(), - name: o - .get("displayName") - .and_then(|v| v.as_str()) - .map(str::to_string), - description: None, + // `displayName`: legacy spelling. buzz-acp order. + name: str_field("name").or_else(|| str_field("displayName")), + description: str_field("description"), }); } } diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index b65f240900..1c7c137ef8 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -413,6 +413,10 @@ fn model_discovery_ignores_stale_record_for_linked_agent() { let global = crate::managed_agents::GlobalAgentConfig::default(); let discovery = agent_model_discovery_config(&record, &personas, &global) .expect("discovery config should resolve for a linked record"); + // The record carries no runtime id of its own, so the harness must resolve + // through the persona — asserted directly so a resolution regression reads + // as a wrong command rather than as missing GOOSE_* env below. + assert_eq!(discovery.command.as_str(), "goose"); assert_eq!(discovery.model.as_deref(), Some("persona-model")); assert_eq!(discovery.provider.as_deref(), Some("anthropic")); @@ -576,6 +580,64 @@ fn is_databricks_provider_matches_both_variants() { assert!(!is_databricks_provider(None)); } +#[test] +fn stable_config_options_carry_their_label_and_description() { + // Verbatim shape of `claude-agent-acp models --json` (v0.62.0): every + // model sits in the stable half with a populated `description`, and + // `unstable` is null. The description is the only thing separating the + // two default-vs-explicit Opus rows, so dropping it here blanks the + // option's secondary line in the picker no matter what the frontend does. + // The last row carries the pre-standardization `displayName` spelling. + let raw = serde_json::json!({ + "agent": { "name": "claude-agent-acp", "version": "0.62.0" }, + "stable": { + "configOptions": [{ + "id": "model", + "category": "model", + "options": [ + { + "value": "default", + "name": "Default (recommended)", + "description": "Use the default model (currently Opus 5)" + }, + { + "value": "opus", + "name": "claude-opus-5", + "description": "Custom Opus model" + }, + { "value": "claude-sonnet-5", "displayName": "Sonnet" } + ] + }] + }, + "unstable": serde_json::Value::Null + }); + + let response = normalize_agent_models(&raw, None); + + assert_eq!(response.models.len(), 3); + assert_eq!(response.models[0].id, "default"); + // The schema field is `name`. Reading only `displayName` leaves every + // claude row nameless, and the picker falls back to showing the raw id. + assert_eq!( + response.models[0].name.as_deref(), + Some("Default (recommended)") + ); + assert_eq!( + response.models[0].description.as_deref(), + Some("Use the default model (currently Opus 5)") + ); + assert_eq!(response.models[1].name.as_deref(), Some("claude-opus-5")); + assert_eq!( + response.models[1].description.as_deref(), + Some("Custom Opus model") + ); + // `displayName` still answers for adapters that never standardized. + assert_eq!(response.models[2].name.as_deref(), Some("Sonnet")); + // An option that omits the field still normalizes to `None` rather than + // an empty string, so the frontend renders one line for it. + assert_eq!(response.models[2].description, None); +} + #[test] fn model_discovery_error_converts_dangling_sentinel_to_sentence() { // get_agent_models is a user-facing surface: a dangling harness must diff --git a/desktop/src-tauri/src/commands/agent_providers.rs b/desktop/src-tauri/src/commands/agent_providers.rs index 178ec0bb6d..4efeb79128 100644 --- a/desktop/src-tauri/src/commands/agent_providers.rs +++ b/desktop/src-tauri/src/commands/agent_providers.rs @@ -1,4 +1,35 @@ -use crate::managed_agents::{discover_provider_candidates, invoke_provider, BackendProviderInfo}; +use crate::managed_agents::{ + discover_provider_candidates, invoke_provider, provider_discover_harnesses, + provider_probe_models, validate_provider_config, BackendProviderInfo, ProviderFailure, +}; + +/// Resolve a frontend-supplied binary path to a canonical path that is +/// provably one of the discovered `buzz-backend-*` providers. +/// +/// Every provider command must go through this. Without it, the `binaryPath` +/// argument is an arbitrary-execution primitive for a compromised frontend or +/// any process that can reach the IPC channel: the desktop would spawn +/// whatever it names and feed it the agent's private key. +/// The provider commands below return [`ProviderFailure`] rather than `String` +/// so a failure carrying a recovery keeps it all the way to the frontend, which +/// reads the serialized `{message, recovery?}` off `TauriInvokeError.payload`. +/// Every non-provider failure on these paths — a path that will not resolve, a +/// config that will not validate, a join that panicked — converts through +/// `.into()` and simply has no recovery. +fn resolve_discovered_provider(binary_path: &str) -> Result { + let canonical = std::path::PathBuf::from(binary_path) + .canonicalize() + .map_err(|e| format!("binary not found: {binary_path}: {e}"))?; + let is_known = discover_provider_candidates() + .iter() + .any(|(_, p)| p.canonicalize().ok().as_ref() == Some(&canonical)); + if !is_known { + return Err(format!( + "binary '{binary_path}' is not a discovered buzz-backend-* provider" + )); + } + Ok(canonical) +} #[tauri::command] pub async fn discover_backend_providers() -> Result, String> { @@ -16,22 +47,10 @@ pub async fn discover_backend_providers() -> Result, St } #[tauri::command] -pub async fn probe_backend_provider(binary_path: String) -> Result { - // Validate that the requested path is actually a discovered buzz-backend-* binary. - // This prevents arbitrary binary execution via a compromised frontend or IPC. - let candidates = discover_provider_candidates(); - let path = std::path::PathBuf::from(&binary_path); - let canonical = path - .canonicalize() - .map_err(|e| format!("binary not found: {binary_path}: {e}"))?; - let is_known = candidates - .iter() - .any(|(_, p)| p.canonicalize().ok().as_ref() == Some(&canonical)); - if !is_known { - return Err(format!( - "binary '{binary_path}' is not a discovered buzz-backend-* provider" - )); - } +pub async fn probe_backend_provider( + binary_path: String, +) -> Result { + let canonical = resolve_discovered_provider(&binary_path)?; // request_id is for provider-side logging — not validated in the response // (stdin→stdout is 1:1 per process invocation). let request = serde_json::json!({ @@ -44,3 +63,50 @@ pub async fn probe_backend_provider(binary_path: String) -> Result Result { + let canonical = resolve_discovered_provider(&binary_path)?; + validate_provider_config(&config)?; + tokio::task::spawn_blocking(move || provider_discover_harnesses(&canonical, &config)) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +/// Model catalog for one remote harness, normalized through the same +/// `normalize_agent_models` the local path uses so the model picker needs no +/// remote-specific rendering code. +#[tauri::command] +pub async fn probe_provider_models( + binary_path: String, + config: serde_json::Value, + harness: serde_json::Value, + env_vars: Option>, +) -> Result { + let canonical = resolve_discovered_provider(&binary_path)?; + validate_provider_config(&config)?; + let env_vars = env_vars.unwrap_or_default(); + let response = tokio::task::spawn_blocking(move || { + provider_probe_models(&canonical, &config, &harness, &env_vars) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + + let models_raw = response + .get("models_raw") + .ok_or("probe_models response is missing 'models_raw'")?; + // `persisted_model: None` — this probes a harness during CREATE, before any + // agent record exists to have persisted a selection. + Ok(super::agent_models::normalize_agent_models( + models_raw, None, + )) +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0758fc3aac..dc7be33997 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -4,11 +4,10 @@ use tauri::{AppHandle, State}; use crate::{ app_state::AppState, managed_agents::{ - build_managed_agent_summary, current_instance_id, discover_provider_candidates, + build_managed_agent_summary, create_time_agent_args, current_instance_id, ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, - load_teams, managed_agent_avatar_url, normalize_agent_args, provider_deploy, - resolve_provider_binary, save_managed_agents, start_managed_agent_process, - stop_managed_agent_process, stop_managed_agent_workspace_pair, + load_teams, managed_agent_avatar_url, resolve_provider_binary, save_managed_agents, + start_managed_agent_process, stop_managed_agent_process, stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, @@ -447,73 +446,6 @@ pub(super) async fn start_local_agent_with_preflight( ) } -/// Deploy an agent to a provider backend. Resolves the binary, calls deploy via -/// spawn_blocking, and persists the result (backend_agent_id or last_error). -/// -/// Idempotency: calling deploy on an already-deployed agent sends the same payload -/// again. Providers are expected to handle this as an update-in-place or no-op — -/// the protocol does not include an explicit `undeploy` operation (deferred to v2). -/// -/// Returns Ok(()) on success, Err(message) on failure. Either way the record is -/// updated and saved before returning. -async fn deploy_to_provider( - app: &AppHandle, - state: &AppState, - pubkey: &str, - provider_id: &str, - config: &serde_json::Value, - agent_json: serde_json::Value, - cached_binary_path: Option<&str>, -) -> Result<(), String> { - // Resolve via discovered candidates only. Cached path must match BOTH - // "is a discovered candidate" AND "belongs to this provider_id". A tampered - // record cannot redirect deploys to a different provider's binary. - let bin_path = cached_binary_path - .map(std::path::PathBuf::from) - .filter(|p| p.exists()) - .map(|p| p.canonicalize().unwrap_or(p)) - .filter(|canonical| { - discover_provider_candidates().iter().any(|(id, cp)| { - id == provider_id && cp.canonicalize().ok().as_ref() == Some(canonical) - }) - }) - .map_or_else(|| resolve_provider_binary(provider_id), Ok)?; - - let config_clone = config.clone(); - let deploy_result = - tokio::task::spawn_blocking(move || provider_deploy(&bin_path, &agent_json, &config_clone)) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))?; - - // Persist result under lock. - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - let rec = records - .iter_mut() - .find(|r| r.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - - match deploy_result { - Ok(backend_agent_id) => { - rec.backend_agent_id = Some(backend_agent_id); - rec.last_started_at = Some(now_iso()); - rec.updated_at = now_iso(); - rec.last_error = None; - } - Err(ref e) => { - rec.last_error = Some(e.clone()); - rec.updated_at = now_iso(); - save_managed_agents(app, &records)?; - return Err(e.clone()); - } - } - save_managed_agents(app, &records)?; - Ok(()) -} - // Async so the blocking body (disk reads of agent/persona records, per-agent // process-liveness syscalls, and a possible save) runs on Tauri's worker pool // via spawn_blocking instead of the main UI thread — it was a beachball on the @@ -737,15 +669,9 @@ pub async fn create_managed_agent( &personas, agent_command_override.as_deref(), ); - let agent_args = normalize_agent_args( - &agent_command, - input - .agent_args - .iter() - .map(|arg| arg.trim().to_string()) - .filter(|arg| !arg.is_empty()) - .collect::>(), - ); + // Backend-scoped: local args normalize against the local catalog, a + // provider's are pinned verbatim from the host's. See the fn's doc. + let agent_args = create_time_agent_args(&input.backend, &agent_command, &input.agent_args); // Derive MCP command exclusively from the runtime catalog — the // per-record field is never read at spawn time so user-supplied input @@ -1017,7 +943,10 @@ pub async fn create_managed_agent( }; match deploy_to_provider(&app, &state, &pubkey, id, config, agent_json, None).await { Ok(()) => spawn_error, - Err(e) => Some(e), + // A reported field of a SUCCEEDING create, not the command's + // error, so it stays a string: the record exists either way and + // a failed first deploy is retried through `start_managed_agent`. + Err(e) => Some(e.message), } } else { spawn_error @@ -1153,7 +1082,13 @@ pub async fn start_managed_agent( agent_json, cached_binary_path.as_deref(), ) - .await?; + .await + // The one place a recovery is deliberately dropped, and explicit + // rather than an `impl From for String` that would + // let a future caller drop one by accident: this command's failures + // render as toasts, which have no room for an action. The message + // still carries the URL as text. Give toasts an action to widen it. + .map_err(|failure| failure.message)?; // Return updated summary. let _store_guard = state @@ -1358,11 +1293,11 @@ pub async fn delete_managed_agent( #[path = "agents_deploy.rs"] mod deploy; -use deploy::build_deploy_payload; -#[cfg(test)] -use deploy::deploy_payload_json; #[cfg(test)] pub(crate) use deploy::resolve_deploy_model_provider; +use deploy::{build_deploy_payload, deploy_to_provider}; +#[cfg(test)] +use deploy::{deploy_payload_json, BinariesToPush}; #[path = "agents_profile.rs"] mod profile; diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index af785711d5..0b1690b889 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -1,6 +1,8 @@ -//! Provider deploy payload construction, split from `agents.rs` (file-size -//! guard). `build_deploy_payload` gathers live state; `deploy_payload_json` -//! is the pure serialization half so payload completeness stays testable. +//! Provider deploy: payload construction and the deploy call itself, split +//! from `agents.rs` (file-size guard). `build_deploy_payload` gathers live +//! state; `deploy_payload_json` is the pure serialization half so payload +//! completeness stays testable; `deploy_to_provider` resolves the binary, +//! invokes it, and persists the outcome onto the record. use tauri::AppHandle; @@ -8,8 +10,12 @@ use tauri::AppHandle; use crate::managed_agents::AgentDefinition; use crate::{ app_state::AppState, - managed_agents::{load_personas, ManagedAgentRecord}, + managed_agents::{ + discover_provider_candidates, load_managed_agents, load_personas, provider_deploy, + resolve_provider_binary, save_managed_agents, ManagedAgentRecord, ProviderFailure, + }, relay::relay_ws_url_with_override, + util::now_iso, }; /// Resolve the deploy-specific structured model/provider for a managed agent. @@ -99,12 +105,55 @@ pub(super) fn build_deploy_payload( effective_provider, effective_prompt, merged_env, + BinariesToPush::from_env(), )) } +/// The binaries this machine offers to install on a host that resolves none. +/// +/// A pair rather than two loose arguments because they are resolved together +/// and read together by the provider, and because passing them in at all is +/// what keeps [`deploy_payload_json`] pure. +#[derive(Default)] +pub(super) struct BinariesToPush { + pub buzz_acp: Option, + pub buzz_cli: Option, +} + +impl BinariesToPush { + /// The dogfood seams as this process's environment currently reports them. + /// + /// Each var names a Linux binary on THIS machine; `buzz-backend-ssh` + /// streams it to the host inside the deploy script and installs it to + /// `~/.local/bin` when the host resolves none. Read at deploy time rather + /// than captured at startup, so a developer can point one at a fresh build + /// without restarting the app. + /// + /// Two vars rather than one because the two binaries are separately + /// policed: a host with no `buzz-acp` cannot run an agent at all, while a + /// host with no CLI runs one that simply cannot reply with + /// `buzz messages send`. + /// + /// They are env vars and not settings because the durable answer is not a + /// path at all: the release build should resolve the artifact for the + /// host's platform by version, with no user-visible choice. Until that + /// lands this is the whole surface. + fn from_env() -> Self { + Self { + buzz_acp: binary_to_push("BUZZ_ACP_PUSH_BINARY"), + buzz_cli: binary_to_push("BUZZ_CLI_PUSH_BINARY"), + } + } +} + /// Pure serialization half of [`build_deploy_payload`] — every field the /// provider harness receives is deliberately listed here, so payload /// completeness is testable without an `AppHandle`. +/// +/// The push seams arrive as a parameter rather than being read from the +/// environment here: reading them inside would make this function's output a +/// property of the developer's shell (the seams are env vars a dogfooding +/// developer sets), which no test could pin down. pub(super) fn deploy_payload_json( record: &ManagedAgentRecord, relay_url: String, @@ -112,6 +161,7 @@ pub(super) fn deploy_payload_json( effective_provider: Option, effective_prompt: Option, merged_env: std::collections::BTreeMap, + binaries_to_push: BinariesToPush, ) -> serde_json::Value { serde_json::json!({ "name": &record.name, @@ -130,5 +180,98 @@ pub(super) fn deploy_payload_json( "respond_to": record.respond_to, "respond_to_allowlist": &record.respond_to_allowlist, "env_vars": merged_env, + // A path on THIS machine to a Linux `buzz-acp` the provider may install + // on the host when the host has none. Serialized as `null` when unset — + // a provider reading it with `as_str()` sees `None`, exactly as it does + // for an absent key, so behavior is unchanged by default. + "buzz_acp_binary": binaries_to_push.buzz_acp, + // The same, for the `buzz` CLI. A local agent gets the CLI because the + // desktop bundles it as a sidecar and prepends its directory to the + // spawned harness's PATH; a remote agent is told by the very same system + // prompt to reply with `buzz messages send`, so without this the remote + // half of that contract is missing. Unlike `buzz-acp` its absence is a + // warning on the provider side, never a failed deploy. + "buzz_cli_binary": binaries_to_push.buzz_cli, }) } + +/// A push seam's value, or `None` when unset or blank — blank being a var the +/// user cleared rather than a request to push an empty path. +fn binary_to_push(var: &str) -> Option { + std::env::var(var) + .ok() + .map(|path| path.trim().to_string()) + .filter(|path| !path.is_empty()) +} + +/// Deploy an agent to a provider backend. Resolves the binary, calls deploy via +/// spawn_blocking, and persists the result (backend_agent_id or last_error). +/// +/// Idempotency: calling deploy on an already-deployed agent sends the same payload +/// again. Providers are expected to handle this as an update-in-place or no-op — +/// the protocol does not include an explicit `undeploy` operation (deferred to v2). +/// +/// Returns Ok(()) or a [`ProviderFailure`]; either way the record is updated +/// and saved first. The failure type is what keeps a recovery — a deploy onto a +/// tailnet wanting browser re-auth carries one — alive for callers that can +/// render it; each caller drops it explicitly where it cannot. The record's +/// `last_error` stays a plain string regardless: it is a post-mortem read long +/// after the fact, and an auth URL is a one-shot token that is stale by then. +pub(super) async fn deploy_to_provider( + app: &AppHandle, + state: &AppState, + pubkey: &str, + provider_id: &str, + config: &serde_json::Value, + agent_json: serde_json::Value, + cached_binary_path: Option<&str>, +) -> Result<(), ProviderFailure> { + // Resolve via discovered candidates only. Cached path must match BOTH + // "is a discovered candidate" AND "belongs to this provider_id". A tampered + // record cannot redirect deploys to a different provider's binary. + let bin_path = cached_binary_path + .map(std::path::PathBuf::from) + .filter(|p| p.exists()) + .map(|p| p.canonicalize().unwrap_or(p)) + .filter(|canonical| { + discover_provider_candidates().iter().any(|(id, cp)| { + id == provider_id && cp.canonicalize().ok().as_ref() == Some(canonical) + }) + }) + .map_or_else(|| resolve_provider_binary(provider_id), Ok)?; + + let config_clone = config.clone(); + let deploy_result = + tokio::task::spawn_blocking(move || provider_deploy(&bin_path, &agent_json, &config_clone)) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))?; + + // Persist result under lock. + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(app)?; + let rec = records + .iter_mut() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + + match deploy_result { + Ok(backend_agent_id) => { + rec.backend_agent_id = Some(backend_agent_id); + rec.last_started_at = Some(now_iso()); + rec.updated_at = now_iso(); + rec.last_error = None; + } + Err(ref e) => { + // The message only — see the `last_error` note above. + rec.last_error = Some(e.message.clone()); + rec.updated_at = now_iso(); + save_managed_agents(app, &records)?; + return Err(e.clone()); + } + } + save_managed_agents(app, &records)?; + Ok(()) +} diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 03389d1d18..7034c78912 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -436,6 +436,7 @@ fn deploy_payload_carries_the_full_behavioral_quad() { Some("openai".to_string()), None, std::collections::BTreeMap::new(), + BinariesToPush::default(), ); assert_eq!(payload["parallelism"], 4); @@ -444,4 +445,119 @@ fn deploy_payload_carries_the_full_behavioral_quad() { assert_eq!(payload["model"], "gpt-x"); assert_eq!(payload["provider"], "openai"); assert_eq!(payload["relay_url"], "wss://relay.example"); + // Both push seams are opt-in: unresolved, the payload must carry no path, + // so a provider reading them sees the same `None` it saw before the seams + // existed. + assert!(payload["buzz_acp_binary"].is_null()); + assert!(payload["buzz_cli_binary"].is_null()); + + // Resolved, each seam serializes as the path itself — the provider reads + // it with `as_str()` and streams that file to the host. + let pushed = deploy_payload_json( + &record, + "wss://relay.example".to_string(), + None, + None, + None, + std::collections::BTreeMap::new(), + BinariesToPush { + buzz_acp: Some("/tmp/buzz-acp".to_string()), + buzz_cli: Some("/tmp/buzz".to_string()), + }, + ); + assert_eq!(pushed["buzz_acp_binary"], "/tmp/buzz-acp"); + assert_eq!(pushed["buzz_cli_binary"], "/tmp/buzz"); +} + +/// The whole point of routing an instance-dialog model edit through the +/// DEFINITION for a provider-backed record: the edited model has to reach the +/// host. +/// +/// The desktop frontend saves the model onto the linked definition (see +/// `instanceModelDefinitionWrite.ts`) because a linked record's own `model` +/// column is never read — this resolver is what the deploy payload consults, +/// and for a linked record it answers from the definition. This pins the +/// provider-backed leg of that claim: a definition edit is visible to the +/// next deploy even though the record still carries the pre-edit bytes. +/// +/// It does NOT hot-swap a running remote agent. The payload is built per deploy +/// call (`build_deploy_payload`, from create-with-spawn and +/// `start_managed_agent`), so an already-deployed process keeps the model it +/// launched with until the next deploy. That deploy does restart it — the SSH +/// provider rewrites the systemd env file and unconditionally +/// `systemctl --user restart`s the unit — so the redeploy applies the model; +/// nothing short of one does. +#[test] +fn provider_backed_record_deploys_the_definition_model_after_an_edit() { + let mut record = bare_agent_record(Some("p1"), Some("pre-edit-model"), Some("anthropic")); + record.backend = BackendKind::Provider { + id: "ssh".to_string(), + config: serde_json::json!({ "host": "example" }), + }; + // The definition as it stands after the instance dialog saved the edit. + let personas = vec![persona_record( + "p1", + Some("post-edit-model"), + Some("anthropic"), + )]; + let global = crate::managed_agents::GlobalAgentConfig::default(); + + let (model, _provider) = resolve_deploy_model_provider(&record, &personas, &global); + + assert_eq!( + model.as_deref(), + Some("post-edit-model"), + "the definition edit must reach the deploy payload; the record's own \ + stale model column is never consulted for a linked instance" + ); +} + +/// The other half of the same guarantee: the SUMMARY the desktop reads back is +/// resolved the same way, so the dialog reopens on the edited model rather than +/// on the record's stale column. +/// +/// This matters because nothing ever rewrites that column for a provider-backed +/// record. `apply_persona_snapshot` would (its `record.model` assignment sits +/// outside the local-only harness guard), but every live caller is +/// local-gated — `start_local_agent_with_preflight` and the restart path both +/// refuse a non-local record outright, and `restore_managed_agents_on_launch` +/// filters to `BackendKind::Local`. The only unguarded caller is the +/// `persona_source_version` backfill, a one-time legacy migration that skips any +/// record already carrying a version (which every persona-linked create sets). +/// +/// So the record column stays stale forever, and that is fine: it is never +/// read. `resolve_effective_config` answers from the definition for a linked +/// instance, and it is the single resolver behind BOTH the deploy payload and +/// this summary. +#[test] +fn provider_backed_summary_resolves_the_definition_model_not_the_record() { + let mut record = bare_agent_record(Some("p1"), Some("pre-edit-model"), Some("anthropic")); + record.backend = BackendKind::Provider { + id: "ssh".to_string(), + config: serde_json::json!({ "host": "example" }), + }; + let personas = vec![persona_record( + "p1", + Some("post-edit-model"), + Some("anthropic"), + )]; + let global = crate::managed_agents::GlobalAgentConfig::default(); + + let resolved = crate::managed_agents::effective_config::resolve_effective_config( + &record, &personas, &global, + ); + let cfg = match resolved { + crate::managed_agents::effective_config::EffectiveConfigResult::Resolved(cfg) => cfg, + _ => panic!("a linked record with a present definition must resolve"), + }; + + assert_eq!( + cfg.model.value.as_deref(), + Some("post-edit-model"), + "the summary's model is the definition's, so the record's stale \ + `pre-edit-model` column is never surfaced to the dialog" + ); + // The record itself is deliberately left untouched — proof that the fix + // does not depend on the record column ever being refreshed. + assert_eq!(record.model.as_deref(), Some("pre-edit-model")); } diff --git a/desktop/src-tauri/src/commands/personas/delete.rs b/desktop/src-tauri/src/commands/personas/delete.rs new file mode 100644 index 0000000000..5b8d29b157 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/delete.rs @@ -0,0 +1,260 @@ +//! Persona deletion and its managed-agent cascade. +//! +//! Deleting a persona deletes every managed-agent instance built from it. The +//! interesting part is the provider-backed subset of that cascade: those +//! instances are deployed units this app can forget but not stop, because the +//! provider protocol is deploy-only. `preflight_remote_deployed_cascade` is +//! where that asymmetry is made explicit rather than assumed. + +use tauri::AppHandle; + +use crate::{ + app_state::AppState, + managed_agents::{ + current_instance_id, delete_agent_key, load_managed_agents, load_personas, load_teams, + save_managed_agents, save_personas, stop_managed_agent_process, + sync_managed_agent_processes, try_regenerate_nest, validate_persona_deletion, + ManagedAgentRecord, + }, +}; + +use super::tombstone_persona_pending; + +/// Return pubkeys of every managed agent whose definition is the given persona. +/// +/// Pure helper used by `delete_persona` to determine which agent records to +/// cascade-delete. Extracted so the filtering logic can be unit-tested without +/// a full Tauri `AppHandle`. +fn collect_cascade_pubkeys(agents: &[ManagedAgentRecord], persona_id: &str) -> Vec { + agents + .iter() + .filter(|a| a.persona_id.as_deref() == Some(persona_id)) + .map(|a| a.pubkey.clone()) + .collect() +} + +/// Names of cascade agents that are provider-deployed: non-local backend with +/// a live `backend_agent_id`. +/// +/// Pure helper behind `delete_persona`'s pre-flight. Deleting one of these +/// records is not the same act as deleting a local one: the provider protocol +/// has no undeploy (see `agents.rs`, deploy-only v1), so the remote unit named +/// by `backend_agent_id` keeps running after its record is gone. +fn collect_remote_deployed( + agents: &[ManagedAgentRecord], + cascade: &std::collections::HashSet, +) -> Vec { + agents + .iter() + .filter(|a| { + cascade.contains(&a.pubkey) + && a.backend != crate::managed_agents::BackendKind::Local + && a.backend_agent_id.is_some() + }) + .map(|a| a.name.clone()) + .collect() +} + +/// Pre-flight gate for a cascade that contains provider-deployed instances. +/// +/// Two-step contract, mirroring `delete_managed_agent`'s `force_remote_delete` +/// parameter: a caller that has not acknowledged the consequence is refused, +/// and a caller that has is allowed through. The consequence is specific — the +/// provider protocol has no undeploy, so cascade-deleting these records leaves +/// their remote units running — which is why the acknowledgement is explicit +/// rather than implied by the delete itself. +/// +/// Without the opt-in the error is unchanged from before the opt-in existed, +/// so an IPC caller that never learned about the flag sees identical behavior. +fn preflight_remote_deployed_cascade( + agents: &[ManagedAgentRecord], + cascade: &std::collections::HashSet, + persona_id: &str, + force_remote_delete: bool, +) -> Result<(), String> { + if force_remote_delete { + return Ok(()); + } + let remote_deployed = collect_remote_deployed(agents, cascade); + if remote_deployed.is_empty() { + return Ok(()); + } + Err(format!( + "persona {persona_id} has provider-deployed agent instances ({}); delete those agent instances first", + remote_deployed.join(", ") + )) +} + +/// Remove cascade agents from `agents` and persist via the injectable `save`. +/// +/// Extracted from `delete_persona` so unit tests can inject a failing save and +/// verify retry-safety without a full `AppHandle` mock: if `save` returns `Err`, +/// this function propagates it before the keyring deletions and tombstones that +/// appear after the `?` in the call site — nothing is destroyed and the command +/// is safe to retry. +fn commit_cascade_agents( + agents: &mut Vec, + cascade: &std::collections::HashSet, + save: impl FnOnce(&[ManagedAgentRecord]) -> Result<(), String>, +) -> Result<(), String> { + agents.retain(|a| !cascade.contains(&a.pubkey)); + save(agents) +} + +/// Delete a persona and cascade-delete every managed-agent instance built from it. +/// +/// `force_remote_delete` acknowledges the one consequence a cascade cannot undo: +/// provider-backed instances are deployed units this app can only forget, never +/// tear down (the provider protocol is deploy-only), so their remote processes +/// keep running after the cascade. Without the flag such a cascade is refused; +/// with it, the instances are deleted along with the persona. Local-only +/// cascades never consult the flag. +#[tauri::command] +pub async fn delete_persona( + id: String, + force_remote_delete: Option, + app: AppHandle, +) -> Result<(), String> { + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + + { + // Store lock held across all three phases. + // Lock ordering: store lock (acquired here) → process lock (per-agent in Phase 2). + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + + // Load and validate the persona before any destructive work. + let mut personas = load_personas(&app)?; + let persona = personas + .iter() + .find(|record| record.id == id) + .ok_or_else(|| format!("persona {id} not found"))?; + let referenced_by_team = load_teams(&app)?.iter().any(|team| { + team.persona_ids + .iter() + .any(|persona_id| persona_id == id.as_str()) + }); + validate_persona_deletion(persona, referenced_by_team)?; + // Capture the coordinate before the record might leave the list. Only + // reached for non-builtin, non-team personas (both rejected above), + // so every deleted persona here is one this owner published. + let d_tag = crate::managed_agents::persona_events::persona_d_tag(persona); + + // ── Phase 1: Stage ───────────────────────────────────────────── + // + // Load agents, sync process state, and build the cascade set. Lock + // ordering: store lock (held) → process lock (acquired for sync, + // then released before Phase 2 stops). Every fallible read/lock is + // here; an error leaves all state intact and the command is retryable. + let mut agents = load_managed_agents(&app)?; + { + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + let (sync_changed, exited_pubkeys) = sync_managed_agent_processes( + &mut agents, + &mut runtimes, + ¤t_instance_id(&app), + ); + if sync_changed { + save_managed_agents(&app, &agents)?; + } + for pk in &exited_pubkeys { + state.clear_agent_session_caches(pk); + } + // runtimes drops here (process lock released before Phase 2). + } + + // Build the cascade set. HashSet for O(1) membership in Phase 3. + let cascade: std::collections::HashSet = + collect_cascade_pubkeys(&agents, &id).into_iter().collect(); + + // Remote-agent pre-flight: run before any destructive work, so an + // unacknowledged cascade fails with everything still on disk. + // Nothing in create_managed_agent forbids a persona-linked provider + // agent, so this is a runtime guard, not an assumed invariant. + preflight_remote_deployed_cascade( + &agents, + &cascade, + &id, + force_remote_delete.unwrap_or(false), + )?; + + // ── Phase 2: Stop ─────────────────────────────────────────────── + // + // Best-effort stop each running cascade instance. Lock ordering: + // store lock (held) → process lock acquired per-agent and released + // between stops so the process lock is not held across the full poll + // cycle (stop_managed_agent_process polls 100ms×10 before SIGKILL). + // + // Per-agent stop errors are swallowed — these records are deleted in + // Phase 3 regardless. Intentional difference from delete_managed_agent + // (single-agent, fatal on stop failure); here the cascade is multi-agent + // and deletion must proceed even if one instance cannot be stopped. + for pk in &cascade { + if let Some(rec) = agents.iter_mut().find(|a| a.pubkey == *pk) { + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + if let Err(e) = stop_managed_agent_process(&app, rec, &mut runtimes) { + eprintln!("buzz-desktop: delete_persona: failed to stop agent {pk}: {e}"); + } + // runtimes drops here (per-agent, process lock not held across stops). + } + } + + // ── Phase 3: Commit ───────────────────────────────────────────── + // + // Disk-authoritative writes first, side effects strictly after. + // commit_cascade_agents is an injectable seam so unit tests can + // verify retry-safety: a failing save propagates before any keyring + // deletion or tombstone occurs. + // + // Failure semantics: + // agent save fails → nothing destroyed; full cascade retries cleanly + // persona save fails → cascade agents gone, persona survives; a retry + // finds an empty cascade and proceeds cleanly + // Keys and tombstones are enqueued only after their records leave disk. + if !cascade.is_empty() { + commit_cascade_agents(&mut agents, &cascade, |recs| { + save_managed_agents(&app, recs) + })?; + } + + let original_len = personas.len(); + personas.retain(|record| record.id != id); + if personas.len() == original_len { + return Err(format!("persona {id} not found")); + } + save_personas(&app, &personas)?; + + // Side effects — strictly after records leave disk. + for pk in &cascade { + state.clear_agent_session_caches(pk); + // Remove nsec from keyring after the record is gone. + delete_agent_key(pk); + crate::commands::agents::tombstone_managed_agent_pending(&app, &state, pk); + crate::commands::agents::archive_managed_agent_pending(&app, &state, pk); + } + tombstone_persona_pending(&app, &state, &d_tag); + + // _store_guard drops here, before try_regenerate_nest. + } + + try_regenerate_nest(&app); + + Ok(()) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +#[cfg(test)] +#[path = "delete_cascade_tests.rs"] +mod delete_cascade_tests; diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 8ff7cfbd9b..4478c75399 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -6,7 +6,10 @@ //! helper that identifies the agents to cascade-delete, using plain //! in-memory data structures (no `AppHandle` required). -use super::{collect_cascade_pubkeys, collect_remote_deployed, commit_cascade_agents}; +use super::{ + collect_cascade_pubkeys, collect_remote_deployed, commit_cascade_agents, + preflight_remote_deployed_cascade, +}; use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; use std::collections::BTreeMap; use std::collections::HashSet; @@ -169,6 +172,24 @@ fn failing_save_is_retry_safe() { /// never-deployed provider agents must not block. #[test] fn remote_deployed_cascade_target_blocks_delete() { + let agents = mixed_cascade_agents(); + let cascade: HashSet = collect_cascade_pubkeys(&agents, PERSONA_ID) + .into_iter() + .collect(); + assert_eq!(cascade.len(), 3, "all three agents are cascade targets"); + + let blockers = collect_remote_deployed(&agents, &cascade); + + assert_eq!( + blockers, + vec!["Deployed Agent".to_string()], + "only the deployed provider agent blocks the cascade" + ); +} + +/// A local agent, a deployed provider agent, and a provider agent that never +/// deployed — all linked to `PERSONA_ID`. +fn mixed_cascade_agents() -> Vec { let mut deployed = make_agent("pk-deployed", Some(PERSONA_ID), None); deployed.name = "Deployed Agent".to_string(); deployed.backend = BackendKind::Provider { @@ -184,21 +205,86 @@ fn remote_deployed_cascade_target_blocks_delete() { config: serde_json::Value::Null, }; - let agents = vec![ + vec![ make_agent("pk-local", Some(PERSONA_ID), None), deployed, undeployed, - ]; - let cascade: HashSet = collect_cascade_pubkeys(&agents, PERSONA_ID) + ] +} + +fn cascade_of(agents: &[ManagedAgentRecord]) -> HashSet { + collect_cascade_pubkeys(agents, PERSONA_ID) .into_iter() - .collect(); - assert_eq!(cascade.len(), 3, "all three agents are cascade targets"); + .collect() +} - let blockers = collect_remote_deployed(&agents, &cascade); +/// Without the opt-in, a cascade containing a provider-deployed instance is +/// refused — the pre-existing contract, unchanged, so an IPC caller that never +/// learned about the flag sees exactly the error it saw before. +#[test] +fn preflight_refuses_remote_cascade_without_opt_in() { + let agents = mixed_cascade_agents(); + let cascade = cascade_of(&agents); - assert_eq!( - blockers, - vec!["Deployed Agent".to_string()], - "only the deployed provider agent blocks the cascade" + let result = preflight_remote_deployed_cascade(&agents, &cascade, PERSONA_ID, false); + + let error = result.expect_err("deployed provider instance must refuse the cascade"); + assert!( + error.contains("Deployed Agent"), + "error names the blocking instance: {error}" + ); + assert!( + error.contains("delete those agent instances first"), + "error keeps its original wording: {error}" + ); +} + +/// With the opt-in, the same cascade is allowed through. The caller has +/// acknowledged that the remote units keep running; the desktop cannot stop +/// them either way, so refusing only strands the records. +#[test] +fn preflight_allows_remote_cascade_with_opt_in() { + let agents = mixed_cascade_agents(); + let cascade = cascade_of(&agents); + + assert!( + preflight_remote_deployed_cascade(&agents, &cascade, PERSONA_ID, true).is_ok(), + "opt-in admits the provider-backed cascade" + ); +} + +/// A local-only cascade never consults the flag: it passes the pre-flight with +/// or without the opt-in, so ordinary persona deletes are untouched by this. +#[test] +fn preflight_ignores_opt_in_for_local_only_cascade() { + let agents = vec![ + make_agent("pk-local-a", Some(PERSONA_ID), None), + make_agent("pk-local-b", Some(PERSONA_ID), Some(4242)), + ]; + let cascade = cascade_of(&agents); + + for force in [false, true] { + assert!( + preflight_remote_deployed_cascade(&agents, &cascade, PERSONA_ID, force).is_ok(), + "local-only cascade passes with force_remote_delete={force}" + ); + } +} + +/// A provider agent that never completed a deploy has no remote unit to +/// orphan, so it must not require the opt-in. +#[test] +fn preflight_allows_never_deployed_provider_without_opt_in() { + let mut undeployed = make_agent("pk-undeployed", Some(PERSONA_ID), None); + undeployed.backend = BackendKind::Provider { + id: "blox".to_string(), + config: serde_json::Value::Null, + }; + let agents = vec![undeployed]; + let cascade = cascade_of(&agents); + + assert!( + preflight_remote_deployed_cascade(&agents, &cascade, PERSONA_ID, false).is_ok(), + "never-deployed provider agent needs no acknowledgement" ); } diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 66f7296a25..61e260cbe4 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -3,10 +3,8 @@ use tauri::AppHandle; use crate::{ app_state::AppState, managed_agents::{ - current_instance_id, delete_agent_key, load_managed_agents, load_personas, load_teams, - save_managed_agents, save_personas, stop_managed_agent_process, - sync_managed_agent_processes, try_regenerate_nest, validate_persona_activation_change, - validate_persona_deletion, AgentDefinition, ManagedAgentRecord, + load_managed_agents, load_personas, load_teams, save_personas, try_regenerate_nest, + validate_persona_activation_change, AgentDefinition, }, util::now_iso, }; @@ -56,201 +54,8 @@ pub async fn list_personas(app: AppHandle) -> Result, Strin .map_err(|e| format!("spawn_blocking failed: {e}"))? } -#[cfg(test)] -mod delete_cascade_tests; - -/// Return pubkeys of every managed agent whose definition is the given persona. -/// -/// Pure helper used by `delete_persona` to determine which agent records to -/// cascade-delete. Extracted so the filtering logic can be unit-tested without -/// a full Tauri `AppHandle`. -fn collect_cascade_pubkeys(agents: &[ManagedAgentRecord], persona_id: &str) -> Vec { - agents - .iter() - .filter(|a| a.persona_id.as_deref() == Some(persona_id)) - .map(|a| a.pubkey.clone()) - .collect() -} - -/// Names of cascade agents that are provider-deployed: non-local backend with -/// a live `backend_agent_id`. -/// -/// Pure helper used by `delete_persona`'s pre-flight: the cascade is refused -/// while any exist, because deleting the local record would orphan the remote -/// deployment. Mirrors `delete_managed_agent`'s `force_remote_delete` guard. -fn collect_remote_deployed( - agents: &[ManagedAgentRecord], - cascade: &std::collections::HashSet, -) -> Vec { - agents - .iter() - .filter(|a| { - cascade.contains(&a.pubkey) - && a.backend != crate::managed_agents::BackendKind::Local - && a.backend_agent_id.is_some() - }) - .map(|a| a.name.clone()) - .collect() -} - -/// Remove cascade agents from `agents` and persist via the injectable `save`. -/// -/// Extracted from `delete_persona` so unit tests can inject a failing save and -/// verify retry-safety without a full `AppHandle` mock: if `save` returns `Err`, -/// this function propagates it before the keyring deletions and tombstones that -/// appear after the `?` in the call site — nothing is destroyed and the command -/// is safe to retry. -fn commit_cascade_agents( - agents: &mut Vec, - cascade: &std::collections::HashSet, - save: impl FnOnce(&[ManagedAgentRecord]) -> Result<(), String>, -) -> Result<(), String> { - agents.retain(|a| !cascade.contains(&a.pubkey)); - save(agents) -} - -#[tauri::command] -pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { - use tauri::Manager; - tokio::task::spawn_blocking(move || { - let state = app.state::(); - - { - // Store lock held across all three phases. - // Lock ordering: store lock (acquired here) → process lock (per-agent in Phase 2). - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - - // Load and validate the persona before any destructive work. - let mut personas = load_personas(&app)?; - let persona = personas - .iter() - .find(|record| record.id == id) - .ok_or_else(|| format!("persona {id} not found"))?; - let referenced_by_team = load_teams(&app)?.iter().any(|team| { - team.persona_ids - .iter() - .any(|persona_id| persona_id == id.as_str()) - }); - validate_persona_deletion(persona, referenced_by_team)?; - // Capture the coordinate before the record might leave the list. Only - // reached for non-builtin, non-team personas (both rejected above), - // so every deleted persona here is one this owner published. - let d_tag = crate::managed_agents::persona_events::persona_d_tag(persona); - - // ── Phase 1: Stage ───────────────────────────────────────────── - // - // Load agents, sync process state, and build the cascade set. Lock - // ordering: store lock (held) → process lock (acquired for sync, - // then released before Phase 2 stops). Every fallible read/lock is - // here; an error leaves all state intact and the command is retryable. - let mut agents = load_managed_agents(&app)?; - { - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|error| error.to_string())?; - let (sync_changed, exited_pubkeys) = sync_managed_agent_processes( - &mut agents, - &mut runtimes, - ¤t_instance_id(&app), - ); - if sync_changed { - save_managed_agents(&app, &agents)?; - } - for pk in &exited_pubkeys { - state.clear_agent_session_caches(pk); - } - // runtimes drops here (process lock released before Phase 2). - } - - // Build the cascade set. HashSet for O(1) membership in Phase 3. - let cascade: std::collections::HashSet = - collect_cascade_pubkeys(&agents, &id).into_iter().collect(); - - // Remote-agent pre-flight: refuse the cascade before any destructive - // work while any target is provider-deployed. Nothing in - // create_managed_agent forbids a persona-linked provider agent, so - // this must be a runtime guard, not an assumed invariant. - let remote_deployed = collect_remote_deployed(&agents, &cascade); - if !remote_deployed.is_empty() { - return Err(format!( - "persona {id} has provider-deployed agent instances ({}); delete those agent instances first", - remote_deployed.join(", ") - )); - } - - // ── Phase 2: Stop ─────────────────────────────────────────────── - // - // Best-effort stop each running cascade instance. Lock ordering: - // store lock (held) → process lock acquired per-agent and released - // between stops so the process lock is not held across the full poll - // cycle (stop_managed_agent_process polls 100ms×10 before SIGKILL). - // - // Per-agent stop errors are swallowed — these records are deleted in - // Phase 3 regardless. Intentional difference from delete_managed_agent - // (single-agent, fatal on stop failure); here the cascade is multi-agent - // and deletion must proceed even if one instance cannot be stopped. - for pk in &cascade { - if let Some(rec) = agents.iter_mut().find(|a| a.pubkey == *pk) { - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|error| error.to_string())?; - if let Err(e) = stop_managed_agent_process(&app, rec, &mut runtimes) { - eprintln!("buzz-desktop: delete_persona: failed to stop agent {pk}: {e}"); - } - // runtimes drops here (per-agent, process lock not held across stops). - } - } - - // ── Phase 3: Commit ───────────────────────────────────────────── - // - // Disk-authoritative writes first, side effects strictly after. - // commit_cascade_agents is an injectable seam so unit tests can - // verify retry-safety: a failing save propagates before any keyring - // deletion or tombstone occurs. - // - // Failure semantics: - // agent save fails → nothing destroyed; full cascade retries cleanly - // persona save fails → cascade agents gone, persona survives; a retry - // finds an empty cascade and proceeds cleanly - // Keys and tombstones are enqueued only after their records leave disk. - if !cascade.is_empty() { - commit_cascade_agents(&mut agents, &cascade, |recs| { - save_managed_agents(&app, recs) - })?; - } - - let original_len = personas.len(); - personas.retain(|record| record.id != id); - if personas.len() == original_len { - return Err(format!("persona {id} not found")); - } - save_personas(&app, &personas)?; - - // Side effects — strictly after records leave disk. - for pk in &cascade { - state.clear_agent_session_caches(pk); - // Remove nsec from keyring after the record is gone. - delete_agent_key(pk); - super::agents::tombstone_managed_agent_pending(&app, &state, pk); - super::agents::archive_managed_agent_pending(&app, &state, pk); - } - tombstone_persona_pending(&app, &state, &d_tag); - - // _store_guard drops here, before try_regenerate_nest. - } - - try_regenerate_nest(&app); - - Ok(()) - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? -} +mod delete; +pub use delete::delete_persona; #[tauri::command] pub async fn set_persona_active( diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 35f4eae866..5d7d9a4e2f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -802,6 +802,8 @@ pub fn run() { update_managed_agent, discover_backend_providers, probe_backend_provider, + discover_provider_harnesses, + probe_provider_models, list_personas, create_persona, update_persona, diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 2a72af92d7..3cbdc56762 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -1,8 +1,10 @@ use std::io::{BufReader, Read, Write}; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::mpsc; use std::time::Duration; +use super::provider_recovery::{ProviderFailure, ProviderRecovery}; + const STDERR_CAP: usize = 65536; /// Provider responses should be small JSON objects. Cap stdout to prevent a /// buggy or malicious provider from OOM-ing the desktop process. @@ -20,7 +22,7 @@ pub fn invoke_provider( binary: &Path, request: &serde_json::Value, timeout: Duration, -) -> Result { +) -> Result { let request_bytes = format!( "{}\n", serde_json::to_string(request).map_err(|e| e.to_string())? @@ -98,7 +100,7 @@ pub fn invoke_provider( if let Err(e) = stdin_result { let _ = child.kill(); let _ = child.wait(); - return Err(format!("stdin write failed: {e}")); + return Err(format!("stdin write failed: {e}").into()); } // Poll try_wait with a deadline, collecting stdout chunks and draining @@ -132,14 +134,14 @@ pub fn invoke_provider( if std::time::Instant::now() >= deadline { let _ = child.kill(); let _ = child.wait(); - return Err(format!("provider timed out after {timeout_secs}s")); + return Err(format!("provider timed out after {timeout_secs}s").into()); } std::thread::sleep(Duration::from_millis(50)); } Err(e) => { let _ = child.kill(); let _ = child.wait(); - return Err(format!("wait error: {e}")); + return Err(format!("wait error: {e}").into()); } } }; @@ -194,14 +196,15 @@ pub fn invoke_provider( // output would be worse than surfacing the failure. let exited_ok = exit_status.success(); if !exited_ok { - let stderr_snippet = &stderr_redacted[..stderr_redacted.len().min(4096)]; - if stderr_snippet.is_empty() { - return Err(format!("provider failed ({exit_info}, empty stderr)")); - } else { - return Err(format!( - "provider failed ({exit_info}). stderr: {stderr_snippet}" - )); + return Err(match provider_stderr_notice(&stderr_redacted) { + Some(stderr_snippet) => { + format!("provider failed ({exit_info}). stderr: {stderr_snippet}") + } + None => format!("provider failed ({exit_info}, empty stderr)"), } + // No recovery: the provider died without emitting a structured + // response, so there is nothing to have carried one. + .into()); } // Incremental JSON parse: try each line, then try the entire buffer. @@ -212,25 +215,57 @@ pub fn invoke_provider( .lines() .find_map(|line| serde_json::from_str(line).ok()) .or_else(|| serde_json::from_str(stdout_str.trim()).ok()) - .ok_or_else(|| { - let stderr_snippet = &stderr_redacted[..stderr_redacted.len().min(4096)]; - if stderr_snippet.is_empty() { + .ok_or_else(|| match provider_stderr_notice(&stderr_redacted) { + Some(stderr_snippet) => format!( + "provider produced no JSON response ({exit_info}). stderr: {stderr_snippet}" + ), + None => { format!("provider produced no JSON response ({exit_info}, empty stderr)") - } else { - format!( - "provider produced no JSON response ({exit_info}). stderr: {stderr_snippet}" - ) } })?; if response.get("ok").and_then(|v| v.as_bool()) == Some(false) { let error = response["error"].as_str().unwrap_or("unknown error"); - return Err(redact_secrets_with(error, &env_secret_refs)); + return Err(ProviderFailure { + message: redact_secrets_with(error, &env_secret_refs), + // The message stands alone whether or not this resolves: it names + // the problem and, for the Tailscale case, carries the URL as text. + // The recovery only adds a button. + recovery: ProviderRecovery::from_response(&response), + }); + } + + // A successful op's stderr is not an error, but it is not nothing either: + // providers write their non-fatal complaints there (today, deploy's + // "this host has no buzz CLI" WARNING). Without this the buffer is dropped + // on success and the warning is invisible. Log-only by design — the op + // succeeded, and a warning is not a result. + if let Some(notice) = provider_stderr_notice(&stderr_redacted) { + tracing::warn!("provider {}: {notice}", binary.display()); } Ok(response) } +/// The reportable form of a provider's stderr: `None` when it holds nothing +/// but whitespace, otherwise the already-redacted text trimmed and capped at +/// 4 KiB. The cap walks back to a char boundary so a multi-byte character +/// straddling it cannot panic. Both the success path (which logs it) and the +/// two failure paths (which fold it into the returned error) go through here, +/// so the snippet a warning shows and the snippet an error reports are the +/// same text under the same cap. +fn provider_stderr_notice(stderr_redacted: &str) -> Option<&str> { + let trimmed = stderr_redacted.trim(); + if trimmed.is_empty() { + return None; + } + let end = (0..=trimmed.len().min(4096)) + .rev() + .find(|&i| trimmed.is_char_boundary(i)) + .unwrap_or(0); + Some(&trimmed[..end]) +} + /// Split a config key into lowercase words on `_`, `-`, `.`, and camelCase boundaries. /// /// Handles acronyms: consecutive uppercase runs stay together until a lowercase follows. @@ -362,7 +397,7 @@ pub fn provider_deploy( binary: &Path, agent: &serde_json::Value, provider_config: &serde_json::Value, -) -> Result { +) -> Result { let request = serde_json::json!({ "op": "deploy", "request_id": uuid::Uuid::new_v4().to_string(), @@ -373,7 +408,53 @@ pub fn provider_deploy( resp["agent_id"] .as_str() .map(String::from) - .ok_or_else(|| "deploy response missing agent_id".to_string()) + .ok_or_else(|| "deploy response missing agent_id".into()) +} + +/// Ask a provider which harnesses exist on the machine it deploys to. +/// +/// This is the catalog the create dialog must pick from for a remote agent: +/// the local `KNOWN_ACP_RUNTIMES` probe describes THIS computer, and what is +/// installed here says nothing about what is installed there. The `command` +/// on the chosen entry becomes the create-time `agentCommand` pin, which is +/// the only channel by which the harness choice reaches the host (see +/// `deploy_payload_json`). +/// +/// The read is side-effect-free on the remote host, but it is a network round +/// trip against a possibly distant machine, so the budget is generous. +pub fn provider_discover_harnesses( + binary: &Path, + provider_config: &serde_json::Value, +) -> Result { + let request = serde_json::json!({ + "op": "discover_harnesses", + "request_id": uuid::Uuid::new_v4().to_string(), + "provider_config": provider_config, + }); + invoke_provider(binary, &request, Duration::from_secs(60)) +} + +/// Ask a provider for the model catalog of one remote harness. +/// +/// `agent` carries the harness env (API keys) under `env_vars` — the same +/// shape `deploy` uses — because that is the only key `invoke_provider` +/// scrubs from error surfaces (`env_secrets_from_request`). Passing model env +/// anywhere else would let a provider echo a credential back through an error +/// string unredacted. +pub fn provider_probe_models( + binary: &Path, + provider_config: &serde_json::Value, + harness: &serde_json::Value, + env_vars: &std::collections::BTreeMap, +) -> Result { + let request = serde_json::json!({ + "op": "probe_models", + "request_id": uuid::Uuid::new_v4().to_string(), + "provider_config": provider_config, + "harness": harness, + "agent": { "env_vars": env_vars }, + }); + invoke_provider(binary, &request, Duration::from_secs(150)) } /// Validate provider_config: flat object, scalar values, no secret-like keys. @@ -409,123 +490,29 @@ pub fn validate_provider_config(config: &serde_json::Value) -> Result<(), String Ok(()) } -/// Enumerate PATH for buzz-backend-* executables. Returns (id, path) pairs. -/// Only includes files that are executable. Does NOT execute any binaries. -/// -/// On macOS, GUI apps inherit a minimal PATH from launchd (`/usr/bin:/bin:/usr/sbin:/sbin`) -/// which excludes both the app bundle's `Contents/MacOS/` dir and `~/.local/bin`. -/// We augment the search with those directories so bundled and user-installed providers -/// are always discovered regardless of how the desktop was launched. -pub fn discover_provider_candidates() -> Vec<(String, PathBuf)> { - let prefix = "buzz-backend-"; - let mut seen = std::collections::HashSet::new(); - let mut results = Vec::new(); - - let path_var = std::env::var_os("PATH").unwrap_or_default(); - let mut dirs: Vec = std::env::split_paths(&path_var).collect(); - - // Prepend the exe parent dir (Contents/MacOS/ in a .app bundle) so bundled - // providers are found even when the process PATH is minimal. - if let Ok(exe) = std::env::current_exe() { - if let Some(parent) = exe.parent() { - let parent_buf = parent.to_path_buf(); - if !dirs.contains(&parent_buf) { - dirs.insert(0, parent_buf); - } - } - } - - // Also include ~/.local/bin — the conventional location for user-installed - // provider binaries (symlinks created by install scripts). - if let Some(home) = dirs::home_dir() { - let local_bin = home.join(".local").join("bin"); - if !dirs.contains(&local_bin) { - dirs.push(local_bin); - } - } - - for dir in dirs { - let Ok(entries) = std::fs::read_dir(&dir) else { - continue; - }; - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if let Some(id) = name.strip_prefix(prefix) { - if !id.is_empty() && !seen.contains(&name) && is_executable(&entry.path()) { - seen.insert(name.clone()); - results.push((id.to_string(), entry.path())); - } - } - } - } - results -} +#[cfg(test)] +mod tests { + use super::*; -/// Resolve a provider ID to a discovered, executable binary path. -/// -/// This is the ONLY way to resolve provider binaries for execution. It: -/// 1. Validates the ID against `^[a-z0-9][a-z0-9_-]*$` (no path traversal) -/// 2. Looks up the ID in `discover_provider_candidates()` (PATH-discovered only) -/// 3. Returns the canonical path of the discovered binary -/// -/// All deploy, start, and create paths MUST use this instead of raw -/// `resolve_command(format!("buzz-backend-{id}"))` to prevent a compromised -/// frontend/IPC caller from steering execution to an arbitrary binary. -pub fn resolve_provider_binary(provider_id: &str) -> Result { - // Reject IDs that could be path components or shell metacharacters. - let valid_id = provider_id - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') - && !provider_id.is_empty() - && provider_id.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()); - if !valid_id { - return Err(format!( - "invalid provider ID '{provider_id}': must match [a-z0-9][a-z0-9_-]*" - )); - } - - let candidates = discover_provider_candidates(); - let found = candidates - .into_iter() - .find(|(id, _)| id == provider_id) - .map(|(_, path)| path); - - match found { - Some(path) => path - .canonicalize() - .map_err(|e| format!("provider binary not accessible: {e}")), - None => Err(format!( - "provider 'buzz-backend-{provider_id}' not found on PATH" - )), + #[test] + fn provider_stderr_notice_skips_blank_and_keeps_warnings() { + // The success path only logs when the provider actually said + // something — a blank buffer must not produce an empty warn line. + assert_eq!(provider_stderr_notice(""), None); + assert_eq!(provider_stderr_notice(" \n\t "), None); + assert_eq!( + provider_stderr_notice("buzz-backend-ssh: WARNING: no buzz CLI\n"), + Some("buzz-backend-ssh: WARNING: no buzz CLI") + ); } -} -/// Check if a file is executable (Unix: mode bits; other platforms: always true). -fn is_executable(path: &Path) -> bool { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - path.metadata() - .map(|m| m.permissions().mode() & 0o111 != 0) - .unwrap_or(false) - } - #[cfg(not(unix))] - { - let _ = path; - true + #[test] + fn provider_stderr_notice_caps_on_a_char_boundary() { + // A multi-byte char straddling the 4096-byte cap must not panic. + let long = format!("{}é", "a".repeat(4095)); + let notice = provider_stderr_notice(&long).expect("non-empty"); + assert_eq!(notice.len(), 4095); } -} - -#[derive(Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BackendProviderInfo { - pub id: String, - pub binary_path: String, -} - -#[cfg(test)] -mod tests { - use super::*; #[test] fn redact_secrets_replaces_nsec() { @@ -670,34 +657,4 @@ mod tests { assert_eq!(split_config_key("accessTOKEN"), vec!["access", "token"]); assert_eq!(split_config_key("MyAPIKey"), vec!["my", "api", "key"]); } - - #[test] - fn resolve_provider_binary_rejects_invalid_ids() { - // Path traversal - assert!(resolve_provider_binary("../evil").is_err()); - // Empty - assert!(resolve_provider_binary("").is_err()); - // Uppercase - assert!(resolve_provider_binary("MyProvider").is_err()); - // Spaces - assert!(resolve_provider_binary("my provider").is_err()); - // Shell metacharacters - assert!(resolve_provider_binary("foo;rm -rf /").is_err()); - // Valid format but not on PATH — should fail with "not found" - assert!(resolve_provider_binary("nonexistent-test-id-12345").is_err()); - } - - #[test] - fn resolve_provider_binary_accepts_valid_id_format() { - // Valid ID format should pass validation. If the binary happens to - // exist on PATH, Ok is returned; otherwise Err contains "not found" - // (not "invalid provider ID"). Either outcome proves validation passed. - match resolve_provider_binary("zzz-nonexistent-test-provider") { - Ok(_) => {} // unlikely but fine — binary exists - Err(e) => assert!( - e.contains("not found"), - "expected 'not found' error, got: {e}" - ), - } - } } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index eecbf4de3e..a83015a65f 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -311,12 +311,11 @@ pub fn record_agent_command( } if let Some(id) = record.runtime.as_deref() { - // Check static builtins first. + // Static builtins first, then the loaded preset/custom registry. if let Some(command) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) { return command.to_string(); } - // Fall back to loaded registry for preset/custom harnesses. if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) { return def.command.clone(); @@ -333,7 +332,7 @@ pub fn record_agent_command( /// Resolution order: /// 1. explicit override (non-empty) — a deliberate per-instance pin; /// 2. the linked persona's `runtime` id mapped to its primary command -/// (checks builtins then loaded preset/custom registry); +/// (builtins, then the loaded preset/custom registry); /// 3. `default_agent_command()` — no persona/runtime, or persona deleted. pub fn effective_agent_command( persona_id: Option<&str>, @@ -352,12 +351,11 @@ pub fn effective_agent_command( .and_then(|persona| persona.runtime.as_deref()); if let Some(id) = runtime_id { - // Check static builtins first. + // Static builtins first, then the loaded preset/custom registry. if let Some(command) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) { return command.to_string(); } - // Check loaded preset/custom registry. if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) { return def.command.clone(); @@ -368,7 +366,9 @@ pub fn effective_agent_command( } mod overrides; -pub use overrides::{apply_agent_command_update, create_time_agent_command_override}; +pub use overrides::{ + apply_agent_command_update, create_time_agent_args, create_time_agent_command_override, +}; /// Prefix of the typed dangling-harness error produced by /// `try_record_agent_command` / `resolve_effective_harness_descriptor`. diff --git a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs index 5140bb2cdd..b9d1f7a073 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs @@ -1,9 +1,11 @@ -//! The `agent_command_override` decision family: divergence comparison, -//! create-time and update-time resolution, and the record apply. Child module -//! of `discovery` (split under the desktop file-size cap) so the override -//! decisions stay next to the resolution ladder they feed. +//! The create/update harness-pin decision family: divergence comparison, +//! create-time and update-time command resolution, the create-time args +//! decision, and the record apply. Child module of `discovery` (split under +//! the desktop file-size cap) so the pin decisions stay next to the resolution +//! ladder they feed. -use super::{effective_agent_command, known_acp_runtime}; +use super::{effective_agent_command, known_acp_runtime, normalize_agent_args}; +use crate::managed_agents::types::BackendKind; /// Decide whether a user-picked harness command is an explicit per-instance /// pin or merely the persona's own runtime restated. Returns the override to @@ -151,3 +153,40 @@ pub fn create_time_agent_command_override( divergent_agent_command_override(persona_id, personas, picked_command) } + +/// Decide the `agent_args` to persist at AGENT CREATE time. +/// +/// A LOCAL create sends no args on purpose (spawn re-resolves them from the +/// definition on every start), so [`normalize_agent_args`] supplies the +/// runtime's defaults by looking the command up in the LOCAL catalog. +/// +/// A PROVIDER create is the opposite on both counts. Its args come from the +/// remote host's `discover_harnesses` catalog and are pinned verbatim, because +/// the record never spawns locally and so nothing resolves them a second time. +/// Normalizing them would compare a REMOTE command against LOCAL runtime +/// identity, which is a category error with real consequences: the host's +/// `/opt/foo/bin/goose` normalizes to the basename `goose`, so a pin of +/// `["acp", "--profile", "prod"]` survives only by accident, and a host binary +/// that merely shares a basename with a local runtime (`codex`, `buzz-agent`) +/// silently has its explicitly-chosen args rewritten — or emptied — before +/// they ever reach the deploy payload. +/// +/// So: pin as sent for a provider backend, normalize for a local one. Blank +/// args are still dropped in both cases — an empty string is never a real +/// argument, only a serialization artifact. +pub fn create_time_agent_args( + backend: &BackendKind, + agent_command: &str, + agent_args: &[String], +) -> Vec { + let trimmed = agent_args + .iter() + .map(|arg| arg.trim().to_string()) + .filter(|arg| !arg.is_empty()) + .collect::>(); + + match backend { + BackendKind::Provider { .. } => trimmed, + BackendKind::Local => normalize_agent_args(agent_command, trimmed), + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 1b587dca0e..77aef03585 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -626,12 +626,11 @@ fn create_time_override_preserves_pin_for_persona_less_create() { #[test] fn update_time_override_preserves_same_runtime_pin_when_overriding() { - // The bug this fixes: the user picks "Custom command" in the edit - // dialog and saves `goose` verbatim for a goose persona. That is a - // deliberate pin (harness_override true) — it must be kept so future - // persona runtime edits stop propagating, even though it maps to the - // persona's own runtime. `divergent_agent_command_override` alone would - // wrongly drop it to `None`. + // The bug this fixes: the user picks "Custom command" in the edit dialog + // and saves `goose` verbatim for a goose persona. That is a deliberate pin + // (harness_override true) — it must be kept so future persona runtime edits + // stop propagating, even though it maps to the persona's own runtime. + // `divergent_agent_command_override` alone would wrongly drop it to `None`. let personas = vec![persona_with_runtime("p1", Some("goose"))]; assert_eq!( update_time_agent_command_override(Some("p1"), &personas, Some("goose"), true), @@ -751,10 +750,11 @@ fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() { assert_eq!(record_agent_command(&record, &personas), "codex-acp"); } -// ── probe_codex_acp_version ─────────────────────────────────────────────────── - +mod create_time_args; mod managed_path_resolution; +// ── probe_codex_acp_version ─────────────────────────────────────────────────── + #[cfg(unix)] #[test] fn probe_codex_acp_version_parses_full_semver_output() { diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/create_time_args.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/create_time_args.rs new file mode 100644 index 0000000000..b01c7fed67 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/create_time_args.rs @@ -0,0 +1,79 @@ +//! `create_time_agent_args`: which authority decides a new record's +//! `agent_args`. A local create is normalized against the LOCAL runtime +//! catalog; a provider create is pinned verbatim from the REMOTE host's. +//! +//! Every test below pairs the two backends over the SAME input, because the +//! bug this guards against is invisible otherwise: a host binary whose +//! basename collides with a local runtime is normalized without complaint. + +use crate::managed_agents::{discovery::create_time_agent_args, BackendKind}; + +fn provider() -> BackendKind { + BackendKind::Provider { + id: "ssh".to_string(), + config: serde_json::json!({}), + } +} + +#[test] +fn create_time_args_normalize_against_the_local_catalog_for_a_local_create() { + // A local record re-resolves its args from the definition on every spawn, + // so the local catalog is the right authority here: `goose` gains its + // default `acp`, and a buzz-agent create's stray `acp` is dropped. + assert_eq!( + create_time_agent_args(&BackendKind::Local, "goose", &[]), + vec!["acp".to_string()] + ); + assert_eq!( + create_time_agent_args(&BackendKind::Local, "buzz-agent", &["acp".to_string()]), + Vec::::new() + ); +} + +#[test] +fn create_time_args_pin_a_provider_create_verbatim() { + // The discriminator: a REMOTE binary whose basename collides with a local + // runtime. `/opt/hosttools/bin/buzz-agent` normalizes to the local + // `buzz-agent` identity, whose default args are empty, so the local path + // deletes a lone `acp` — even though the HOST's own catalog is what + // reported that argument. The deploy payload reads `record.agent_args` + // directly, with no second resolution, so the host binary would launch + // bare and the create's harness choice would be silently wrong. + let pinned = ["acp".to_string()]; + + assert_eq!( + create_time_agent_args(&provider(), "/opt/hosttools/bin/buzz-agent", &pinned), + pinned.to_vec() + ); + assert_eq!( + create_time_agent_args( + &BackendKind::Local, + "/opt/hosttools/bin/buzz-agent", + &pinned + ), + Vec::::new(), + "the local path is what would have destroyed the pin" + ); +} + +#[test] +fn create_time_args_never_invent_args_for_a_provider_create() { + // The mirror of the case above: a host harness that takes no arguments + // must not acquire the local `goose` default. Only the host's catalog + // decides what its own binary is launched with. + assert_eq!( + create_time_agent_args(&provider(), "/opt/hosttools/bin/goose", &[]), + Vec::::new() + ); + assert_eq!( + create_time_agent_args(&BackendKind::Local, "/opt/hosttools/bin/goose", &[]), + vec!["acp".to_string()], + "the local path is what would have invented the argument" + ); + // Blanks are still dropped — an empty string is a serialization artifact, + // never a real argument, on either backend. + assert_eq!( + create_time_agent_args(&provider(), "/opt/hosttools/bin/goose", &[" ".to_string()]), + Vec::::new() + ); +} diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 592a5cbbd9..4c8bb3f63b 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -71,6 +71,7 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", + "CLAUDE_CODE_EXECUTABLE", // Security gates: respond-to mode + allowlist + legacy owner-only // fallback. Overriding would make the running agent's gate diverge // from the saved/UI-visible settings. diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index cf57b12546..bafa3059d9 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -135,6 +135,14 @@ fn is_reserved_recognises_full_list() { assert!(!is_reserved_env_key("BUZZ_ACP_MODEL")); // behavior knob } +#[test] +fn reserved_keys_include_claude_code_executable() { + assert!(is_reserved_env_key("CLAUDE_CODE_EXECUTABLE")); + let agent = map(&[("CLAUDE_CODE_EXECUTABLE", "/tmp/untrusted-claude")]); + let merged = merged_user_env(&BTreeMap::new(), &agent); + assert!(merged.is_empty()); +} + #[test] fn reserved_keys_include_agent_owner_for_legacy_records() { // Legacy records without auth_tag fall back to BUZZ_ACP_AGENT_OWNER diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index b0e86f8edb..13490baaeb 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -20,6 +20,8 @@ pub(crate) mod persona_events; mod personas; #[cfg(windows)] mod process_lifecycle; +mod provider_discovery; +mod provider_recovery; pub(crate) mod readiness; pub(crate) mod reconcile; mod relay_mesh; @@ -60,6 +62,8 @@ pub use nest::*; pub use personas::*; #[cfg(windows)] pub use process_lifecycle::*; +pub use provider_discovery::*; +pub use provider_recovery::*; pub(crate) use readiness::{ agent_readiness, resolve_effective_agent_env, resolve_effective_harness_descriptor, AgentReadiness, Requirement, diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index ea61a811db..1037335cff 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -9,7 +9,7 @@ use buzz_core_pkg::kind::{persona_event_is_shared, KIND_PERSONA}; use nostr::{EventBuilder, Kind, Tag}; use serde::{Deserialize, Serialize}; -use super::{AgentDefinition, ManagedAgentRecord}; +use super::{AgentDefinition, BackendKind, ManagedAgentRecord}; use crate::app_state::AppState; /// The JSON body stored in a persona event's content field. @@ -413,10 +413,12 @@ pub struct PersonaSnapshot { pub model: Option, pub provider: Option, /// Preferred ACP runtime ID, copied verbatim from the persona (including - /// `None`). Unlike `model`/`provider`, there is no record-fallback: the - /// materialized instance `runtime` must mirror the definition so that - /// definition edits propagate on the next spawn rather than being silently - /// shadowed by the stale materialized value. + /// `None`). Unlike `model`/`provider`, there is no record-fallback: a LOCAL + /// instance's `runtime` must mirror the definition so that definition edits + /// propagate on the next spawn rather than being silently shadowed by the + /// stale materialized value. Provider-backed records are exempt — their + /// harness is chosen from the remote host's catalog, so a locally-resolved + /// id is not authoritative over it. See `apply_persona_snapshot`. pub runtime: Option, /// `persona_content_hash` of the persona at snapshot time; the drift basis. pub source_version: String, @@ -463,23 +465,44 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe } record.model = snapshot.model; record.provider = snapshot.provider; - record.runtime = snapshot.runtime; - // Drop a stale create-time harness pin when the definition names a - // different known runtime; custom commands stay pinned. - if let Some(def_runtime) = persona - .runtime - .as_deref() - .map(str::trim) - .filter(|r| !r.is_empty()) - .and_then(crate::managed_agents::known_acp_runtime_exact) - { - if let Some(pin_runtime) = record - .agent_command_override + // Mirror the definition's runtime and drop a stale create-time harness pin + // when the definition names a different known runtime; custom commands stay + // pinned. + // + // LOCAL RECORDS ONLY. For a provider-backed record the pin is not a + // local-runtime preference that the definition may override — it is the + // only channel by which the harness selected from the REMOTE host's + // catalog reaches that host (`deploy_payload_json` ships + // `record.agent_command`, which `record_agent_command` derives from this + // pin). The comparison below is against the LOCAL runtime registry, which + // knows nothing about what is installed on the remote machine, so clearing + // here would silently re-resolve the record to a locally-known command — + // ultimately `default_agent_command()` = `buzz-agent` — and the next + // deploy would provision the wrong harness. The persona's runtime is not + // authoritative over a remote harness choice, so the pin survives. + // + // `record.runtime` is scoped the same way and for the same reason: it names + // a harness id, `record_agent_command` resolves the deploy's command from it + // once the pin is absent, and the definition's id was chosen against the + // LOCAL catalog. Overwriting it on a provider record would let a local + // snapshot silently redirect a remote agent's harness. + if record.backend == BackendKind::Local { + record.runtime = snapshot.runtime; + if let Some(def_runtime) = persona + .runtime .as_deref() - .and_then(crate::managed_agents::known_acp_runtime) + .map(str::trim) + .filter(|r| !r.is_empty()) + .and_then(crate::managed_agents::known_acp_runtime_exact) { - if !std::ptr::eq(pin_runtime, def_runtime) { - record.agent_command_override = None; + if let Some(pin_runtime) = record + .agent_command_override + .as_deref() + .and_then(crate::managed_agents::known_acp_runtime) + { + if !std::ptr::eq(pin_runtime, def_runtime) { + record.agent_command_override = None; + } } } } diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index b9542f9a87..3f5083bbfa 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -764,6 +764,8 @@ fn definition_provider_set_model_blank_produces_none_model() { ); } +mod harness_pin; + // Gated off Windows for the same reason as `archive::real_relay`: // `build_app_state()` pulls native DLLs unavailable in the Windows CI // runner. This stub-relay test is hermetic (localhost axum) otherwise. diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests/harness_pin.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests/harness_pin.rs new file mode 100644 index 0000000000..7b67becc5e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests/harness_pin.rs @@ -0,0 +1,155 @@ +//! `apply_persona_snapshot`: the harness pin is backend-scoped. +//! +//! A provider-backed record's `agent_command_override` names a harness on the +//! remote host, so the "is this a known local runtime?" test that clears a +//! stale local pin cannot apply to it. These cases pin that asymmetry. + +use super::*; + +fn snapshot_record(backend: BackendKind) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "p".repeat(64), + name: "agent".into(), + persona_id: Some("test-persona".into()), + private_key_nsec: "nsec1fake".into(), + auth_tag: None, + relay_url: "ws://localhost:3000".into(), + avatar_url: None, + acp_command: "buzz-acp".into(), + agent_command: "claude-code-acp".into(), + // A create-time pin naming a known runtime — the shape the clear targets. + agent_command_override: Some("claude-code-acp".into()), + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: Some("You are a test agent.".into()), + model: None, + provider: None, + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "now".into(), + updated_at: "now".into(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: Default::default(), + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: Some("goose".into()), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + shared: false, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + } +} + +/// Local records keep today's behaviour: a pin naming a runtime the definition +/// no longer asks for is stale local preference and is dropped. +#[test] +fn local_record_drops_a_stale_known_runtime_pin() { + let mut record = snapshot_record(BackendKind::Local); + let persona = sample_persona(); // runtime = "goose" + + apply_persona_snapshot(&mut record, &persona); + + assert_eq!( + record.agent_command_override, None, + "a local pin diverging from the definition runtime is stale" + ); +} + +/// Provider records must NOT be cleared. The pin is the only channel by which +/// the harness chosen from the remote host's catalog reaches that host; the +/// comparison above is against the LOCAL registry, which knows nothing about +/// the remote machine. Clearing would re-resolve the record to a locally-known +/// command — ultimately `default_agent_command()` = "buzz-agent" — and the next +/// deploy would silently provision the wrong harness. +#[test] +fn provider_record_keeps_its_remote_harness_pin() { + let mut record = snapshot_record(BackendKind::Provider { + id: "ssh".into(), + config: serde_json::json!({ "host": "example" }), + }); + let persona = sample_persona(); // runtime = "goose", diverges from the pin + + apply_persona_snapshot(&mut record, &persona); + + assert_eq!( + record.agent_command_override.as_deref(), + Some("claude-code-acp"), + "a remote harness pin must survive persona reconciliation" + ); +} + +/// The rest of the snapshot still applies to a provider record — only the +/// harness fields are exempt. +#[test] +fn provider_record_still_takes_the_definition_quad() { + let mut record = snapshot_record(BackendKind::Provider { + id: "ssh".into(), + config: serde_json::Value::Null, + }); + let persona = sample_persona(); + + apply_persona_snapshot(&mut record, &persona); + + assert_eq!(record.model.as_deref(), Some("claude-opus-4")); + assert_eq!(record.provider.as_deref(), Some("anthropic")); +} + +/// `record.runtime` is harness state, scoped exactly like the pin above. It +/// names the harness the deploy resolves once no pin is present, and the +/// definition's id was chosen against the LOCAL catalog — so a persona sync +/// must not redirect a remote agent's harness with it. +#[test] +fn provider_record_keeps_its_runtime_through_a_persona_sync() { + let mut record = snapshot_record(BackendKind::Provider { + id: "ssh".into(), + config: serde_json::json!({ "host": "example" }), + }); + record.runtime = Some("claude".into()); + let persona = sample_persona(); // runtime = "goose", a local id + + apply_persona_snapshot(&mut record, &persona); + + assert_eq!( + record.runtime.as_deref(), + Some("claude"), + "a local snapshot must not overwrite a remote record's harness" + ); +} + +/// The local half of the same rule: a local record still mirrors the +/// definition, so a definition edit propagates on the next spawn. +#[test] +fn local_record_mirrors_the_definition_runtime() { + let mut record = snapshot_record(BackendKind::Local); + record.runtime = Some("claude".into()); + let persona = sample_persona(); // runtime = "goose" + + apply_persona_snapshot(&mut record, &persona); + + assert_eq!(record.runtime.as_deref(), Some("goose")); +} diff --git a/desktop/src-tauri/src/managed_agents/provider_discovery.rs b/desktop/src-tauri/src/managed_agents/provider_discovery.rs new file mode 100644 index 0000000000..63e2277911 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/provider_discovery.rs @@ -0,0 +1,157 @@ +//! Finding a provider binary: which files on PATH name a `buzz-backend-*` +//! provider, and which id resolves to which executable. +//! +//! Split out of `backend.rs` so the question "is this file a provider I may +//! execute?" — a naming and executability rule with its own security argument — +//! sits apart from actually invoking one. + +use std::path::{Path, PathBuf}; + +/// Enumerate PATH for buzz-backend-* executables. Returns (id, path) pairs. +/// Only includes files that are executable. Does NOT execute any binaries. +/// +/// On macOS, GUI apps inherit a minimal PATH from launchd (`/usr/bin:/bin:/usr/sbin:/sbin`) +/// which excludes both the app bundle's `Contents/MacOS/` dir and `~/.local/bin`. +/// We augment the search with those directories so bundled and user-installed providers +/// are always discovered regardless of how the desktop was launched. +pub fn discover_provider_candidates() -> Vec<(String, PathBuf)> { + let prefix = "buzz-backend-"; + let mut seen = std::collections::HashSet::new(); + let mut results = Vec::new(); + + let path_var = std::env::var_os("PATH").unwrap_or_default(); + let mut dirs: Vec = std::env::split_paths(&path_var).collect(); + + // Prepend the exe parent dir (Contents/MacOS/ in a .app bundle) so bundled + // providers are found even when the process PATH is minimal. + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + let parent_buf = parent.to_path_buf(); + if !dirs.contains(&parent_buf) { + dirs.insert(0, parent_buf); + } + } + } + + // Also include ~/.local/bin — the conventional location for user-installed + // provider binaries (symlinks created by install scripts). + if let Some(home) = dirs::home_dir() { + let local_bin = home.join(".local").join("bin"); + if !dirs.contains(&local_bin) { + dirs.push(local_bin); + } + } + + for dir in dirs { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if let Some(id) = name.strip_prefix(prefix) { + if !id.is_empty() && !seen.contains(&name) && is_executable(&entry.path()) { + seen.insert(name.clone()); + results.push((id.to_string(), entry.path())); + } + } + } + } + results +} + +/// Resolve a provider ID to a discovered, executable binary path. +/// +/// This is the ONLY way to resolve provider binaries for execution. It: +/// 1. Validates the ID against `^[a-z0-9][a-z0-9_-]*$` (no path traversal) +/// 2. Looks up the ID in `discover_provider_candidates()` (PATH-discovered only) +/// 3. Returns the canonical path of the discovered binary +/// +/// All deploy, start, and create paths MUST use this instead of raw +/// `resolve_command(format!("buzz-backend-{id}"))` to prevent a compromised +/// frontend/IPC caller from steering execution to an arbitrary binary. +pub fn resolve_provider_binary(provider_id: &str) -> Result { + // Reject IDs that could be path components or shell metacharacters. + let valid_id = provider_id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') + && !provider_id.is_empty() + && provider_id.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()); + if !valid_id { + return Err(format!( + "invalid provider ID '{provider_id}': must match [a-z0-9][a-z0-9_-]*" + )); + } + + let candidates = discover_provider_candidates(); + let found = candidates + .into_iter() + .find(|(id, _)| id == provider_id) + .map(|(_, path)| path); + + match found { + Some(path) => path + .canonicalize() + .map_err(|e| format!("provider binary not accessible: {e}")), + None => Err(format!( + "provider 'buzz-backend-{provider_id}' not found on PATH" + )), + } +} + +/// Check if a file is executable (Unix: mode bits; other platforms: always true). +fn is_executable(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + path.metadata() + .map(|m| m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + let _ = path; + true + } +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackendProviderInfo { + pub id: String, + pub binary_path: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_provider_binary_rejects_invalid_ids() { + // Path traversal + assert!(resolve_provider_binary("../evil").is_err()); + // Empty + assert!(resolve_provider_binary("").is_err()); + // Uppercase + assert!(resolve_provider_binary("MyProvider").is_err()); + // Spaces + assert!(resolve_provider_binary("my provider").is_err()); + // Shell metacharacters + assert!(resolve_provider_binary("foo;rm -rf /").is_err()); + // Valid format but not on PATH — should fail with "not found" + assert!(resolve_provider_binary("nonexistent-test-id-12345").is_err()); + } + + #[test] + fn resolve_provider_binary_accepts_valid_id_format() { + // Valid ID format should pass validation. If the binary happens to + // exist on PATH, Ok is returned; otherwise Err contains "not found" + // (not "invalid provider ID"). Either outcome proves validation passed. + match resolve_provider_binary("zzz-nonexistent-test-provider") { + Ok(_) => {} // unlikely but fine — binary exists + Err(e) => assert!( + e.contains("not found"), + "expected 'not found' error, got: {e}" + ), + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/provider_recovery.rs b/desktop/src-tauri/src/managed_agents/provider_recovery.rs new file mode 100644 index 0000000000..5f048d9cee --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/provider_recovery.rs @@ -0,0 +1,213 @@ +//! The provider failure wire type and the one recovery a provider may attach. +//! +//! Split out of `backend.rs` because this is the trust boundary between the +//! desktop and a `buzz-backend-*` subprocess it merely discovered on PATH: +//! everything here exists to decide what a provider is allowed to make the +//! desktop *do*, which is a different concern from invoking one or finding one. + +/// The one control-server URL a provider is allowed to send us to, and the one +/// prefix `ProviderRecovery::from_response` will accept. +const TAILSCALE_AUTH_PREFIX: &str = "https://login.tailscale.com/a/"; + +/// An actionable step the user can take to clear a provider failure. +/// +/// Deliberately not a free-form URL: this is the only enum, and each variant +/// names both the affordance and the destination, so adding a second one is a +/// deliberate act rather than a provider's choice at runtime. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum ProviderRecovery { + /// Open `url` in the user's browser. The URL is validated against + /// [`TAILSCALE_AUTH_PREFIX`] before it is ever constructed. + OpenUrl { url: String }, +} + +impl ProviderRecovery { + /// Read the optional `recovery` key of a provider's failure response. + /// + /// **Fails closed and validates here, not at the opener.** The SSH provider + /// builds this URL from a fixed prefix rather than parsing one out of + /// remote stderr, but that guarantee does not cross the process boundary: + /// the desktop treats a provider as a subprocess it merely discovered, not + /// a trusted peer — which is why it re-redacts secrets on the way in on the + /// very next line. Checking on entry rather than at `open_url` means an + /// unvalidated URL never exists in desktop memory at all, so no later + /// reader of the payload can become a second, unguarded way to open it. + /// + /// Anything unrecognized yields `None`: the failure still surfaces with its + /// message, minus a button. + pub(super) fn from_response(response: &serde_json::Value) -> Option { + // `pub(super)`: `backend::invoke_provider` is the one caller, and + // keeping it inside `managed_agents` is what stops a second, unguarded + // construction path appearing elsewhere in the crate. + let recovery = response.get("recovery")?; + if recovery.get("action").and_then(|v| v.as_str()) != Some("open_url") { + return None; + } + let url = recovery.get("url").and_then(|v| v.as_str())?; + let token = url.strip_prefix(TAILSCALE_AUTH_PREFIX)?; + // The prefix alone is not enough: `…/a/` followed by anything would + // still be a match, and userinfo/query/fragment bytes are exactly how a + // look-alike destination would be smuggled past a `starts_with` check. + let unreserved = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~'); + if token.is_empty() || token.len() > 128 || !token.chars().all(unreserved) { + return None; + } + Some(Self::OpenUrl { + url: url.to_string(), + }) + } +} + +/// A provider op failure, plus the optional recovery the UI needs to offer a +/// way out. Mirrors the provider's own `Failure` type across the process +/// boundary. +/// +/// A struct rather than an error enum for the same reason it is one there: +/// almost every failure in this path is an unremarkable `format!`, and an enum +/// would make each one name a variant. `From`/`From<&str>` keep those +/// compiling unchanged. There is deliberately **no** `From for +/// String` — that is the type-level guard against a caller flattening the +/// recovery away, which is exactly the bug this type exists to prevent. +/// +/// It serializes as `{message, recovery?}`, which is the shape the frontend's +/// `toTauriError` already turns into a `TauriInvokeError` carrying `payload`. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ProviderFailure { + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub recovery: Option, +} + +impl From for ProviderFailure { + fn from(message: String) -> Self { + Self { + message, + recovery: None, + } + } +} + +impl From<&str> for ProviderFailure { + fn from(message: &str) -> Self { + Self::from(message.to_string()) + } +} + +impl std::fmt::Display for ProviderFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A provider failure response carrying the recovery shape. + fn failure_with_recovery(url: &str) -> serde_json::Value { + serde_json::json!({ + "ok": false, + "error": "this host requires Tailscale SSH authentication in a browser", + "recovery": { "action": "open_url", "url": url }, + }) + } + + #[test] + fn a_tailscale_login_url_is_the_one_recovery_that_survives() { + let response = failure_with_recovery("https://login.tailscale.com/a/1a2b3c4d"); + assert_eq!( + ProviderRecovery::from_response(&response), + Some(ProviderRecovery::OpenUrl { + url: "https://login.tailscale.com/a/1a2b3c4d".to_string() + }) + ); + } + + #[test] + fn a_provider_cannot_send_the_desktop_anywhere_else() { + // The provider is a discovered subprocess, not a trusted peer: a + // compromised or merely buggy one must not be able to pick the + // destination of a browser the desktop opens. + for url in [ + "https://login.tailscale.com.evil.test/a/tok", + "https://evil.test/https://login.tailscale.com/a/tok", + "http://login.tailscale.com/a/tok", + "https://user@login.tailscale.com/a/tok", + "javascript:alert(1)", + "file:///etc/passwd", + // Prefix-correct but with a payload a bare `starts_with` would pass. + "https://login.tailscale.com/a/tok?next=https://evil.test", + "https://login.tailscale.com/a/tok#/../../evil", + "https://login.tailscale.com/a/tok/../../evil", + // Nothing after the marker is not a link, it is a 404. + "https://login.tailscale.com/a/", + ] { + assert_eq!( + ProviderRecovery::from_response(&failure_with_recovery(url)), + None, + "{url}" + ); + } + } + + #[test] + fn an_unknown_or_absent_recovery_is_simply_dropped() { + // Fails closed in every direction: the failure still surfaces with its + // message, minus a button. + assert_eq!( + ProviderRecovery::from_response(&serde_json::json!({ "ok": false, "error": "no" })), + None + ); + assert_eq!( + ProviderRecovery::from_response(&serde_json::json!({ + "ok": false, "error": "no", + "recovery": { "action": "run_command", "command": "rm -rf /" }, + })), + None + ); + assert_eq!( + ProviderRecovery::from_response(&serde_json::json!({ + "ok": false, "error": "no", "recovery": "https://login.tailscale.com/a/tok", + })), + None + ); + } + + #[test] + fn an_overlong_token_is_refused_rather_than_truncated() { + let at_cap = format!("https://login.tailscale.com/a/{}", "a".repeat(128)); + assert!(ProviderRecovery::from_response(&failure_with_recovery(&at_cap)).is_some()); + let over_cap = format!("https://login.tailscale.com/a/{}", "a".repeat(129)); + assert_eq!( + ProviderRecovery::from_response(&failure_with_recovery(&over_cap)), + None + ); + } + + #[test] + fn a_failure_serializes_as_the_shape_the_frontend_reads() { + // `toTauriError` turns this into a `TauriInvokeError` whose `payload` + // is the object, which is what `providerRecoveryOf` reads. + let failure = ProviderFailure { + message: "nope".to_string(), + recovery: Some(ProviderRecovery::OpenUrl { + url: "https://login.tailscale.com/a/tok".to_string(), + }), + }; + assert_eq!( + serde_json::to_value(&failure).unwrap(), + serde_json::json!({ + "message": "nope", + "recovery": { "action": "open_url", "url": "https://login.tailscale.com/a/tok" }, + }) + ); + // Without a recovery the key is absent, not null — an older frontend + // reading `.recovery` sees undefined either way, and the wire stays the + // same size for the overwhelmingly common failure. + assert_eq!( + serde_json::to_value(ProviderFailure::from("plain")).unwrap(), + serde_json::json!({ "message": "plain" }) + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index f3b4cb67fd..b6459ba5a3 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -65,6 +65,8 @@ use instance_reaper::{buffer_contains_identifier, is_desktop_binary}; // Exact-path harness sweep lives in runtime/sweep.rs (re-exported above). +mod setup_payload; + mod lifecycle; #[cfg(test)] use lifecycle::kill_stale_tracked_processes_with; @@ -619,7 +621,7 @@ pub fn spawn_agent_child( let spawned_setup_mode; { use crate::managed_agents::readiness::EffectiveAgentEnv; - use crate::managed_agents::{agent_readiness, AgentReadiness, Requirement}; + use crate::managed_agents::{agent_readiness, AgentReadiness}; // Construct EffectiveAgentEnv from the descriptor computed above — no second // resolver call; the descriptor's env is already the fully layered result. @@ -631,61 +633,7 @@ pub fn spawn_agent_child( // Compute the optional payload before touching the command. let setup_payload_json = if let AgentReadiness::NotReady { requirements } = agent_readiness(&effective) { - let reqs: Vec = requirements - .into_iter() - .map(|r| match r { - Requirement::NormalizedField { field } => serde_json::json!({ - "surface": "normalized_field", - "field": field, - }), - Requirement::EnvKey { key } => serde_json::json!({ - "surface": "env_key", - "key": key, - }), - Requirement::CliLogin { - probe_args, - setup_copy, - availability, - } => serde_json::json!({ - "surface": "cli_login", - "probe_args": probe_args, - "setup_copy": setup_copy, - "availability": availability, - }), - Requirement::CliConfigInvalid { - probe_args, - setup_copy, - diagnostic, - } => serde_json::json!({ - "surface": "cli_config_invalid", - "probe_args": probe_args, - "setup_copy": setup_copy, - "diagnostic": diagnostic, - }), - Requirement::GitBash => serde_json::json!({ - "surface": "git_bash", - }), - Requirement::MissingBinary { command } => serde_json::json!({ - "surface": "missing_binary", - "command": command, - }), - }) - .collect(); - let payload = serde_json::json!({ - "agent_name": record.name, - "agent_pubkey": record.pubkey, - "requirements": reqs, - }); - match serde_json::to_string(&payload) { - Ok(json) => Some(json), - Err(e) => { - eprintln!( - "buzz-desktop: failed to serialize setup payload for {}: {e}", - record.name - ); - None - } - } + setup_payload::build_setup_payload_json(&record.name, &record.pubkey, requirements) } else { None }; @@ -854,9 +802,14 @@ pub fn spawn_agent_child( // `descriptor.env` is the fully-layered result from `resolve_effective_harness_descriptor`: // baked floor → runtime metadata → definition env (harness author defaults) → // global → live persona → per-agent, with reserved-key and malformed-key filtering - // applied. Writing it last lets user-provided values win over every Buzz-set env - // written above — reserved keys were already stripped from descriptor.env so they - // cannot clobber BUZZ_PRIVATE_KEY, NOSTR_PRIVATE_KEY, etc. + // applied. The persona layer is read live, so persona credential edits reach the + // agent on the next spawn — same refresh semantics as prompt/model/provider above + // and the provider deploy path. + // + // Writing it last lets user-provided values win over every Buzz-set env written + // above — EXCEPT reserved keys (BUZZ_PRIVATE_KEY, NOSTR_PRIVATE_KEY, BUZZ_AUTH_TAG, + // BUZZ_API_TOKEN, BUZZ_ACP_PRIVATE_KEY, BUZZ_ACP_API_TOKEN), which were already + // stripped from `descriptor.env` so they cannot clobber Buzz's identity. for (key, value) in &descriptor.env { command.env(key, value); } diff --git a/desktop/src-tauri/src/managed_agents/runtime/setup_payload.rs b/desktop/src-tauri/src/managed_agents/runtime/setup_payload.rs new file mode 100644 index 0000000000..a13a8c1902 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/setup_payload.rs @@ -0,0 +1,135 @@ +//! Setup-listener payload construction. +//! +//! When the desktop determines an agent is not ready to run, it serializes the +//! missing requirements into `BUZZ_ACP_SETUP_PAYLOAD`. `buzz-acp` detects that +//! env var at startup and enters the minimal setup-listener mode instead of the +//! agent pool. +//! +//! The desktop is the sole readiness source; `buzz-acp` only transports the +//! payload. The JSON shape mirrors `setup_mode::SetupPayload` in `buzz-acp`: +//! +//! ```json +//! { "agent_name": "…", "agent_pubkey": "…", "requirements": [{ "surface": "…", … }] } +//! ``` +//! +//! Kept pure (no `AppHandle`, no `Command`) so the surface mapping is +//! unit-testable without spawning a process. + +use crate::managed_agents::Requirement; + +/// Serialize one missing requirement into its wire form, tagged with the UI +/// surface that owns it. +fn requirement_json(requirement: Requirement) -> serde_json::Value { + match requirement { + Requirement::NormalizedField { field } => serde_json::json!({ + "surface": "normalized_field", + "field": field, + }), + Requirement::EnvKey { key } => serde_json::json!({ + "surface": "env_key", + "key": key, + }), + Requirement::CliLogin { + probe_args, + setup_copy, + availability, + } => serde_json::json!({ + "surface": "cli_login", + "probe_args": probe_args, + "setup_copy": setup_copy, + "availability": availability, + }), + Requirement::CliConfigInvalid { + probe_args, + setup_copy, + diagnostic, + } => serde_json::json!({ + "surface": "cli_config_invalid", + "probe_args": probe_args, + "setup_copy": setup_copy, + "diagnostic": diagnostic, + }), + Requirement::GitBash => serde_json::json!({ + "surface": "git_bash", + }), + Requirement::MissingBinary { command } => serde_json::json!({ + "surface": "missing_binary", + "command": command, + }), + } +} + +/// Build the `BUZZ_ACP_SETUP_PAYLOAD` JSON for an agent that is not ready. +/// +/// Returns `None` when serialization fails — the caller then spawns normally +/// rather than in setup mode, which is the safe degradation: a failed payload +/// must not silently strand the agent in a listener with no requirements. +pub(super) fn build_setup_payload_json( + agent_name: &str, + agent_pubkey: &str, + requirements: Vec, +) -> Option { + let reqs: Vec = requirements.into_iter().map(requirement_json).collect(); + let payload = serde_json::json!({ + "agent_name": agent_name, + "agent_pubkey": agent_pubkey, + "requirements": reqs, + }); + match serde_json::to_string(&payload) { + Ok(json) => Some(json), + Err(e) => { + eprintln!("buzz-desktop: failed to serialize setup payload for {agent_name}: {e}"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_binary_serializes_its_surface_and_command() { + let json = requirement_json(Requirement::MissingBinary { + command: "my-acp-agent".to_string(), + }); + assert_eq!(json["surface"], "missing_binary"); + assert_eq!(json["command"], "my-acp-agent"); + } + + #[test] + fn git_bash_serializes_surface_only() { + let json = requirement_json(Requirement::GitBash); + assert_eq!(json["surface"], "git_bash"); + } + + #[test] + fn payload_carries_identity_and_every_requirement() { + let json = build_setup_payload_json( + "Ada", + "npub-abc", + vec![ + Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }, + Requirement::MissingBinary { + command: "my-acp-agent".to_string(), + }, + ], + ) + .expect("payload serializes"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json"); + assert_eq!(parsed["agent_name"], "Ada"); + assert_eq!(parsed["agent_pubkey"], "npub-abc"); + assert_eq!(parsed["requirements"].as_array().unwrap().len(), 2); + assert_eq!(parsed["requirements"][0]["surface"], "env_key"); + assert_eq!(parsed["requirements"][1]["surface"], "missing_binary"); + } + + #[test] + fn ready_agent_payload_has_no_requirements() { + let json = build_setup_payload_json("Ada", "npub-abc", vec![]).expect("payload serializes"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json"); + assert!(parsed["requirements"].as_array().unwrap().is_empty()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b1..7f2b81d6d1 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -44,6 +44,20 @@ struct StatusInputs<'a> { global: &'a super::GlobalAgentConfig, } +/// Build the runtime status for one record. +/// +/// This table is a **local-process view by construction**: every caller is +/// keyed off `state.managed_agent_processes`, `start_pair` refuses a non-local +/// backend outright, and `reconcile_managed_agent_runtimes` filters to +/// `BackendKind::Local`. A provider-backed record therefore never produces a +/// row here, and its liveness comes from relay presence instead. +/// +/// That matters because `local_setup` below is unfiltered: it asks whether +/// *this* machine could run the agent. For a remote record the answer is +/// meaningless, and the UI turns `local_setup == false` into the copy "Needs +/// setup on this device" — which would describe a machine the agent will never +/// run on. If you widen a caller to reach non-local records, gate `local_setup` +/// on `record.backend == BackendKind::Local` first. fn status_for_with( app: &AppHandle, record: &super::ManagedAgentRecord, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 3d8e0ed02b..464eb348f7 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -162,6 +162,11 @@ impl ManagedAgentRecord { /// [`AgentDefinition`] shape — the compatibility view the persona command /// surface serves until Phase 1B unifies the UI. Inverse of /// [`AgentDefinition::into_agent_record`] for the fields personas carry. + /// + /// LOSSY: `AgentDefinition` has no slot for `backend`, `team_id`, + /// `agent_command`, `idle_timeout_seconds`, `relay_mesh`, `respond_to` or + /// `parallelism`, so a round-trip resets each — safe only while no + /// definition carries a non-default value there. pub fn to_definition_view(&self) -> Option { let slug = self.slug.clone()?; Some(AgentDefinition { diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 96ed556068..cb2235d276 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -583,6 +583,52 @@ fn empty_prompt_folds_to_none() { assert_eq!(persona.into_agent_record().system_prompt, None); } +/// A provider record's definition view carries no harness — the frontend's +/// harness auto-seed guard depends on this projection staying lossy. +/// +/// `AgentDefinition` has no slot for `backend` or `agent_command`, and +/// `runtime` on a provider record names a harness chosen from the HOST's +/// catalog, which `apply_persona_snapshot` deliberately refuses to mirror. So +/// the view's `runtime` is whatever the record holds, and for a provider record +/// created through the remote path that is `None`. The definition dialog reads +/// that blank as "no preference" and used to fill it with THIS computer's +/// default; the guard in `createRuntimeSeedAction` exists because the blank is +/// deliberate, not an absence. +#[test] +fn provider_record_definition_view_carries_no_harness() { + let mut record = sample_persona().into_agent_record(); + record.backend = super::BackendKind::Provider { + id: "ssh".to_string(), + config: serde_json::json!({}), + }; + record.runtime = None; + record.agent_command = "/opt/homebrew/bin/goose".to_string(); + + let view = record + .to_definition_view() + .expect("slugged record must present a definition view"); + assert_eq!( + view.runtime, None, + "the host's harness must not reach the definition dialog as a local preference" + ); + assert_eq!( + serde_json::to_value(&view) + .unwrap() + .get("agent_command") + .map(ToString::to_string), + None, + "AgentDefinition has no agent_command slot; the host's command is dropped" + ); + assert_eq!( + serde_json::to_value(&view) + .unwrap() + .get("backend") + .map(ToString::to_string), + None, + "AgentDefinition has no backend slot; a round-trip would revert it to Local" + ); +} + // ── Mint-time behavioral defaults (B5 quad activation) ────────────────────── use super::resolve_mint_behavioral_defaults; diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 35ad4a63af..6151ad47d5 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -114,6 +114,227 @@ with a TypeScript lookup table or an id comparison in a component. published or removed. A queued update must stay visibly queued, and the catalog itself must render only relay-confirmed publications — never an optimistic local persona. +11. **A remote create's models come from the host, never from this computer.** + When "Where to run" targets a backend provider and a harness is picked from + the host's catalog, `WhereToRunSection` calls `probeProviderModels` + (`probe_provider_models`, guarded by `resolve_discovered_provider`) and + parks the result in `WhereToRunDraft.remoteModelProbe`. + `remoteModelDiscoveryView()` projects it into the exact shape + `usePersonaModelDiscovery` returns (`RemoteModelDiscoveryView` extends + `ModelDiscoveryView` so the compiler holds them together), and + `useRemoteAwareModelDiscovery` substitutes it for the local one — the two + are never merged (different machines; the union would offer models the + chosen harness cannot run) and local discovery is suppressed entirely while + the host owns the control. Both decisions live in pure helpers + (`resolveModelDiscovery`, `shouldSuppressLocalDiscovery`) precisely so they + are testable without hook infrastructure; keep them that way. Changing the + picked harness resets the model for the same reason changing the local + runtime does. Do not add a remote-specific rendering path in + `PersonaModelField`: keep the substitution at the discovery seam. +12. **Every host round-trip carries a request id.** `WhereToRunSection` opens + real SSH connections (`discoverProviderHarnesses`, `probeProviderModels`). + Both claim `hostRequestRef` at their start and re-check it after every + await; anything that moves the draft off the host they were made for + (provider switch, config edit, re-pick, re-check) bumps it. Without that + re-check on the CATALOG read, a config edit made mid-flight lets the old + host's catalog reinstall itself and then fires a credential-carrying model + probe at the NEW host under the OLD host's harness command. A new + host-touching call gets the same treatment — do not add one that only + guards its own continuation. +13. **"Where does this agent run?" is the create flow's first question, and + it scopes every field below it.** `createRunSection` renders above name, + persona, and harness in `AgentDefinitionDialog` because the harness comes + from the chosen machine's catalog and the models come from that harness — + asking last would mean answering the dependent questions against the wrong + computer and silently re-scoping them. Consequences that must move + together: the local `AgentHarnessField` is hidden for a remote create (its + "not installed, visit Settings" guidance describes the wrong machine), and + the defaults summary names the host's pick via `createRemoteHarnessLabel` + rather than the locally seeded `runtime`. The credential gate is asked of + the host's pin too (`createGateHarnessId`): the deploy keys the agent's env + off the REMOTE command, so the local id would demand `BUZZ_AGENT_*` for a + remote Goose, or nothing at all when this machine defaults to Claude. It is + NOT relaxed, though — a deploy writes that env to the host verbatim, so a + missing key is just as fatal there. The one layer that is suppressed is the + runtime *file* config: it reads this machine's `~/.config`, and letting a + local `goose/config.yaml` satisfy a remote requirement trades a loud + create-time block for a silent deploy-time failure. + Edit mode is untouched — `createRunSection` is create-only. +14. **A provider decorates a config property with `oneOf`; the desktop renders + it generically.** `providerConfigChoices` reads + `oneOf: [{ const, title }]` off any config-schema property and + `ProviderConfigFields` renders a select over it plus an "Other…" escape + hatch; no `oneOf`, and the field is the plain Input it always was. The + desktop knows nothing about tailnets — the SSH provider fills the + decoration from the local Tailscale peer list and supplies the display + strings. Keep it that way: a Tailscale-shaped branch in the renderer makes + the next provider's suggestions unrenderable. A value that is not in the + list (carried over, or a peer that has left the tailnet) stays in free text + rather than reading as "nothing selected" — that is + `usesProviderConfigFreeText`, and it is pure so it can be tested. +15. **Remote liveness is relay presence; `"deployed"` is a control-plane fact, + not a liveness one.** `build_managed_agent_summary` reports `"deployed"` + whenever `backend_agent_id` is set, and that id is written exactly once — + on a successful deploy (`commands/agents.rs`) — with no clearer anywhere, + because the provider protocol has no undeploy. So `isManagedAgentActive` + answering true for a remote record means "this desktop deployed it", and + it keeps meaning that after the remote process dies. Every surface this + stack touches therefore paints liveness through + `managedAgentPresenceStatus`, which keeps the control plane authoritative + for a local record (this machine's own process table, and relays need not + retain ephemeral kind:20001 presence) and defers to relay presence for a + provider-backed one — the same channel `deleteManagedAgentWithRules` + already trusts before warning about an orphaned deployment. Two upstream + surfaces, `MembersSidebarMemberCard` and `AgentStatusBadge`, still paint + from the stale flag; they are follow-up candidates, not a licence to add a + third. Do not add SSH polling, a second status channel, or a `local_setup` + read for a non-local record: `local_setup` asks whether *this* machine + could run the agent and its UI copy ("Needs setup on this device") names + the wrong machine. See the doc comment on `status_for_with` + (`runtime_commands.rs`). +16. **The pinned harness must be an `available` catalog entry.** + `selectedRemoteHarness` filters on `available`, so an id that a re-check + turned unavailable stops being the pin rather than deploying a command the + host says is not installed. Likewise the create-time args of a provider + record are pinned verbatim (`create_time_agent_args`): normalizing them + would compare a REMOTE command against LOCAL runtime identity, and a host + binary sharing a basename with a local runtime would have its explicit + args silently rewritten. +17. **An `exclusive` catalog entry may back at most one agent.** The provider + marks entries that name a persistent IDENTITY on the host (its own memory, + sessions, credentials) rather than an ephemeral runner — today only the + per-Hermes-profile entries. Deploying `claude` N times to one host is the + point; two agents on one profile are two puppeteers on one body. + `isExclusiveRemoteHarnessAdded` decides "already taken" generically: same + provider, same provider config, same command+args as an existing record's + `agentCommand`/`agentArgs` (the RESOLVED pin — `agentCommandOverride` is + null for a pin equal to what the definition inherits). The picker renders a + taken entry disabled with an "(added)" suffix — the existing annotated-and- + disabled option vocabulary, not a new badge — auto-pick skips it, and + `WhereToRunSection` clears a pick the agent list turns stale so a + background refresh cannot leave an armed submit behind. Nothing in the + desktop knows what Hermes or a profile is; do not teach it. Config equality + is exact after trimming/dropping-blanks/sorting, so a host reached by two + names under-matches (the guard does not fire) rather than falsely blocking + a create; the real fix is a host-identity answer from the provider. +18. **A location label names the PROVIDER, never the host.** `agentRunsOnLabel` + is the one owner of "where does this agent run" for every surface that + lists agents (`AgentIdentityCard`, `MembersSidebarMemberCard`, the user + profile panel's "Runs on" row). It answers `null` for a local agent — "on + this computer" is the assumption a reader already holds, so painting it + costs a metadata line to say nothing — and for a provider-backed one it + returns `backendProviderLabel(backend.id)`. It does NOT read + `backend.config`. That is the same refusal rule 17 makes: a blessed + `ssh_host` key means the desktop grows a host vocabulary per provider, and + a provider id is constrained to `^[a-z0-9][a-z0-9_-]*$` by + `provider_id_is_valid` while a host is not, so naming the provider is also + the only answer with a bounded shape. Ids stand in for probed names on + these surfaces on purpose (`backendProviderLabel`'s own rule): a card list + must not spawn one subprocess per provider to decorate a string. A new + agent-listing surface calls the same helper and extends an existing + metadata slot rather than adding a badge. +19. **Settings → Remote servers reports what is installed; the create flow owns + deployment.** `RemoteServersCard` is read-only by design and has no host + list. A provider is a binary on `PATH`, so "adding" one is an install, not + a form; and the host is a per-agent decision the create dialog pins onto + the agent record verbatim at create time. CRUD here would either edit saved + configs that deployed agents deliberately do not re-read — which reads as a + bug — or duplicate the create flow's ownership of the host. Three + consequences that must not drift: this gallery is the ONLY surface that + pays for an `info` probe per discovered binary + (`useBackendProviderProbesQuery`; the create dialog and the onboarding + notice render ids, per rule 18); `"ready"` means "this binary answers the + provider protocol", never "the server is reachable", because `info` opens + no connection; and every settled probe must land in the probe map, since an + absent entry is indistinguishable from one still in flight and a dropped + result is therefore a row that spins forever (`remoteServerProbes`). The + no-provider sentence is `NO_BACKEND_PROVIDER_HINT`, stated once and + rendered by all three surfaces — a user meets it in up to three places, and + three spellings of one fact read as three different facts. +20. **A provider record answers from itself, never from the local catalog.** + A provider-backed record's `agentCommand`/`agentArgs` name a binary on the + HOST, which this computer's runtime catalog has never seen — so every local + lookup either misses (`hermes …` → generic icon, raw command as a name) or + hits by pure name collision (`claude-agent-acp` happens to be a local + builtin's command, the only reason a Claude card ever looked right). + Neither is knowledge. `providerRecordHarness` (`lib/pinnedHarness.ts`) is + the single owner of "may this surface read the record instead of the local + catalog?", and it answers `null` for a local agent — whose catalog entry + genuinely describes it, and whose rendering must not change. Three + consequences: + - **Derivation is generic.** A command basename (both separators, since + the path is the host's), the base-id fallback `resolveHarnessLogo` + already uses for variant ids, and a `--profile ` flag — a + widespread CLI convention. Nothing here knows what Hermes or SSH is; do + not teach it. The profile is part of the identity: two profiles of one + harness are two agents with their own memory and credentials, and must + not read as one name (same fact rule 17 guards on). + - **Normalization has one owner.** `normalizePinnedCommand` trims the + command and drops blank args, matching `create_time_agent_args`, and + BOTH the displayed `pin.command` and `exclusiveRemoteHarness`'s equality + check go through it. A rule that decides "same agent?" and a rule that + decides what the user reads must be one rule. + - **Avatar precedence is by authorship** (`lib/agentAvatarUrl.ts`): what a + human chose, then what the agent published about itself, then the record's + create-time stamp, then the pin's bundled harness mark. That last step + exists because a local create stamps this computer's runtime avatar onto + the record and a remote one has nothing to stamp — the host's catalog + entry deliberately carries no avatar url, since rendering a host-supplied + image is a tracking-pixel and spoofing vector (`RuntimeIcon`'s bundled + maps are the only permitted route). Do not add a host-supplied image + path, and do not move the derivation to deploy time: the fleet already + exists, and records minted before this carry an empty avatar forever. + - **Editing routes by the same question.** A provider record is edited in + the INSTANCE dialog, never the definition one. `AgentDefinition` has no + slot for `backend` or `agent_command` (`to_definition_view` drops both by + design), so the definition dialog can only show a remote record's harness + as blank — and then fill that blank from this computer's catalog, which + arms a provider requirement and an API-key demand for a machine the agent + never runs on. Both doors ask `providerRecordHarness`: + `profileEditAgentTarget` for the profile panel's Edit action and + `agentManagementUpdateTarget` for the owner-reviewed `!model` draft. + `createRuntimeSeedAction`'s `editsProviderRecord` is the backstop; the + blank runtime is deliberate, not an absence to fill. Do NOT widen + `AgentDefinition` to carry these fields instead: `into_agent_record` + silently reverts non-default `backend`/`agent_command`, turning a display + bug into data loss. + + The pin is editable only where it was made — at create/deploy. + `personaManagedAgentUpdate` must NOT write a locally-discovered runtime's + command/args over a provider record (its `runtimeChanged` gate is + `backend.type === "local"`): the catalog entry it would write is a path on + THIS machine, so a working `hermes --profile marshall acp` would be + replaced by a binary that does not exist on the host, from a dialog that + never said it would touch the harness. +21. **A host failure the user can fix carries a typed recovery, and the URL is + validated on entry.** A provider may answer a failed op with + `recovery: {action: "open_url", url}` (see `docs/remote-agents.md`); today + the only case is a tailnet ACL demanding browser re-auth. It reaches the UI + as `ProviderFailure {message, recovery}` — there is deliberately no + `From for String`, which is the type-level guard against a + caller flattening the recovery away. The frontend reads it off + `TauriInvokeError.payload` with `providerRecoveryOf`; `hostFailureOf` is the + one place `WhereToRunSection` converts a rejection, so a new host call picks + the recovery up for free. Two rules for anything added here: + - **Validate where the value enters, not where it is used.** + `ProviderRecovery::from_response` checks the Tailscale prefix AND the + token charset before constructing the value, so an unvalidated URL never + exists in desktop memory and no later reader can become a second, + unguarded way to open it. A provider is a discovered subprocess, not a + trusted peer — the same footing that makes `invoke_provider` re-redact + secrets on the way in. Do not relax this to a bare `starts_with`, and do + not let a provider name the destination. + - **The message always stands alone.** The recovery only ever ADDS a button; + the copy names the problem without it, so an older provider (or a dropped + recovery) degrades to an ordinary failure rather than an empty one. + Nothing auto-retries: the desktop cannot observe a browser it does not + own, so "Check the host again" stays the retry. + - **Dropping it is allowed, silently doing so is not.** `start_managed_agent` + and `create_managed_agent`'s `spawn_error` render into a toast and a + reported field, neither of which has room for an action, so each converts + to the message at one named site. Do not turn that into a blanket `impl + From for String` — the explicitness is the point. Give + the surface an action before widening it. ## The tests that enforce this @@ -124,6 +345,89 @@ with a TypeScript lookup table or an id comparison in a component. `shouldRenderModelControl` (successful-empty omit vs failure keep). If this fails, you probably reintroduced a per-surface flag or conflated empty with failed discovery. +- `ui/whereToRunIntent.test.mjs` — the remote create's submit gate, the + available-only harness pin, `runTargetOptions` / `rememberProbedProviderName` + / `remoteHarnessSummaryLabel` (the first question, the label cache that keeps + its entries from renaming themselves as the selection moves, and the summary + that follows it), and `remoteModelDiscoveryView` + (idle/loading/failed/loaded/empty-catalog). Covers the PROJECTION of the + host's probe, not the substitution that consumes it. Also `remoteHarnessOptions` + / `autoPickRemoteHarness`: rule 17's disabled "(added)" row and the auto-pick + that must never arm a create the picker itself refuses. +- `lib/exclusiveRemoteHarness.test.mjs` — rule 17's matcher. Same host + same + pinned identity is taken; a different host, user, provider, profile or + command is not; a local agent never occupies a host identity; a + non-exclusive entry is never taken however many agents run it; and an absent + flag is exactly today's behavior. +- `lib/pinnedHarness.test.mjs` — rule 20's derivation, plus the label table's + agreement with the Rust catalogs in BOTH directions (it reads + `discovery.rs` as text, the same trick `presetLogos.test.mjs` uses; a TS-only + key must be listed in `NOT_IN_RUST_CATALOG` with the surface that carries the + command). The cases are the two fleet shapes that broke: a + `hermes --profile marshall acp` pin that rendered a generic icon and lost its + profile, and a `claude-agent-acp` pin that only looked right by collision. + Also that an unknown host binary shows itself rather than a local guess — if + a remote card starts naming a harness the host does not run, this is the test + that should have caught it. +- `../profile/ui/profileEditAgentTarget.test.mjs` and + `agentManagementUpdateTarget.test.mjs` — rule 20's editing route, once per + door. Both pin the same pair: a provider-backed record selects the instance + editor even though it has a personaId (every provider create does), and a + local persona-backed one still selects the definition editor. If a remote + agent's Edit starts demanding an API key again, these are the tests that + should have caught it. `tests/e2e/edit-agent-provider-routing.spec.ts` covers + the same route end to end. +- `ui/createRuntimeGate.test.mjs` — the harness auto-seed, including the + edit-mode `editsProviderRecord` guard. Its create-mode sibling asks + `runsRemotely`, which is false in edit mode, so a remote record's blank + runtime was being filled with the local default. +- `lib/agentAvatarUrl.test.mjs` — rule 20's precedence chain. A human's choice + beats the agent's own published avatar beats the record's stamp beats the + harness mark; a LOCAL record never reaches the harness step, so its rendering + is unchanged. +- `features/profile/ui/profileRuntimeLabel.test.mjs` — that the profile + surfaces name a record from its pin and a foreign surface's free-form command + (a relay agent's declared `agentType`) through the SAME owner. A second label + table here is what let `codex-acp` be "Codex" in one place and a harness + learned in Rust be a raw command in the other. +- `lib/agentLocationLabel.test.mjs` — rule 18. A local, undefined or null + backend is unlabelled; a provider-backed one is named by its id; and a + config carrying an `ssh_host` still yields the provider's name, so the host + cannot leak into a card by accident. +- `lib/backendProviderLabel.test.mjs` — the id-vs-probed-name fallback and the + sorted label list. A blank probed name reads as no name (the id is the better + label), and sorting is what stops a `PATH`-order change from reshuffling a + hint line under the user. +- `features/onboarding/ui/remoteRunNotice.test.mjs` — the setup step's three + states. Pending stays pending over a cached list, because rendering the + install hint and then contradicting it a frame later is the failure this + projection exists to prevent. +- `features/settings/ui/remoteServerGalleryLogic.test.mjs` — rule 19. + `remoteServerProbes` lands every settled query somewhere (a response-less + success is a failed row, not a permanent spinner), and `remoteServerEntries` + covers ready/probing/unavailable, blank-vs-absent metadata, and the + ready-first sort. The card itself is a thin render over these two; if a row + spins forever or the gallery reshuffles, this is the test that should have + caught it. +- `shared/api/tauri.test.mjs` — `fromRawRemoteHarness`: the wire boundary for + `exclusive`. An asserted flag is carried; absent stays absent (the desktop + must not claim something the provider never said). +- `ui/providerConfigFields.test.mjs` — `providerConfigChoices` (a malformed + `oneOf` entry costs one row, not the list), `usesProviderConfigFreeText` + (an unlisted value stays editable), and `providerConfigSelection` (picking + "Other…" keeps the value; returning to a suggestion drops the override, so a + round trip leaves no text box stuck open). Rule 13 lives or dies here. +- `lib/managedAgentControlActions.test.mjs` — `managedAgentPresenceStatus`. + Rule 14: a deployed remote agent with nothing on the relay reads offline, a + running local one stays online through a silent relay. If a stopped remote + agent's card goes green again, this is the test that should have caught it. +- `ui/useRemoteAwareModelDiscovery.test.mjs` — `resolveModelDiscovery` and + `shouldSuppressLocalDiscovery`. If the Model control starts offering this + computer's models to a remote harness, or runs local discovery IPC + underneath a live remote catalog, these are the tests that should have + caught it. The staleness guard in `WhereToRunSection` itself is NOT covered + (no hook/DOM test infrastructure in this workspace — `pnpm test` is bare + `node --test`); it is held by rule 12 and review, so read it carefully. - `ui/usePersonaModelDiscovery.test.mjs` — `synthesizeEmptyDiscoveryStatus`, `isCacheableDiscoveryResponse`, `deriveModelDiscoveryPending`, `isSuccessfulEmptyDiscovery`. If the "reopen to retry" copy becomes inert @@ -134,6 +438,10 @@ with a TypeScript lookup table or an id comparison in a component. - Rust: `runtime_metadata_env_vars` tests pin spawn-time key application. - Rust: persona sharing/retention tests pin relay+owner scoping, durable enqueue errors, relay rejection/unavailability, and accepted publication. +- Rust: `discovery/tests/create_time_args.rs` — the create-time args authority. + Every case asserts the local AND provider backend over the same input, + because a remote binary sharing a basename with a local runtime is + normalized without complaint otherwise. ## Keep this file true diff --git a/desktop/src/features/agents/backendProviderHooks.ts b/desktop/src/features/agents/backendProviderHooks.ts new file mode 100644 index 0000000000..08c4af64b9 --- /dev/null +++ b/desktop/src/features/agents/backendProviderHooks.ts @@ -0,0 +1,64 @@ +import { useQueries, useQuery } from "@tanstack/react-query"; + +import { + discoverBackendProviders, + probeBackendProvider, +} from "@/shared/api/tauri"; +import type { BackendProviderCandidate } from "@/shared/api/types"; + +/** + * Queries about backend providers — the binaries that let an agent run + * somewhere other than this computer. + * + * Their own module rather than another block in `hooks.ts`: providers are the + * one axis of the agent surface that is about WHERE an agent lives, and the + * three surfaces that ask (onboarding, Settings → Remote servers, the create + * dialog) share nothing else in that file. + */ + +export const backendProvidersQueryKey = ["backend-providers"] as const; +/** Keyed by binary path, not id: the path is what is actually spawned. */ +export const backendProviderProbeQueryKey = ["backend-provider-probe"] as const; + +/** + * Which providers are installed. + * + * A `PATH` walk, not a spawn: cheap enough for any surface to ask on mount. + */ +export function useBackendProvidersQuery(options?: { enabled?: boolean }) { + return useQuery({ + enabled: options?.enabled ?? true, + queryKey: backendProvidersQueryKey, + queryFn: discoverBackendProviders, + staleTime: 30_000, + }); +} + +/** + * `info` for each discovered provider, so a surface can name and version them. + * + * Deliberately NOT what `runTargetOptions` does: the create dialog renders its + * provider list before the user has asked anything, so probing there would + * spawn every discovered binary to decorate a dropdown. This hook exists for + * the one surface whose entire subject IS the providers (Settings → Agents → + * Remote servers), where the round-trip is the thing the user asked for. + * + * `info` is the only op that opens no connection (see docs/remote-agents.md): + * it is a local spawn under a 10s desktop budget, not an SSH handshake, so N + * of them cost N short-lived child processes and never block on a host. + * + * `retry: false` per entry — one broken provider must not blank the gallery; + * its own row reports the failure. + */ +export function useBackendProviderProbesQuery( + providers: readonly BackendProviderCandidate[], +) { + return useQueries({ + queries: providers.map((provider) => ({ + queryKey: [...backendProviderProbeQueryKey, provider.binaryPath], + queryFn: () => probeBackendProvider(provider.binaryPath), + staleTime: 60_000, + retry: false, + })), + }); +} diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 122c872e54..5be2d7f041 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -23,7 +23,6 @@ import { deleteManagedAgent, deleteCustomHarness, discoverAcpRuntimes, - discoverBackendProviders, discoverGitBashPrerequisite, discoverManagedAgentPrereqs, getAgentConfigSurface, @@ -85,6 +84,12 @@ import type { ProvisionChannelManagedAgentResult, } from "@/features/agents/channelAgents"; export { findReusableAgent } from "@/features/agents/agentReuse"; +export { + backendProviderProbeQueryKey, + backendProvidersQueryKey, + useBackendProviderProbesQuery, + useBackendProvidersQuery, +} from "@/features/agents/backendProviderHooks"; export { teamsQueryKey, useCreateTeamMutation, @@ -110,7 +115,6 @@ export const personasQueryKey = ["personas"] as const; export const acpRuntimesQueryKey = ["acp-runtimes"] as const; export const acpAuthMethodsQueryKey = ["acp-auth-methods"] as const; export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const; -export const backendProvidersQueryKey = ["backend-providers"] as const; export const gitBashPrerequisiteQueryKey = ["git-bash-prerequisite"] as const; type InvalidateAgentQueriesOptions = { @@ -274,15 +278,6 @@ export function useGitBashPrerequisiteQuery() { }); } -export function useBackendProvidersQuery(options?: { enabled?: boolean }) { - return useQuery({ - enabled: options?.enabled ?? true, - queryKey: backendProvidersQueryKey, - queryFn: discoverBackendProviders, - staleTime: 30_000, - }); -} - export function usePersonasQuery(options?: { enabled?: boolean }) { return useQuery({ enabled: options?.enabled ?? true, @@ -506,7 +501,15 @@ export function useDeletePersonaMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (id: string) => deletePersona(id), + mutationFn: ({ + id, + forceRemoteDelete, + }: { + id: string; + /** Acknowledges that provider-backed cascade instances leave their + * remote units running. The backend refuses the cascade without it. */ + forceRemoteDelete?: boolean; + }) => deletePersona(id, forceRemoteDelete), onSettled: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: personasQueryKey }), diff --git a/desktop/src/features/agents/lib/agentAvatarUrl.test.mjs b/desktop/src/features/agents/lib/agentAvatarUrl.test.mjs new file mode 100644 index 0000000000..68a63f281d --- /dev/null +++ b/desktop/src/features/agents/lib/agentAvatarUrl.test.mjs @@ -0,0 +1,130 @@ +/** + * Agent card avatar precedence. + * + * The harness-mark fallback exists for provider-backed records only, and only + * as a last resort: a local record already carries a stamped avatar and must + * render exactly as it did, and anything a human or the agent itself chose + * outranks a logo. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { PRESET_LOGOS } from "../../onboarding/ui/RuntimeIcon.tsx"; +import { resolveAgentAvatarUrl } from "./agentAvatarUrl.ts"; + +const CHOSEN = "https://cdn.example/marshall.png"; +const PUBLISHED = "https://cdn.example/published.png"; + +const remoteAgent = { + backend: { type: "provider", id: "ssh", config: { ssh_host: "vps" } }, + agentCommand: "hermes", + agentArgs: ["--profile", "marshall", "acp"], +}; + +/** A local record, whose avatar was stamped from this computer's catalog. */ +const localAgent = { + backend: { type: "local" }, + agentCommand: "claude-agent-acp", + agentArgs: [], +}; + +test("a remote record falls back to its pinned harness mark", () => { + // The bug: the host's catalog entry deliberately carries no avatar url, so + // this record rendered blank initials while its local twin showed a logo. + assert.equal( + resolveAgentAvatarUrl({ agent: remoteAgent }), + PRESET_LOGOS.hermes, + ); +}); + +test("a chosen avatar outranks the harness mark", () => { + assert.equal( + resolveAgentAvatarUrl({ + agent: remoteAgent, + personaAvatarUrl: CHOSEN, + profileAvatarUrl: PUBLISHED, + }), + CHOSEN, + ); + assert.equal( + resolveAgentAvatarUrl({ agent: remoteAgent, profileAvatarUrl: PUBLISHED }), + PUBLISHED, + ); +}); + +test("blank candidates do not outrank anything", () => { + assert.equal( + resolveAgentAvatarUrl({ + agent: remoteAgent, + personaAvatarUrl: " ", + profileAvatarUrl: "", + }), + PRESET_LOGOS.hermes, + ); + assert.equal( + resolveAgentAvatarUrl({ + agent: remoteAgent, + personaAvatarUrl: null, + profileAvatarUrl: ` ${PUBLISHED} `, + }), + PUBLISHED, + "a chosen url is trimmed, as the card's own helper always did", + ); +}); + +test("a local record gains no fallback", () => { + // Byte-identical to the previous behavior: whatever the record and profile + // carry, and initials when they carry nothing. + assert.equal(resolveAgentAvatarUrl({ agent: localAgent }), null); + assert.equal( + resolveAgentAvatarUrl({ agent: localAgent, profileAvatarUrl: PUBLISHED }), + PUBLISHED, + ); +}); + +test("the record's own stamp is used before the harness mark", () => { + // The timeline has no definition to read, so it passes the record's stamped + // avatar. A local record was stamped at create time and must keep showing + // exactly that; a remote one was stamped with nothing, which is the gap. + const STAMPED = "app-avatar://claude"; + assert.equal( + resolveAgentAvatarUrl({ agent: localAgent, recordAvatarUrl: STAMPED }), + STAMPED, + ); + assert.equal( + resolveAgentAvatarUrl({ agent: remoteAgent, recordAvatarUrl: null }), + PRESET_LOGOS.hermes, + ); + assert.equal( + resolveAgentAvatarUrl({ + agent: remoteAgent, + profileAvatarUrl: PUBLISHED, + recordAvatarUrl: STAMPED, + }), + PUBLISHED, + "what the agent published about itself still outranks its stamp", + ); +}); + +test("a never-spawned persona shows only what its definition carries", () => { + assert.equal( + resolveAgentAvatarUrl({ agent: undefined, personaAvatarUrl: CHOSEN }), + CHOSEN, + ); + assert.equal(resolveAgentAvatarUrl({ agent: undefined }), null); +}); + +test("a remote record on an unknown harness earns no mark", () => { + assert.equal( + resolveAgentAvatarUrl({ + agent: { + backend: { type: "provider", id: "ssh", config: {} }, + agentCommand: "acme-brain", + agentArgs: [], + }, + }), + null, + "initials are honest; a borrowed logo would not be", + ); +}); diff --git a/desktop/src/features/agents/lib/agentAvatarUrl.ts b/desktop/src/features/agents/lib/agentAvatarUrl.ts new file mode 100644 index 0000000000..d5bbc3d126 --- /dev/null +++ b/desktop/src/features/agents/lib/agentAvatarUrl.ts @@ -0,0 +1,66 @@ +import type { ManagedAgent } from "@/shared/api/types"; +import { providerRecordHarness } from "./pinnedHarness"; + +/** + * The image that stands for an agent on the surfaces that list agents. + * + * Precedence is by authorship: what a human chose for this agent, then what + * the agent published about itself, then the mark of the harness it runs. + * Nothing here invents an identity — the last step only names the harness the + * record is already pinned to. + * + * The harness step exists because it is what a local agent has always had. + * Creating one stamps the local runtime's avatar onto the record, which is the + * only reason a Claude card shows the Claude mark. A provider-backed record + * gets no such stamp: its harness lives on the host, and the catalog entry the + * host advertises deliberately carries no avatar url (rendering a + * host-supplied image would be a tracking-pixel and spoofing vector — the + * bundled logo maps are the only permitted route, see `RuntimeIcon`). So a + * remote agent fell through to blank initials while its local twin showed a + * logo, for reasons that were never about the agent. + * + * Deriving it at render time rather than at deploy time is deliberate: the + * fleet already exists, and records minted before this fix carry an empty + * avatar forever. + */ +export function resolveAgentAvatarUrl({ + agent, + personaAvatarUrl, + profileAvatarUrl, + recordAvatarUrl, +}: { + /** The record, or `undefined` for a persona that has never been spawned. */ + agent: + | Pick + | undefined; + /** The avatar on the agent's definition, chosen by a human. */ + personaAvatarUrl?: string | null; + /** The avatar the running agent published about itself. */ + profileAvatarUrl?: string | null; + /** + * The avatar stamped onto the record when it was created — this computer's + * runtime avatar for a local agent, and empty for a remote one, which is the + * gap the harness mark below fills. + */ + recordAvatarUrl?: string | null; +}): string | null { + const chosen = firstNonEmpty( + personaAvatarUrl, + profileAvatarUrl, + recordAvatarUrl, + ); + if (chosen) return chosen; + // `null` for a local record, whose avatar was already stamped from this + // computer's catalog at create time — its rendering must not change. + return agent ? (providerRecordHarness(agent)?.logoUrl ?? null) : null; +} + +function firstNonEmpty( + ...candidates: Array +): string | null { + for (const candidate of candidates) { + const trimmed = candidate?.trim(); + if (trimmed) return trimmed; + } + return null; +} diff --git a/desktop/src/features/agents/lib/agentLocationLabel.test.mjs b/desktop/src/features/agents/lib/agentLocationLabel.test.mjs new file mode 100644 index 0000000000..45201f6f26 --- /dev/null +++ b/desktop/src/features/agents/lib/agentLocationLabel.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { agentLocationLabel, agentRunsOnLabel } from "./agentLocationLabel.ts"; + +describe("agentRunsOnLabel", () => { + it("names the provider for a provider-backed agent", () => { + assert.equal( + agentRunsOnLabel({ type: "provider", id: "ssh", config: {} }), + "ssh", + ); + }); + + it("is silent for a local agent — 'on this computer' is the assumption", () => { + assert.equal(agentRunsOnLabel({ type: "local" }), null); + }); + + it("is silent when there is no backend yet", () => { + assert.equal(agentRunsOnLabel(undefined), null); + assert.equal(agentRunsOnLabel(null), null); + }); + + it("never reads a host out of the provider config", () => { + // `exclusiveRemoteHarness` refuses to bless an `ssh_host` key; this label + // must not be the surface that reintroduces it. + const label = agentRunsOnLabel({ + type: "provider", + id: "ssh", + config: { ssh_host: "vps.example.com", ssh_user: "buzz" }, + }); + assert.equal(label, "ssh"); + assert.ok(!label.includes("vps.example.com")); + }); + + it("falls back to a floor rather than rendering an empty label", () => { + assert.equal( + agentRunsOnLabel({ type: "provider", id: " ", config: {} }), + "Unknown provider", + ); + }); +}); + +describe("agentLocationLabel", () => { + it("reads as a compact metadata line", () => { + assert.equal( + agentLocationLabel({ type: "provider", id: "ssh", config: {} }), + "on ssh", + ); + }); + + it("adds nothing for a local agent", () => { + assert.equal(agentLocationLabel({ type: "local" }), null); + assert.equal(agentLocationLabel(undefined), null); + }); +}); diff --git a/desktop/src/features/agents/lib/agentLocationLabel.ts b/desktop/src/features/agents/lib/agentLocationLabel.ts new file mode 100644 index 0000000000..d680899d51 --- /dev/null +++ b/desktop/src/features/agents/lib/agentLocationLabel.ts @@ -0,0 +1,39 @@ +import type { ManagedAgentBackend } from "@/shared/api/types"; +import { backendProviderLabel } from "./backendProviderLabel"; + +/** + * Where an agent runs, for the surfaces that list agents. + * + * `null` for a local agent, on purpose and everywhere. "On this computer" is + * the default a user already assumes, so painting it on every card would cost a + * line of metadata to say nothing; the label exists to mark the records that + * are NOT here. + * + * The label names the PROVIDER, never the host. `backend.config` holds the + * host, but reading a blessed `ssh_host` key out of it is the thing + * `exclusiveRemoteHarness` explicitly refuses to do: the desktop has no + * vocabulary for "the host field" and would have to grow one per provider. + * Naming the provider is the honest answer this record can give without the + * desktop learning any provider's config shape. + */ +export function agentRunsOnLabel( + backend: ManagedAgentBackend | undefined | null, +): string | null { + if (backend?.type !== "provider") return null; + return backendProviderLabel(backend.id); +} + +/** + * `"on ssh"` — the same fact as a compact metadata line for an agent card. + * + * Unprobed ids are the label, matching `runTargetOptions`, which renders a + * discovered provider as its id until a probe has paid for a friendlier name. + * A card list is exactly the place that must not spawn one subprocess per + * provider to decorate a string. + */ +export function agentLocationLabel( + backend: ManagedAgentBackend | undefined | null, +): string | null { + const name = agentRunsOnLabel(backend); + return name ? `on ${name}` : null; +} diff --git a/desktop/src/features/agents/lib/backendProviderLabel.test.mjs b/desktop/src/features/agents/lib/backendProviderLabel.test.mjs new file mode 100644 index 0000000000..ee41edc4d1 --- /dev/null +++ b/desktop/src/features/agents/lib/backendProviderLabel.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + backendProviderLabel, + backendProviderLabels, +} from "./backendProviderLabel.ts"; + +describe("backendProviderLabel", () => { + it("prefers a probed name over the binary-derived id", () => { + assert.equal(backendProviderLabel("ssh", "SSH"), "SSH"); + }); + + it("falls back to the id when no probe has been paid for", () => { + assert.equal(backendProviderLabel("ssh"), "ssh"); + assert.equal(backendProviderLabel("ssh", null), "ssh"); + }); + + it("treats a blank probed name as no name — the id is a better label", () => { + assert.equal(backendProviderLabel("ssh", " "), "ssh"); + }); + + it("trims a probed name", () => { + assert.equal(backendProviderLabel("ssh", " SSH "), "SSH"); + }); + + it("never renders an empty label", () => { + assert.equal(backendProviderLabel(" "), "Unknown provider"); + }); +}); + +describe("backendProviderLabels", () => { + it("returns an empty list when nothing is discovered", () => { + assert.deepEqual(backendProviderLabels([]), []); + }); + + it("sorts so a PATH-order change cannot reshuffle the line", () => { + const providers = [ + { id: "ssh", binaryPath: "/usr/bin/buzz-backend-ssh" }, + { id: "blox", binaryPath: "/usr/bin/buzz-backend-blox" }, + ]; + assert.deepEqual(backendProviderLabels(providers), ["blox", "ssh"]); + assert.deepEqual(backendProviderLabels([...providers].reverse()), [ + "blox", + "ssh", + ]); + }); + + it("sorts by the human order, not the code-point one", () => { + const providers = [ + { id: "SSH", binaryPath: "/usr/bin/buzz-backend-ssh" }, + { id: "blox", binaryPath: "/usr/bin/buzz-backend-blox" }, + ]; + // `localeCompare`, so "blox" precedes "SSH" rather than every capitalized + // label being stranded ahead of every lowercase one. + assert.deepEqual(backendProviderLabels(providers), ["blox", "SSH"]); + }); +}); diff --git a/desktop/src/features/agents/lib/backendProviderLabel.ts b/desktop/src/features/agents/lib/backendProviderLabel.ts new file mode 100644 index 0000000000..03ebbb84c4 --- /dev/null +++ b/desktop/src/features/agents/lib/backendProviderLabel.ts @@ -0,0 +1,53 @@ +import type { BackendProviderCandidate } from "@/shared/api/types"; + +/** + * What every surface says when no provider is installed. + * + * One constant rather than the same sentence typed into the create dialog, the + * onboarding notice and the Settings gallery: a user meets this line in up to + * three places, and three spellings of one fact read as three different facts. + */ +export const NO_BACKEND_PROVIDER_HINT = + "Install a backend provider to run agents on another machine."; + +/** + * How the app names a backend provider — the `buzz-backend-*` binary that runs + * an agent on a machine other than this computer. + * + * A provider's own `info.name` ("SSH") is friendlier than its binary-derived id + * ("ssh"), but `info` is a subprocess round-trip: surfaces that render a + * provider before the user has asked anything of it (the onboarding notice, an + * agent card) have not paid for one, so the id stands in. That is the same + * trade `runTargetOptions` makes in the create dialog, and this is the single + * owner of the rule so the two cannot drift into different naming schemes for + * the same machine. + */ +export function backendProviderLabel( + id: string, + probedName?: string | null, +): string { + const name = probedName?.trim(); + if (name) return name; + const trimmedId = id.trim(); + return trimmedId || "Unknown provider"; +} + +/** + * Labels for a discovered provider list, in a stable order. + * + * Sorted rather than left in discovery order because discovery walks `PATH`, + * so the same two providers can come back in a different order between reads + * and a hint line would reshuffle itself under the user. + * + * Ids only: its one caller is the onboarding notice, which renders before the + * user has asked anything of a provider and so has not paid for a probe. A + * surface that HAS probed names its rows through `backendProviderLabel` + * directly with the name it already holds (`remoteServerEntries`). + */ +export function backendProviderLabels( + providers: readonly BackendProviderCandidate[], +): string[] { + return providers + .map((provider) => backendProviderLabel(provider.id)) + .sort((left, right) => left.localeCompare(right)); +} diff --git a/desktop/src/features/agents/lib/exclusiveRemoteHarness.test.mjs b/desktop/src/features/agents/lib/exclusiveRemoteHarness.test.mjs new file mode 100644 index 0000000000..7ee43d6b23 --- /dev/null +++ b/desktop/src/features/agents/lib/exclusiveRemoteHarness.test.mjs @@ -0,0 +1,216 @@ +/** + * The exclusive-identity guard: "is this catalog entry already driven by an + * agent I have?" + * + * An exclusive entry names a persistent identity on the host (its own memory, + * sessions, credentials), so a second agent pinned to it would be a second + * puppeteer on one body. A non-exclusive entry is an ephemeral runner and may + * be deployed as many times as the user likes — these tests pin both halves. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + addedExclusiveHarnessIds, + isExclusiveRemoteHarnessAdded, +} from "./exclusiveRemoteHarness.ts"; +import { resolvePinnedHarness } from "./pinnedHarness.ts"; + +const HOST = { + providerId: "ssh", + config: { ssh_host: "vps", ssh_user: "bee" }, +}; + +function harness(overrides = {}) { + return { + id: "hermes-default", + label: "Hermes (default)", + command: "hermes", + args: ["--profile", "default", "acp"], + env: {}, + available: true, + binaryPath: "/usr/local/bin/hermes", + version: null, + exclusive: true, + ...overrides, + }; +} + +function agent(overrides = {}) { + return { + pubkey: "a".repeat(64), + name: "Hermes", + agentCommand: "hermes", + agentCommandOverride: null, + agentArgs: ["--profile", "default", "acp"], + backend: { type: "provider", id: "ssh", config: { ...HOST.config } }, + ...overrides, + }; +} + +test("same host and same pinned identity reads as added", () => { + assert.equal(isExclusiveRemoteHarnessAdded(harness(), HOST, [agent()]), true); +}); + +test("no agents at all means nothing is added", () => { + assert.equal(isExclusiveRemoteHarnessAdded(harness(), HOST, []), false); +}); + +test("the same identity on a different host is a different identity", () => { + const elsewhere = agent({ + backend: { + type: "provider", + id: "ssh", + config: { ssh_host: "other-vps", ssh_user: "bee" }, + }, + }); + assert.equal( + isExclusiveRemoteHarnessAdded(harness(), HOST, [elsewhere]), + false, + ); +}); + +test("the same host reached as a different user is a different identity store", () => { + // A different $HOME is different memory/sessions, so it must not block. + const otherUser = agent({ + backend: { + type: "provider", + id: "ssh", + config: { ssh_host: "vps", ssh_user: "root" }, + }, + }); + assert.equal( + isExclusiveRemoteHarnessAdded(harness(), HOST, [otherUser]), + false, + ); +}); + +test("another provider on the same-looking config does not match", () => { + const otherProvider = agent({ + backend: { type: "provider", id: "fly", config: { ...HOST.config } }, + }); + assert.equal( + isExclusiveRemoteHarnessAdded(harness(), HOST, [otherProvider]), + false, + ); +}); + +test("a different profile on the same host does not match", () => { + const matt = agent({ agentArgs: ["--profile", "matt", "acp"] }); + assert.equal(isExclusiveRemoteHarnessAdded(harness(), HOST, [matt]), false); +}); + +test("a different command with identical args does not match", () => { + const other = agent({ agentCommand: "hermes-next" }); + assert.equal(isExclusiveRemoteHarnessAdded(harness(), HOST, [other]), false); +}); + +test("a local agent can never occupy a host identity", () => { + const local = agent({ backend: { type: "local" } }); + assert.equal(isExclusiveRemoteHarnessAdded(harness(), HOST, [local]), false); +}); + +test("a non-exclusive entry is never added, however many agents run it", () => { + const claude = harness({ + id: "claude", + label: "Claude Code", + command: "claude-code-acp", + args: [], + exclusive: undefined, + }); + const running = agent({ agentCommand: "claude-code-acp", agentArgs: [] }); + assert.equal( + isExclusiveRemoteHarnessAdded(claude, HOST, [running, running]), + false, + "ephemeral runners are meant to be deployed N times", + ); +}); + +test("an absent exclusive flag behaves exactly like today (no guard)", () => { + const legacy = harness(); + delete legacy.exclusive; + assert.equal(isExclusiveRemoteHarnessAdded(legacy, HOST, [agent()]), false); +}); + +test("blank and untrimmed args still match the record they minted", () => { + // `create_time_agent_args` trims and drops blanks on the way into the + // record, so the catalog entry must be compared the same way. + const messy = harness({ args: [" --profile ", "default", "", "acp"] }); + assert.equal(isExclusiveRemoteHarnessAdded(messy, HOST, [agent()]), true); +}); + +test("what counts as the same pin here is what the user is shown", () => { + // Two spellings of one rule is the drift this shares an owner to avoid: a + // pin taken by the guard must read as the same string on screen, or an + // agent is "already added" against a card naming something else. + const messy = { command: " hermes ", args: [" --profile ", "default", ""] }; + const clean = { command: "hermes", args: ["--profile", "default"] }; + assert.equal( + resolvePinnedHarness(messy.command, messy.args).command, + resolvePinnedHarness(clean.command, clean.args).command, + ); + assert.equal( + isExclusiveRemoteHarnessAdded(harness({ args: messy.args }), HOST, [ + agent({ agentArgs: clean.args }), + ]), + true, + ); +}); + +test("a blank optional config field equals an omitted one", () => { + // The create dialog seeds schema defaults, so both spellings really occur. + const seeded = { + providerId: "ssh", + config: { ssh_host: "vps", ssh_user: "bee", ssh_port: "" }, + }; + assert.equal( + isExclusiveRemoteHarnessAdded(harness(), seeded, [agent()]), + true, + ); +}); + +test("config key order does not change the answer", () => { + const reordered = agent({ + backend: { + type: "provider", + id: "ssh", + config: { ssh_user: "bee", ssh_host: "vps" }, + }, + }); + assert.equal( + isExclusiveRemoteHarnessAdded(harness(), HOST, [reordered]), + true, + ); +}); + +test("addedExclusiveHarnessIds collects only the taken exclusive entries", () => { + const catalog = [ + // Taken. + harness(), + // Exclusive but free: nobody drives this profile. + harness({ id: "hermes-matt", args: ["--profile", "matt", "acp"] }), + // Running, but an ephemeral runner: never "taken". + harness({ + id: "claude", + command: "claude-code-acp", + args: [], + exclusive: undefined, + }), + harness({ + id: "codex", + command: "codex", + args: ["acp"], + exclusive: false, + }), + ]; + const ids = addedExclusiveHarnessIds(catalog, HOST, [ + agent(), + agent({ agentCommand: "claude-code-acp", agentArgs: [] }), + agent({ agentCommand: "codex", agentArgs: ["acp"] }), + ]); + assert.deepEqual([...ids], ["hermes-default"]); +}); + +test("addedExclusiveHarnessIds is empty for an empty catalog", () => { + assert.equal(addedExclusiveHarnessIds([], HOST, [agent()]).size, 0); +}); diff --git a/desktop/src/features/agents/lib/exclusiveRemoteHarness.ts b/desktop/src/features/agents/lib/exclusiveRemoteHarness.ts new file mode 100644 index 0000000000..a646d60206 --- /dev/null +++ b/desktop/src/features/agents/lib/exclusiveRemoteHarness.ts @@ -0,0 +1,146 @@ +import type { ManagedAgent, RemoteHarness } from "@/shared/api/types"; +import { normalizePinnedCommand } from "./pinnedHarness"; + +/** + * "Is this exclusive catalog entry already taken?" + * + * A remote harness entry marked `exclusive` names a persistent identity on the + * host — its own memory, sessions and credentials — rather than an ephemeral + * runner. Two agents pinned to the same one are two puppeteers driving one + * body, so the create flow refuses the second. Everything here is provider- and + * harness-agnostic: the entry says it is exclusive, and this decides whether + * the machine + identity pair is already spoken for. + * + * Nothing here knows what Hermes, a profile, or SSH is. Do not teach it. + */ + +/** The host an exclusive entry would be deployed to. */ +export type RemoteDeployTarget = { + /** Backend provider id — the `runOn` of the create draft. */ + providerId: string; + /** The provider config the create would deploy with, already coerced. */ + config: Record; +}; + +/** + * Canonical form of a provider config, for equality only. + * + * Two records name the same machine when their provider AND their whole config + * agree. Comparing the config wholesale rather than reading one blessed key + * (`ssh_host`) is what keeps this generic — the desktop has no vocabulary for + * "the host field" and would have to grow one per provider — and it is also + * more correct for the provider that exists: the same address reached as a + * different `ssh_user` is a different HOME, and therefore a different identity + * store, so a host-only comparison would wrongly refuse it. + * + * Normalization is deliberately minimal: keys are sorted, string values are + * trimmed, and empty strings are dropped so a blank optional field equals an + * absent one (the create dialog seeds schema defaults into every draft, so both + * spellings really do occur). Both sides of the comparison pass through + * `coerceConfigValues` before they get here, so a numeric port is a number on + * both. + * + * KNOWN LIMITATION, accepted for v1: this is exact matching, not host + * resolution. `10.0.0.4`, `vps`, and `vps.tail1234.ts.net` are the same machine + * to everyone except this function, and an explicit `ssh_port: 22` does not + * equal an omitted one. Both mis-read as a DIFFERENT host, so the guard simply + * does not fire and the user gets today's behavior — an unguarded second agent. + * The failure mode is under-matching, never falsely blocking a legitimate + * create. Resolving aliases needs a host-identity op in the provider protocol + * (the provider is the only party that can answer "who did I just connect + * to?"); that is the real fix, not a normalization table here. + */ +function canonicalConfig(config: Record): string { + const entries = Object.entries(config) + .map(([key, value]) => { + const normalized = typeof value === "string" ? value.trim() : value; + return [key, normalized] as const; + }) + .filter( + ([, value]) => value !== "" && value !== null && value !== undefined, + ) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)); + return JSON.stringify(entries); +} + +/** + * The pinned identity of a harness: the command and args that will actually + * run on the host. + * + * Canonicalized by `normalizePinnedCommand`, the same owner the pin's displayed + * `command` string goes through — a rule that decides "same agent?" and a rule + * that decides what the user reads must be one rule, or a pin differing only by + * whitespace is taken here and a different string on screen. + * + * Beyond that normalization the comparison is literal, which is right for a + * provider record: its create-time args are pinned verbatim precisely because + * normalizing a REMOTE command against LOCAL runtime identity is a category + * error (see `create_time_agent_args`). The summary layer still runs them + * through `normalize_agent_args`, so an exclusive entry whose command happens + * to share a basename with a known local runtime could read back with + * substituted args and under-match here. No such entry exists today (`hermes` + * is not a known local runtime), and the failure mode is the documented one: + * the guard does not fire. + */ +function pinnedIdentity(command: string, args: readonly string[]): string { + const pin = normalizePinnedCommand(command, args); + return JSON.stringify([pin.command, pin.args]); +} + +/** + * Does an existing agent already occupy this exclusive entry? + * + * `false` for a non-exclusive entry, always and without inspecting anything — + * an ephemeral runner is meant to be deployed N times, and that is the whole + * point of the distinction. + * + * A record matches when all three hold: + * 1. it is provider-backed by the SAME provider (a local agent runs on this + * computer and cannot occupy anything on the host), + * 2. it deploys with the same provider config (see `canonicalConfig`), and + * 3. its effective harness pin is byte-identical to the entry's command+args. + * + * `agentCommand` is the resolved/effective command, which is what a provider + * record's deploy actually ships; `agentCommandOverride` can be null even for a + * real remote pin when the pin happens to equal what the definition inherits, + * so it is the wrong field to read here. + */ +export function isExclusiveRemoteHarnessAdded( + harness: RemoteHarness, + target: RemoteDeployTarget, + agents: readonly ManagedAgent[], +): boolean { + if (!harness.exclusive) return false; + + const targetHost = canonicalConfig(target.config); + const targetIdentity = pinnedIdentity(harness.command, harness.args); + + return agents.some((agent) => { + if (agent.backend.type !== "provider") return false; + if (agent.backend.id !== target.providerId) return false; + if (canonicalConfig(agent.backend.config) !== targetHost) return false; + return ( + pinnedIdentity(agent.agentCommand, agent.agentArgs) === targetIdentity + ); + }); +} + +/** + * The ids of every catalog entry that is exclusive AND already added. + * + * The picker disables exactly these, and auto-pick skips exactly these, so both + * read from one computation rather than each re-deriving the rule. + */ +export function addedExclusiveHarnessIds( + harnesses: readonly RemoteHarness[], + target: RemoteDeployTarget, + agents: readonly ManagedAgent[], +): ReadonlySet { + return new Set( + harnesses + .filter((harness) => + isExclusiveRemoteHarnessAdded(harness, target, agents), + ) + .map((harness) => harness.id), + ); +} diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs index d79c7abcf8..34f2e21271 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { availableRuntimesForStart, buildInstanceInputForDefinition, + resolveCreateRuntimeForDefinition, resolveStartRuntimeForDefinition, } from "./instanceInputForDefinition.ts"; @@ -197,12 +198,29 @@ test("Buzz shared compute definition carries native provider and auto model", as assert.equal(input.startOnAppLaunch, true); }); -test("provider intent forces startOnAppLaunch off and omits local commands", async () => { +const remoteHarness = { + id: "goose", + command: "/opt/host/bin/goose", + args: ["acp"], + env: { GOOSE_MODE: "auto" }, +}; + +function providerIntent(overrides = {}) { + return { + type: "provider", + id: "blox", + config: { region: "us" }, + harness: remoteHarness, + ...overrides, + }; +} + +test("provider intent forces startOnAppLaunch off and spawns no local ACP", async () => { const input = await buildInstanceInputForDefinition( persona(), gooseRuntime, undefined, - { type: "provider", id: "blox", config: { region: "us" } }, + providerIntent(), ); assert.deepEqual(input.backend, { type: "provider", @@ -211,25 +229,111 @@ test("provider intent forces startOnAppLaunch off and omits local commands", asy }); assert.equal(input.startOnAppLaunch, false, "remote agents never auto-start"); assert.equal(input.spawnAfterCreate, true); - assert.equal(input.harnessOverride, false); - // Provider agents spawn no local ACP — the legacy provider branch omitted - // all local commands and model/provider, and so does the intent path. - for (const key of [ - "acpCommand", - "agentCommand", - "agentArgs", - "mcpCommand", - "model", - "provider", - "envVars", - "relayMesh", - ]) { + // Provider agents spawn no local ACP or MCP sidecar. + for (const key of ["acpCommand", "mcpCommand", "relayMesh"]) { assert.equal(key in input, false, `provider intent must omit ${key}`); } assert.equal(input.personaId, "p-1", "definition link is kept"); assert.equal(input.systemPrompt, "prompt"); }); +// ── Correction C1: the remote harness pin ─────────────────────────────────── +// +// The create-time agentCommand is the ONLY channel by which the harness choice +// reaches the host: `create_time_agent_command_override` drops a divergent +// command when harnessOverride is false, `effective_agent_command` then +// resolves against the LOCAL registry and falls through to +// `default_agent_command()`, and the deploy ships that. So a provider create +// without a true override + remote command silently provisions "buzz-agent". + +test("C1: provider intent pins the REMOTE harness command, not the local runtime", async () => { + const input = await buildInstanceInputForDefinition( + persona(), + gooseRuntime, + undefined, + providerIntent(), + ); + assert.equal(input.agentCommand, "/opt/host/bin/goose"); + assert.notEqual( + input.agentCommand, + gooseRuntime.command, + "the local runtime's command describes the wrong machine", + ); + assert.equal( + input.harnessOverride, + true, + "without the override flag the backend discards the pin and falls back to buzz-agent", + ); +}); + +test("C1: remote args and env are pinned (nothing re-resolves them)", async () => { + const input = await buildInstanceInputForDefinition( + persona(), + null, + undefined, + providerIntent(), + ); + assert.deepEqual(input.agentArgs, ["acp"]); + assert.deepEqual(input.envVars, { GOOSE_MODE: "auto" }); +}); + +test("C1: a provider create with no remote harness is refused", async () => { + await assert.rejects( + () => + buildInstanceInputForDefinition( + persona(), + gooseRuntime, + undefined, + providerIntent({ harness: undefined }), + ), + /remote host/i, + "must refuse rather than silently deploy the locally-resolved default", + ); +}); + +test("C1: a blank remote harness command is refused", async () => { + await assert.rejects( + () => + buildInstanceInputForDefinition(persona(), gooseRuntime, undefined, { + ...providerIntent(), + harness: { ...remoteHarness, command: " " }, + }), + /remote host/i, + ); +}); + +test("provider create needs no locally-installed runtime", () => { + const { runtime } = resolveCreateRuntimeForDefinition([], "goose", true); + assert.equal( + runtime, + null, + "the harness lives on the host; the local catalog is a different machine", + ); +}); + +test("local create still refuses an unavailable runtime", () => { + assert.throws( + () => resolveCreateRuntimeForDefinition([], "goose", false), + /Choose an available runtime/, + ); +}); + +test("create runtime resolves locally when it happens to exist", () => { + const { runtime } = resolveCreateRuntimeForDefinition( + [gooseRuntime, claudeRuntime], + "claude", + true, + ); + assert.equal(runtime?.id, "claude", "used for the avatar fallback"); +}); + +test("a local create with no runtime is refused at the mapping too", async () => { + await assert.rejects( + () => buildInstanceInputForDefinition(persona(), null), + /Choose an available runtime/, + ); +}); + test("row 1: refuses when the configured runtime is not available", () => { assert.throws( () => diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.ts b/desktop/src/features/agents/lib/instanceInputForDefinition.ts index 5919309224..51e7605a00 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.ts +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.ts @@ -66,6 +66,39 @@ export function resolveStartRuntimeForDefinition( return { runtime, warnings }; } +/** + * Resolve the runtime for a definition CREATE, honouring where the agent will + * actually run. + * + * For a local create the definition's runtime must be installed on this + * machine, so an unavailable pick is refused exactly as before. + * + * For a provider create the harness lives on the REMOTE host and is chosen + * from that host's catalog (`discover_provider_harnesses`); the local catalog + * describes a different machine entirely. Requiring a locally-installed + * runtime here would make every remote-only harness unsubmittable — the user + * could not create a Goose agent on their server without first installing + * Goose on their laptop — so the local availability check does not apply. + * The runtime is still returned when it happens to resolve locally, because + * callers use it for the avatar fallback; `null` simply means "no local + * counterpart", which is normal and not an error. + * + * Returns `{ runtime }` on success and throws with an actionable message on + * refusal, matching the start-path contract above. + */ +export function resolveCreateRuntimeForDefinition( + runtimes: readonly AcpRuntime[], + requestedRuntimeId: string | null | undefined, + isProviderCreate: boolean, +): { runtime: AcpRuntime | null } { + const runtime = + runtimes.find((candidate) => candidate.id === requestedRuntimeId) ?? null; + if (!runtime && !isProviderCreate) { + throw new Error("Choose an available runtime for this agent."); + } + return { runtime }; +} + /** * Where the started instance should run when the user picked something other * than plain local in the definition-create flow (B5). Absent intent = @@ -86,6 +119,27 @@ export type BackendIntent = { type: "provider"; id: string; config: Record; + /** + * The harness chosen from the REMOTE host's catalog + * (`discover_provider_harnesses`). Correction C1: this is the only channel + * by which the harness choice reaches the host, so it must name a binary on + * the remote machine — never one resolved from the local runtime catalog. + */ + harness?: RemoteHarnessSelection; +}; + +/** + * One entry of a provider's `discover_harnesses` catalog, narrowed to the + * fields the create mapping pins. `command` and `args` describe the remote + * host; `env` carries the runtime's `default_env`, which locally would be + * applied from the catalog at spawn time — a remote agent never spawns + * locally, so it has to ride along in the record or it is simply lost. + */ +export type RemoteHarnessSelection = { + id: string; + command: string; + args?: readonly string[]; + env?: Record; }; /** @@ -106,10 +160,17 @@ export type BackendIntent = { * later definition edits made before the first spawn. (Mesh preset env is * the deliberate exception: it is instance-override state, not * definition env.) + * + * The provider branch deliberately diverges from the local one on the harness + * fields (command/args/env pinned rather than re-resolved). See the comments + * inside it: nothing re-resolves them for a record that never spawns locally. */ export async function buildInstanceInputForDefinition( persona: AgentPersona, - runtime: AcpRuntime, + // Nullable only for a provider create: the harness then comes from the + // remote catalog via `backendIntent.harness`, and no local runtime need + // exist. The local branch below still requires one. + runtime: AcpRuntime | null, upload?: UploadMediaBytes, backendIntent?: BackendIntent, ): Promise { @@ -126,9 +187,39 @@ export async function buildInstanceInputForDefinition( }; if (backendIntent?.type === "provider") { + const harness = backendIntent.harness; + if (!harness?.command.trim()) { + // Correction C1, refused at the source. Without a remote harness pin the + // record falls through `create_time_agent_command_override` (which + // returns None for a persona-backed create with harnessOverride: false) + // to `effective_agent_command`, which resolves against the LOCAL runtime + // registry and ultimately `default_agent_command()` — so the deploy + // payload would carry "buzz-agent" and the host would silently provision + // a harness the user never chose. The provider refuses a blank pin, but + // a wrong non-blank one it cannot detect, so the guard has to live here. + throw new Error( + "Select a harness installed on the remote host before creating this agent.", + ); + } return { ...base, - harnessOverride: false, + // The pin is only preserved when harnessOverride is true: with false, + // the backend treats a divergent command as a missing-runtime fallback + // and discards it. A remote harness choice is always a deliberate pin — + // the definition's runtime names a LOCAL runtime id that says nothing + // about the remote machine, so it is never authoritative here. + harnessOverride: true, + agentCommand: harness.command, + // Unlike the local branch (which sends [] so spawn re-resolves args from + // the definition on every start), a provider-backed record never spawns + // locally. These args and env come from the remote catalog and have no + // second chance to be resolved, so they are pinned at create time. + agentArgs: [...(harness.args ?? [])], + ...(harness.env && Object.keys(harness.env).length > 0 + ? { envVars: { ...harness.env } } + : {}), + model: persona.model ?? undefined, + provider: persona.provider ?? undefined, spawnAfterCreate: true, startOnAppLaunch: false, backend: { @@ -139,6 +230,14 @@ export async function buildInstanceInputForDefinition( }; } + if (!runtime) { + // Unreachable through the UI (`resolveCreateRuntimeForDefinition` only + // returns null for a provider create, which returned above), but a local + // create with no runtime has no harness to spawn — refuse rather than + // build an input whose agentCommand is undefined. + throw new Error("Choose an available runtime for this agent."); + } + return { ...base, acpCommand: "buzz-acp", diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index e6926b36d2..cbad5164ef 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs +++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + deleteManagedAgentWithRules, + managedAgentPresenceStatus, startManagedAgentWithRules, respawnManagedAgentWithRules, } from "./managedAgentControlActions.ts"; @@ -81,6 +83,45 @@ test("ordinary local agents still start normally", async () => { assert.equal(calledWith, "deadbeef".repeat(8)); }); +// --- managedAgentPresenceStatus: control-plane status is not liveness -------- + +const REMOTE_BACKEND = { type: "provider", id: "ssh", config: {} }; + +test("a local agent's own process table beats a silent relay", () => { + // Relays need not retain ephemeral kind:20001 presence, and this desktop is + // supervising the process — a blip must not grey out a running local agent. + assert.equal(managedAgentPresenceStatus(agent(), undefined), "online"); + assert.equal(managedAgentPresenceStatus(agent(), {}), "online"); +}); + +test("a deployed remote agent with no relay presence is not claimed online", () => { + // `backend_agent_id` is written once at deploy and never cleared (there is + // no undeploy), so "deployed" alone would light the dot green forever. + const remote = agent({ + status: "deployed", + backend: REMOTE_BACKEND, + backendAgentId: "remote-1", + }); + assert.equal(managedAgentPresenceStatus(remote, {}), "offline"); + assert.equal(managedAgentPresenceStatus(remote, null), "offline"); +}); + +test("a deployed remote agent reports whatever the relay says, verbatim", () => { + const remote = agent({ + pubkey: "AB".repeat(32), + status: "deployed", + backend: REMOTE_BACKEND, + backendAgentId: "remote-1", + }); + // Lookup keys are normalized pubkeys; a mixed-case record must still hit. + const lookup = { ["ab".repeat(32)]: "away" }; + assert.equal(managedAgentPresenceStatus(remote, lookup), "away"); + assert.equal( + managedAgentPresenceStatus(remote, { ["ab".repeat(32)]: "online" }), + "online", + ); +}); + // --- respawnManagedAgentWithRules: stop→clear→start boundary tests ----------- test("test_respawn_stop_success_start_failure_onStopped_still_fires", async () => { @@ -166,3 +207,180 @@ test("test_respawn_onStopped_fires_before_start_resolves", async () => { "onStopped must fire after stop resolves and before start is called", ); }); + +// ── Provider delete: orphan disclosure ──────────────────────────────────── +// +// There is no undeploy in the provider protocol, so deleting a provider-backed +// record removes what this app knows about the deployment and nothing else. +// Every path that deletes one must therefore say so — either through this +// function's own confirm, or through a caller that already did. +// +// These cases cover the branches that reach no channel (offline, and not in a +// channel at all), which are exactly the ones a `skipRemoteDeleteConfirm: true` +// caller used to slip through with neither a warning nor a `!shutdown`. + +function deployedProviderAgent(overrides = {}) { + return agent({ + name: "Remote Scout", + backend: { type: "provider", id: "blox", config: null }, + backendAgentId: "buzz-agent-scout.service", + status: "deployed", + ...overrides, + }); +} + +function withConfirm(answer, body) { + const previous = globalThis.window; + const prompts = []; + globalThis.window = { + confirm: (message) => { + prompts.push(message); + return answer; + }, + }; + try { + return body(prompts); + } finally { + globalThis.window = previous; + } +} + +test("offline provider delete warns and names the unit that keeps running", async () => { + let deleted = null; + const prompts = await withConfirm(true, async (captured) => { + await deleteManagedAgentWithRules({ + agent: deployedProviderAgent(), + channels: [], + // Offline: presence lookup has no entry, so no !shutdown is deliverable. + presenceLookup: {}, + relayAgents: [], + preferredChannelId: "channel-1", + deleteManagedAgent: async (input) => { + deleted = input; + }, + }); + return captured; + }); + + assert.equal(prompts.length, 1, "offline provider delete must warn"); + assert.match( + prompts[0], + /does not stop the remote deployment/, + "warning must not imply a teardown that cannot happen", + ); + assert.match( + prompts[0], + /buzz-agent-scout\.service/, + "warning must name the unit so the owner can stop it by hand", + ); + assert.deepEqual(deleted, { + pubkey: "deadbeef".repeat(8), + forceRemoteDelete: true, + }); +}); + +test("provider delete with no channel warns and names the unit", async () => { + let deleted = null; + const prompts = await withConfirm(true, async (captured) => { + await deleteManagedAgentWithRules({ + agent: deployedProviderAgent(), + channels: [], + presenceLookup: { ["deadbeef".repeat(8)]: "online" }, + relayAgents: [], + // No preferredChannelId and no relay agent match — unaddressable. + deleteManagedAgent: async (input) => { + deleted = input; + }, + }); + return captured; + }); + + assert.equal(prompts.length, 1, "unreachable provider delete must warn"); + assert.match(prompts[0], /not in any channel/); + assert.match(prompts[0], /buzz-agent-scout\.service/); + assert.ok(deleted, "confirmed delete proceeds"); +}); + +test("declining the warning cancels the delete", async () => { + let deleted = null; + await withConfirm(false, async () => { + const result = await deleteManagedAgentWithRules({ + agent: deployedProviderAgent(), + channels: [], + presenceLookup: {}, + relayAgents: [], + deleteManagedAgent: async (input) => { + deleted = input; + }, + }); + assert.deepEqual(result, { cancelled: true }); + }); + + assert.equal(deleted, null, "a declined warning must delete nothing"); +}); + +test("a caller that already disclosed the orphan is not prompted twice", async () => { + let deleted = null; + const prompts = await withConfirm(true, async (captured) => { + await deleteManagedAgentWithRules({ + agent: deployedProviderAgent(), + channels: [], + presenceLookup: {}, + relayAgents: [], + remoteOrphanDisclosedByCaller: true, + deleteManagedAgent: async (input) => { + deleted = input; + }, + }); + return captured; + }); + + assert.deepEqual(prompts, [], "caller's own dialog is the disclosure"); + assert.ok(deleted, "delete still proceeds"); +}); + +test("provider record that never deployed needs no orphan warning", async () => { + let deleted = null; + const prompts = await withConfirm(true, async (captured) => { + await deleteManagedAgentWithRules({ + // Provider backend, but no backend_agent_id: nothing was ever deployed, + // so there is no remote unit to leave running. + agent: deployedProviderAgent({ backendAgentId: null }), + channels: [], + presenceLookup: {}, + relayAgents: [], + deleteManagedAgent: async (input) => { + deleted = input; + }, + }); + return captured; + }); + + assert.deepEqual(prompts, [], "nothing deployed, nothing to disclose"); + assert.deepEqual(deleted, { + pubkey: "deadbeef".repeat(8), + forceRemoteDelete: undefined, + }); +}); + +test("local agent delete is untouched by the provider disclosure", async () => { + let deleted = null; + const prompts = await withConfirm(true, async (captured) => { + await deleteManagedAgentWithRules({ + agent: agent(), + channels: [], + presenceLookup: {}, + relayAgents: [], + deleteManagedAgent: async (input) => { + deleted = input; + }, + }); + return captured; + }); + + assert.deepEqual(prompts, [], "local deletes never orphan anything"); + assert.deepEqual(deleted, { + pubkey: "deadbeef".repeat(8), + forceRemoteDelete: undefined, + }); +}); diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index dbaaaba803..09e70923d4 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -3,6 +3,7 @@ import type { Channel, ManagedAgent, PresenceLookup, + PresenceStatus, RelayAgent, } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -35,6 +36,33 @@ export function isManagedAgentActive(agent: Pick) { return agent.status === "running" || agent.status === "deployed"; } +/** + * The presence a surface should show for an agent the control plane calls + * active — `isManagedAgentActive` says what this desktop DID to the agent, not + * whether it is alive right now, and for a remote deployment those two facts + * diverge permanently. + * + * A local record's `"running"` is this machine's own process table, so the + * control plane is the liveness answer for it and stays authoritative: the + * relay may not retain ephemeral kind:20001 presence at all, and a relay blip + * must not make a process we are supervising read as dead. + * + * A provider-backed record's `"deployed"` says only that the deploy + * succeeded. `backend_agent_id` is written once, on that success, and nothing + * clears it — the provider protocol has no undeploy — so a remote agent that + * died hours ago is still `"deployed"` forever. The relay is the only channel + * that knows, which is exactly what `deleteManagedAgentWithRules` already + * trusts before it warns about orphaning a deployment. Silence there means + * "not known to be alive", so it reads offline rather than claiming otherwise. + */ +export function managedAgentPresenceStatus( + agent: Pick, + presenceLookup: PresenceLookup | null | undefined, +): PresenceStatus { + if (agent.backend.type === "local") return "online"; + return presenceLookup?.[normalizePubkey(agent.pubkey)] ?? "offline"; +} + export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { if (agent.backend.type === "provider") { return isManagedAgentActive(agent) ? "Shutdown" : "Deploy"; @@ -141,6 +169,23 @@ export async function stopManagedAgentWithRules({ return {}; } +/** + * Delete a managed-agent record, sending `!shutdown` first when the agent is a + * live provider deployment that can still be reached through a channel. + * + * `remoteOrphanDisclosedByCaller` asserts that the caller has ALREADY shown the + * user a confirmation naming this agent's remote unit and stating that the + * delete does not stop it. It suppresses this function's fallback + * `window.confirm` only — it never suppresses `!shutdown`, and it is not a + * "delete quietly" switch. + * + * It exists because two surfaces would otherwise stack two dialogs on one + * click: the profile panel's `AgentDeleteConfirmDialog` already carries the + * full disclosure. The name is deliberately a claim about the caller rather + * than a `skip…` verb, because the previous spelling let that caller's copy + * drift into promising a remote teardown that the provider protocol has never + * implemented, with nothing tying the two together. + */ export async function deleteManagedAgentWithRules({ agent, channels, @@ -148,11 +193,11 @@ export async function deleteManagedAgentWithRules({ preferredChannelId, presenceLookup, relayAgents, - skipRemoteDeleteConfirm = false, + remoteOrphanDisclosedByCaller = false, }: { agent: ManagedAgent; deleteManagedAgent: DeleteManagedAgent; - skipRemoteDeleteConfirm?: boolean; + remoteOrphanDisclosedByCaller?: boolean; } & ManagedAgentActionContext): Promise { if (agent.backend.type === "provider" && agent.backendAgentId) { const presence = presenceLookup?.[normalizePubkey(agent.pubkey)]; @@ -161,43 +206,35 @@ export async function deleteManagedAgentWithRules({ preferredChannelId, relayAgents, }); + const reachable = + channelId !== null && (presence === "online" || presence === "away"); + + // Best-effort graceful stop. Only possible while the agent is both online + // and addressable in a channel — the shutdown travels as a mention, so + // there is no out-of-band path to it. Sent regardless of whether the + // caller already confirmed: it is a courtesy to the agent, not a prompt. + if (reachable && channelId) { + await sendChannelMessage(channelId, "!shutdown", undefined, undefined, [ + agent.pubkey, + ]); + } - if (channelId) { - if (presence === "online" || presence === "away") { - await sendChannelMessage(channelId, "!shutdown", undefined, undefined, [ - agent.pubkey, - ]); - - if (!skipRemoteDeleteConfirm) { - const confirmed = window.confirm( - "Shutdown command sent, but the agent may still be running. " + - "Deleting now removes the local record — the remote deployment " + - "will be orphaned if shutdown hasn't completed. Continue?", - ); - if (!confirmed) { - return { cancelled: true }; - } - } - } else { - if (!skipRemoteDeleteConfirm) { - const confirmed = window.confirm( - "This agent is offline but the remote deployment may still exist. " + - "Deleting removes the local management record. Continue?", - ); - if (!confirmed) { - return { cancelled: true }; - } - } - } - } else { - if (!skipRemoteDeleteConfirm) { - const confirmed = window.confirm( - "This agent is deployed but not in any channel. " + - "Deleting will orphan the remote deployment (it will keep running). Continue?", - ); - if (!confirmed) { - return { cancelled: true }; - } + if (!remoteOrphanDisclosedByCaller) { + // Every branch says the same thing, because the same thing is true in + // every branch: this app cannot tear down a remote deployment. Only the + // reason it cannot promise a clean stop differs. + const situation = reachable + ? "A shutdown command was sent, but it may not have completed." + : channelId + ? "This agent is not responding, so no shutdown could be delivered." + : "This agent is not in any channel, so no shutdown could be delivered."; + const confirmed = window.confirm( + `${situation} Deleting removes the local record but does not stop the ` + + `remote deployment — ${agent.backendAgentId} keeps running until ` + + `stopped on the host. Continue?`, + ); + if (!confirmed) { + return { cancelled: true }; } } } diff --git a/desktop/src/features/agents/lib/personaCascade.test.mjs b/desktop/src/features/agents/lib/personaCascade.test.mjs new file mode 100644 index 0000000000..7d7604f69d --- /dev/null +++ b/desktop/src/features/agents/lib/personaCascade.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { collectPersonaRemoteCascadeInstances } from "./personaCascade.ts"; + +// Which cascade instances the persona-delete confirmation has to name. The +// answer drives both the warning copy and the backend opt-in, so an instance +// wrongly included prompts about nothing, and one wrongly excluded deletes a +// live deployment with no disclosure at all. + +const PERSONA_ID = "custom:scout"; + +function instance(overrides = {}) { + return { + pubkey: "aa".repeat(32), + name: "Instance", + personaId: PERSONA_ID, + backend: { type: "local" }, + backendAgentId: null, + ...overrides, + }; +} + +const deployed = instance({ + name: "Remote Scout", + backend: { type: "provider", id: "blox", config: null }, + backendAgentId: "buzz-agent-scout.service", +}); + +test("collects provider instances that have a live deployment", () => { + assert.deepEqual( + collectPersonaRemoteCascadeInstances([deployed], PERSONA_ID), + [{ name: "Remote Scout", unitId: "buzz-agent-scout.service" }], + ); +}); + +test("excludes local instances", () => { + assert.deepEqual( + collectPersonaRemoteCascadeInstances([instance()], PERSONA_ID), + [], + ); +}); + +test("excludes provider instances that never deployed", () => { + // No backend_agent_id means no deploy ever completed, so there is no remote + // unit to leave running and nothing to warn about. + const undeployed = instance({ + backend: { type: "provider", id: "blox", config: null }, + }); + assert.deepEqual( + collectPersonaRemoteCascadeInstances([undeployed], PERSONA_ID), + [], + ); +}); + +test("excludes deployed instances belonging to a different persona", () => { + // These are not in the cascade, so naming them would warn about agents this + // delete does not touch. + const other = { ...deployed, personaId: "custom:other" }; + assert.deepEqual( + collectPersonaRemoteCascadeInstances([other], PERSONA_ID), + [], + ); +}); + +test("excludes instances with no persona", () => { + const orphan = { ...deployed, personaId: null }; + assert.deepEqual( + collectPersonaRemoteCascadeInstances([orphan], PERSONA_ID), + [], + ); +}); + +test("collects every deployed instance in a mixed cascade", () => { + const second = { + ...deployed, + name: "Relay Watcher", + backendAgentId: "buzz-agent-relay.service", + }; + assert.deepEqual( + collectPersonaRemoteCascadeInstances( + [ + instance(), + deployed, + { ...deployed, personaId: "custom:other" }, + second, + ], + PERSONA_ID, + ), + [ + { name: "Remote Scout", unitId: "buzz-agent-scout.service" }, + { name: "Relay Watcher", unitId: "buzz-agent-relay.service" }, + ], + ); +}); diff --git a/desktop/src/features/agents/lib/personaCascade.ts b/desktop/src/features/agents/lib/personaCascade.ts new file mode 100644 index 0000000000..1fb0b53678 --- /dev/null +++ b/desktop/src/features/agents/lib/personaCascade.ts @@ -0,0 +1,35 @@ +import type { ManagedAgent } from "@/shared/api/types"; + +/** + * A cascade instance whose deployment outlives its record. + * + * `unitId` is the provider-side identifier written once on a successful deploy + * (`backend_agent_id` — the systemd unit for the SSH provider). Nothing clears + * it, because the provider protocol has no undeploy, which is precisely why + * these instances need naming before a cascade: deleting them removes what this + * app knows about them and nothing else. + */ +export type PersonaRemoteCascadeInstance = { + name: string; + unitId: string; +}; + +/** + * Provider-backed instances in a persona's cascade that have a live deployment. + * + * Provider-backed instances that never completed a deploy are excluded — they + * have no remote unit, so deleting them costs nothing and needs no disclosure. + */ +export function collectPersonaRemoteCascadeInstances( + managedAgents: readonly ManagedAgent[], + personaId: string, +): PersonaRemoteCascadeInstance[] { + const instances: PersonaRemoteCascadeInstance[] = []; + for (const agent of managedAgents) { + if (agent.personaId !== personaId) continue; + if (agent.backend.type !== "provider") continue; + if (!agent.backendAgentId) continue; + instances.push({ name: agent.name, unitId: agent.backendAgentId }); + } + return instances; +} diff --git a/desktop/src/features/agents/lib/pinnedHarness.test.mjs b/desktop/src/features/agents/lib/pinnedHarness.test.mjs new file mode 100644 index 0000000000..4323437d71 --- /dev/null +++ b/desktop/src/features/agents/lib/pinnedHarness.test.mjs @@ -0,0 +1,229 @@ +/** + * Harness-pin identity. + * + * A provider-backed record's `agentCommand`/`agentArgs` name a binary on the + * HOST, which this computer's runtime catalog has never seen. These cases pin + * the two real fleet shapes that broke — a `hermes --profile acp` pin + * that rendered a generic icon, and a `claude-agent-acp` pin that only looked + * right by name collision with a local builtin — plus the label table's + * agreement with the Rust catalogs it mirrors. + */ + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { PRESET_LOGOS } from "../../onboarding/ui/RuntimeIcon.tsx"; +import { + HARNESS_LABELS, + providerRecordHarness, + resolvePinnedHarness, +} from "./pinnedHarness.ts"; + +// ── The label table mirrors the Rust catalogs ──────────────────────────────── +// +// `HARNESS_LABELS` restates ids and labels that live in Rust. The two sides are +// different languages, so no compiler catches drift and a renamed harness would +// silently fall back to rendering its raw command. Same trick as +// `presetLogos.test.mjs`: read the Rust source as text. + +const desktopRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../..", +); + +const discoveryRs = readFileSync( + path.join(desktopRoot, "src-tauri/src/managed_agents/discovery.rs"), + "utf8", +); + +/** Every `id` + `label` pair inside one Rust table literal. */ +function parseCatalog(constName, structName) { + const block = discoveryRs.match( + new RegExp( + `const ${constName}: &\\[${structName}\\] = &\\[([\\s\\S]*?)\\n\\];`, + ), + ); + assert.ok(block, `could not locate ${constName} in discovery.rs`); + return [ + ...block[1].matchAll(/^\s{8}id: "([^"]+)",\n\s{8}label: "([^"]+)",$/gm), + ].map((match) => ({ id: match[1], label: match[2] })); +} + +const rustHarnesses = [ + ...parseCatalog("KNOWN_ACP_RUNTIMES", "KnownAcpRuntime"), + ...parseCatalog("PRESET_HARNESSES", "PresetHarness"), +]; + +test("the Rust catalog parse found both tables", () => { + // Guards the regex itself: a struct-field reorder would otherwise yield zero + // pairs and make every assertion below vacuously pass. + assert.ok( + rustHarnesses.length >= 12, + `expected at least 12 harnesses, parsed ${rustHarnesses.length}`, + ); +}); + +/** + * Keys that intentionally have no Rust counterpart. + * + * The table also names the free-form command strings foreign surfaces carry — + * a relay agent's self-declared `agentType` — which are harnesses Buzz itself + * cannot run and so appear in no local catalog. Each one is listed with its + * reason, so an id dropped from the Rust side cannot hide here. + */ +const NOT_IN_RUST_CATALOG = new Set([ + // Declared by relay agents (`agentType`); Buzz has no Aider runtime entry. + "aider", +]); + +for (const { id, label } of rustHarnesses) { + test(`harness "${id}" renders its catalog label`, () => { + // Resolved through the public helper rather than the private table, so a + // pin whose command IS the id keeps proving the whole path. + assert.equal( + resolvePinnedHarness(id, []).label, + label, + `"${id}" falls back to its raw command instead of "${label}" — add it ` + + "to HARNESS_LABELS in pinnedHarness.ts.", + ); + }); +} + +test("HARNESS_LABELS names no harness the Rust catalogs dropped", () => { + // The other direction. Without it a harness renamed or removed in Rust + // leaves a stale TS entry that keeps answering with the old name, and the + // per-id tests above — which only walk the Rust side — stay green. + const rustIds = new Set(rustHarnesses.map((harness) => harness.id)); + const orphaned = Object.keys(HARNESS_LABELS).filter( + (id) => !rustIds.has(id) && !NOT_IN_RUST_CATALOG.has(id), + ); + assert.deepEqual( + orphaned, + [], + `HARNESS_LABELS names ids no Rust catalog emits: ${orphaned.join(", ")}. ` + + "Drop them, or list them in NOT_IN_RUST_CATALOG with the surface that " + + "carries the command.", + ); +}); + +// ── The pins that broke ───────────────────────────────────────────────────── + +test("a hermes profile pin is named and marked", () => { + const pin = resolvePinnedHarness("hermes", ["--profile", "marshall", "acp"]); + assert.equal(pin.id, "hermes"); + assert.equal( + pin.label, + "Hermes Agent (marshall)", + "the profile is the identity — two profiles of one harness are two agents", + ); + assert.equal(pin.logoUrl, PRESET_LOGOS.hermes); + assert.equal(pin.command, "hermes --profile marshall acp"); +}); + +test("a claude pin resolves through its adapter command", () => { + const pin = resolvePinnedHarness("claude-agent-acp", []); + assert.equal(pin.id, "claude"); + assert.equal(pin.label, "Claude Code"); + assert.ok(pin.logoUrl, "the bundled claude mark, not the generic icon"); + assert.equal(pin.command, "claude-agent-acp"); +}); + +test("an unknown host binary shows itself rather than a guess", () => { + const pin = resolvePinnedHarness("/opt/acme/acme-brain", ["serve", "--acp"]); + assert.equal(pin.id, null, "no id was earned, so none is claimed"); + assert.equal( + pin.label, + "/opt/acme/acme-brain", + "the pin itself is the honest label — never a local default", + ); + assert.equal(pin.logoUrl, null); + assert.equal(pin.command, "/opt/acme/acme-brain serve --acp"); +}); + +test("a host path and extension do not hide a known harness", () => { + assert.equal( + resolvePinnedHarness("/home/ubuntu/.local/bin/hermes-acp", []).label, + "Hermes Agent", + "the pin describes the HOST's filesystem, which is not this computer's", + ); + assert.equal( + resolvePinnedHarness("C:\\tools\\Codex-ACP.EXE", []).label, + "Codex", + ); +}); + +test("an unmapped base is not shortened into an identity it did not earn", () => { + // `buzz-agent` is a known id whole; `acme-agent` must not become "acme". + assert.equal(resolvePinnedHarness("buzz-agent", []).label, "Buzz Agent"); + assert.equal(resolvePinnedHarness("acme-agent", []).id, null); + assert.equal(resolvePinnedHarness("-hermes", []).id, null); +}); + +test("an empty pin says so", () => { + const pin = resolvePinnedHarness(" ", []); + assert.equal(pin.id, null); + assert.equal(pin.label, "Not configured"); + assert.equal(pin.command, ""); +}); + +// ── Profile parsing ───────────────────────────────────────────────────────── + +test("both spellings of the profile flag are read", () => { + assert.equal( + resolvePinnedHarness("hermes", ["--profile=matt", "acp"]).label, + "Hermes Agent (matt)", + ); +}); + +test("a profile flag without a value names no profile", () => { + // `--profile --verbose` means the flag was passed bare; naming the agent + // "Hermes Agent (--verbose)" would be worse than not narrowing at all. + assert.equal( + resolvePinnedHarness("hermes", ["--profile", "--verbose"]).label, + "Hermes Agent", + ); + assert.equal( + resolvePinnedHarness("hermes", ["--profile"]).label, + "Hermes Agent", + ); + assert.equal( + resolvePinnedHarness("hermes", ["--profile="]).label, + "Hermes Agent", + ); +}); + +test("an unknown command still takes its profile", () => { + assert.equal( + resolvePinnedHarness("acme-brain", ["--profile", "ops"]).label, + "acme-brain (ops)", + ); +}); + +// ── Local records are untouched ───────────────────────────────────────────── + +const localAgent = { + backend: { type: "local" }, + agentCommand: "claude-agent-acp", + agentArgs: [], +}; + +const remoteAgent = { + backend: { type: "provider", id: "ssh", config: { ssh_host: "vps" } }, + agentCommand: "hermes", + agentArgs: ["--profile", "marshall", "acp"], +}; + +test("a local record has no pin to read", () => { + // A local agent runs on this computer, where the catalog genuinely describes + // it; every local surface must keep resolving exactly as it did. + assert.equal(providerRecordHarness(localAgent), null); +}); + +test("a provider record answers from itself", () => { + const pin = providerRecordHarness(remoteAgent); + assert.equal(pin?.label, "Hermes Agent (marshall)"); + assert.equal(pin?.logoUrl, PRESET_LOGOS.hermes); +}); diff --git a/desktop/src/features/agents/lib/pinnedHarness.ts b/desktop/src/features/agents/lib/pinnedHarness.ts new file mode 100644 index 0000000000..777cadba3e --- /dev/null +++ b/desktop/src/features/agents/lib/pinnedHarness.ts @@ -0,0 +1,195 @@ +import { getHarnessLogoUrl } from "@/features/onboarding/ui/RuntimeIcon"; +import type { ManagedAgent } from "@/shared/api/types"; + +/** + * What a record's harness pin IS, read from the record and nothing else. + * + * For a provider-backed agent the pin (`agentCommand` + `agentArgs`) names a + * binary on the HOST. This computer's runtime catalog has never seen it, so + * every local lookup either misses outright (`hermes …` → no entry → generic + * icon, "custom") or hits by pure name collision (`claude-agent-acp` happens + * to be a local builtin's command, which is the only reason Claude's card ever + * looked right). Neither is knowledge. The record is. + * + * The derivation is deliberately generic: a command basename, the same + * base-id fallback `resolveHarnessLogo` already uses for variant ids, and a + * `--profile ` flag — a widespread CLI convention, not one harness's + * vocabulary. Nothing here knows what Hermes or SSH is. Do not teach it. + */ + +/** + * Human labels for every harness command this app has a name for. + * + * Mirrors the Rust `KNOWN_ACP_RUNTIMES` and `PRESET_HARNESSES` tables. The two + * sides are different languages, so no compiler catches drift — + * `pinnedHarness.test.mjs` reads the Rust source and asserts both directions, + * exactly as `presetLogos.test.mjs` does for the logo map. + * + * A key with no Rust counterpart must be listed in that test's + * `NOT_IN_RUST_CATALOG` with its reason: this table also names the free-form + * command strings foreign surfaces carry (a relay agent's self-declared + * `agentType`), which are harnesses Buzz itself cannot run. + */ +export const HARNESS_LABELS: Record = { + aider: "Aider", + amp: "Amp", + "buzz-agent": "Buzz Agent", + claude: "Claude Code", + codex: "Codex", + cursor: "Cursor", + goose: "Goose", + grok: "Grok Build", + hermes: "Hermes Agent", + kimi: "Kimi Code", + omp: "Oh My Pi", + opencode: "OpenCode", + openclaw: "OpenClaw", +}; + +/** Shown when a record carries no command at all. Matches the dialog copy. */ +const UNCONFIGURED_LABEL = "Not configured"; + +/** + * A pin's command and args in the one canonical spelling this app uses. + * + * The command is trimmed and blank args are dropped, because + * `create_time_agent_args` drops them on the way into the record — a catalog + * entry carrying one would otherwise never match the record minted from it. + * Beyond that nothing is rewritten: the pin names a binary on the HOST, and + * normalizing a remote command against local runtime identity is the category + * error `create_time_agent_args` exists to avoid. + * + * One owner because two lanes read this: the pin's own `command` string (what a + * human sees and copies) and `exclusiveRemoteHarness`'s identity comparison + * (whether two records are the same agent). If they disagreed, a pin differing + * only by whitespace would be "already added" and a different string on screen. + */ +export function normalizePinnedCommand( + command: string, + args: readonly string[], +): { command: string; args: string[] } { + return { + command: command.trim(), + args: args.map((arg) => arg.trim()).filter((arg) => arg.length > 0), + }; +} + +/** The identity of a harness pin, derived from the pin alone. */ +export type PinnedHarness = { + /** + * The harness id the pin resolves to, or `null` when nothing is recognized — + * an unknown host binary is a legitimate pin, and guessing an id for it would + * be the same lie in a new place. + */ + id: string | null; + /** Display name for the pin. Never empty. */ + label: string; + /** Bundled logo for the pin's harness, or `null` when it has none. */ + logoUrl: string | null; + /** The pin exactly as it runs on the host: command followed by its args. */ + command: string; +}; + +/** + * The harness name inside a command, stripped of the path and extension that + * differ per host. + * + * Both separators are handled because the pin describes the HOST's filesystem, + * which is not necessarily this computer's. + */ +function commandBasename(command: string): string { + const trimmed = command.trim(); + const separator = Math.max( + trimmed.lastIndexOf("/"), + trimmed.lastIndexOf("\\"), + ); + const basename = separator >= 0 ? trimmed.slice(separator + 1) : trimmed; + return basename.replace(/\.(?:exe|cmd|bat)$/i, "").toLowerCase(); +} + +/** + * The harness id a command basename belongs to. + * + * Exact first, then the text before the FIRST hyphen and only when that base is + * itself a known id — the same rule `resolveHarnessLogo` applies to variant + * ids, for the same reason. It resolves every real adapter spelling + * (`hermes-acp` → `hermes`, `claude-agent-acp` → `claude`, `amp-acp` → `amp`, + * `cursor-agent` → `cursor`) while leaving `buzz-agent` — a known id in its own + * right — whole, and refusing to shorten an unknown command into an id it did + * not earn. + */ +function resolveHarnessId(basename: string): string | null { + if (basename in HARNESS_LABELS) return basename; + const separator = basename.indexOf("-"); + if (separator <= 0) return null; + const base = basename.slice(0, separator); + return base in HARNESS_LABELS ? base : null; +} + +const PROFILE_FLAG = "--profile"; + +/** + * The profile a pin selects, when its args say so. + * + * A profile is a separate identity of the same harness — its own memory, + * credentials and sessions — so `hermes --profile marshall acp` and a plain + * `hermes` pin are two different agents and must not read as one name. Both + * spellings of the flag are accepted; a value that looks like another flag is + * refused, since that means the flag was passed without one. + */ +function profileName(args: readonly string[]): string | null { + for (const [index, raw] of args.entries()) { + const arg = raw.trim(); + if (arg.startsWith(`${PROFILE_FLAG}=`)) { + const value = arg.slice(PROFILE_FLAG.length + 1).trim(); + if (value.length > 0) return value; + continue; + } + if (arg !== PROFILE_FLAG) continue; + const value = args[index + 1]?.trim(); + if (value && !value.startsWith("-")) return value; + } + return null; +} + +/** + * Identify a harness pin. + * + * The label names the harness a human recognizes, narrowed by profile when the + * args name one. An unrecognized command falls back to the command itself + * rather than to any local default: the pin is the truth, and a command we + * cannot name is still better shown than replaced by a guess. + */ +export function resolvePinnedHarness( + command: string, + args: readonly string[], +): PinnedHarness { + const pin = normalizePinnedCommand(command, args); + const basename = commandBasename(pin.command); + const id = resolveHarnessId(basename); + const baseLabel = id ? HARNESS_LABELS[id] : pin.command || UNCONFIGURED_LABEL; + const profile = profileName(args); + + return { + id, + label: profile ? `${baseLabel} (${profile})` : baseLabel, + logoUrl: getHarnessLogoUrl(basename), + command: [pin.command, ...pin.args].join(" ").trim(), + }; +} + +/** + * The harness pin of a provider-backed record, or `null` for a local one. + * + * The single owner of "may this surface read the record instead of the local + * catalog?", so every surface asks the same question in the same place. `null` + * means the local path stays exactly as it was: a local agent runs on this + * computer, the catalog genuinely describes it, and its rendering must not + * change. + */ +export function providerRecordHarness( + agent: Pick, +): PinnedHarness | null { + if (agent.backend.type !== "provider") return null; + return resolvePinnedHarness(agent.agentCommand, agent.agentArgs); +} diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 1bd8af8976..96376dd79d 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -54,6 +54,10 @@ import { } from "@/features/agents/ui/buzzAgentModelTuningFields"; import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; +import { + modelFieldStatus, + shouldRenderModelControl, +} from "./agentAiConfigurationPolicy"; import { getGlobalAgentCredentialState } from "./globalAgentCredentialState"; export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = { @@ -156,41 +160,6 @@ export function shouldShowModelStatusMessage( return showDescriptions || status !== null; } -/** - * Whether the Model control should render given discovery state. - * - * Optional-model harnesses (Claude Code / Codex, `acpNative`) omit the control - * while discovery is in flight and after a **confirmed successful empty** - * catalog (IPC resolved, no usable options) — there is nothing useful to pick. - * Discovery failures / unavailable runtimes keep the control so #2246 failure - * UI can render. Full disclosure still shows the control when Custom model is - * available. Required-model harnesses always render the control. - */ -export function shouldRenderModelControl({ - discoveredModelOptions, - modelDiscoveryLoading, - modelDiscoverySuccessfulEmpty, - modelIsOptional, - showCustomModelOption, -}: { - discoveredModelOptions: readonly { id: string }[] | null; - modelDiscoveryLoading: boolean; - /** True only when discovery IPC resolved with a response that yielded no options. */ - modelDiscoverySuccessfulEmpty: boolean; - modelIsOptional: boolean; - showCustomModelOption: boolean; -}): boolean { - if (!modelIsOptional) return true; - if (modelDiscoveryLoading) return false; - const hasExplicitModel = (discoveredModelOptions ?? []).some( - (option) => option.id.trim().length > 0, - ); - if (hasExplicitModel) return true; - if (showCustomModelOption) return true; - // Omit only on confirmed successful empty — not on failure/unavailable. - return !modelDiscoverySuccessfulEmpty; -} - export type AgentConfigFieldsProps = { bakedEnv: BakedEnvEntry[]; selectedRuntime: AcpRuntimeCatalogEntry | undefined; @@ -351,12 +320,6 @@ export function AgentConfigFields({ runtimeFileConfig, runtimeId: credentialRuntimeId, }); - const configIsValid = - selectedRuntimeId.length > 0 && modelIsValid && credentialsValid; - React.useEffect(() => { - onValidityChange?.(configIsValid); - }, [configIsValid, onValidityChange]); - const { discoveredModelOptions, modelDiscoveryLoading, @@ -382,6 +345,23 @@ export function AgentConfigFields({ modelIsOptional, showCustomModelOption, }); + // Declared after the catalog so a model typed against it can block Save + // rather than silently resolving to the adapter's default at runtime. + const { blocked: modelBlocked, status: modelStatus } = modelFieldStatus({ + catalog: dependentFieldsDisabled ? null : discoveredModelOptions, + discoveryStatus: dependentFieldsDisabled ? null : modelDiscoveryStatus, + isTypedEntry: + isCustomModelEditing && providerForDiscovery.trim() !== "relay-mesh", + model: config.model ?? "", + }); + const configIsValid = + selectedRuntimeId.length > 0 && + modelIsValid && + credentialsValid && + !modelBlocked; + React.useEffect(() => { + onValidityChange?.(configIsValid); + }, [configIsValid, onValidityChange]); // Mount-time healing policy: onboarding page 4 edits the root config during // first-run (no higher layers to inherit from), so acting on open is safe @@ -797,9 +777,7 @@ export function AgentConfigFields({ modelDiscoveryLoading={ dependentFieldsDisabled ? false : modelDiscoveryLoading } - modelDiscoveryStatus={ - dependentFieldsDisabled ? null : modelDiscoveryStatus - } + modelDiscoveryStatus={modelStatus} onIsCustomModelEditingChange={onCustomModelEditingChange} onModelChange={handleModelChange} placeholderClassName={placeholderClassName} @@ -811,7 +789,7 @@ export function AgentConfigFields({ showCustomModelOption={showCustomModelOption} showStatusMessage={shouldShowModelStatusMessage( showDescriptions, - dependentFieldsDisabled ? null : modelDiscoveryStatus, + modelStatus, )} testId="global-agent-model" useCustomSelect={useCustomSelect} diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 5425131448..1af178daa2 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -10,10 +10,13 @@ import type { import { cn } from "@/shared/lib/cn"; import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Dialog } from "@/shared/ui/dialog"; -import { Input } from "@/shared/ui/input"; -import { Textarea } from "@/shared/ui/textarea"; import { AgentCreationPreview } from "./AgentCreationPreview"; -import { PersonaDropdownField } from "./PersonaDropdownField"; +import { + createGateHarnessId, + createRuntimeSelectionSatisfied, + runtimeDropdownOptions as buildRuntimeDropdownOptions, + runtimeDropdownPlaceholder, +} from "./createRuntimeGate"; import type { EnvVarsValue } from "./EnvVarsEditor"; import { PersonaAdvancedFields } from "./PersonaAdvancedFields"; import { PersonaModelField } from "./PersonaModelField"; @@ -34,8 +37,7 @@ import { import { AUTO_MODEL_DROPDOWN_VALUE, AUTO_PROVIDER_DROPDOWN_VALUE, - BLOCK_BUILD_HIDDEN_PROVIDER_IDS, - buildPersonaRuntimeDropdownOptions, + hiddenProviderIdsForBuild, CUSTOM_PROVIDER_DROPDOWN_VALUE, computeLocalModeGate, formatRuntimeOptionLabel, @@ -46,12 +48,8 @@ import { NO_RUNTIME_DROPDOWN_VALUE, runtimeSupportsLlmProviderSelection, type PersonaDropdownOption, - PERSONA_FIELD_CONTROL_CLASS, - PERSONA_FIELD_SHELL_CLASS, - PERSONA_LABEL_OPTIONAL_CLASS, shouldClearKnownModelForSelectionScope, } from "./agentConfigOptions"; -import { RequiredFieldLabel } from "./agentConfigControls"; import { modelDropdownOptions as buildModelDropdownOptions, relayMeshModelPickerState, @@ -62,10 +60,7 @@ import { selectionOnRuntimeChange, type RuntimeModelProviderSelection, } from "./runtimeModelProviderSelection"; -import { - MODEL_DISCOVERY_LOADING_VALUE, - usePersonaModelDiscovery, -} from "./usePersonaModelDiscovery"; +import { MODEL_DISCOVERY_LOADING_VALUE } from "./usePersonaModelDiscovery"; import { useBakedBuildEnvKeysQuery, useRuntimeFileConfigQuery } from "../hooks"; import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; @@ -79,16 +74,22 @@ import { agentAiConfigurationModeSatisfied, agentAiConfigurationPairForMode, initialAgentAiConfigurationMode, + modelFieldStatus, } from "./agentAiConfigurationPolicy"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload"; import { AgentDefinitionDialogFooter } from "./AgentDefinitionDialogFooter"; +import { AgentDefinitionIdentityFields } from "./AgentDefinitionIdentityFields"; +import { AgentLlmProviderField } from "./AgentLlmProviderField"; import { AddCustomHarnessDialog } from "./AddCustomHarnessDialog"; import { ADD_CUSTOM_HARNESS_OPTION, runtimeDropdownAction, usePendingHarnessSelection, } from "./addCustomHarness"; +import { useCreateRuntimeSeed } from "./useCreateRuntimeSeed"; +import { useRemoteAwareModelDiscovery } from "./useRemoteAwareModelDiscovery"; +import type { RemoteModelDiscoveryView } from "./whereToRunIntent"; type AgentDefinitionDialogProps = { open: boolean; @@ -107,10 +108,46 @@ type AgentDefinitionDialogProps = { ) => Promise; /** Publishes saved changes when the edited agent is shared in the catalog. */ publishCatalogUpdatesOnSave?: boolean; - /** Rendered below the form fields in create mode only ("Where to run"). */ - createRunSection?: React.ReactNode; + /** + * Rendered below the form fields in create mode only ("Where to run"). A + * render prop because the section's host model probe must carry this + * component's unsaved credential env (it reads the global layer itself). + */ + createRunSection?: (args: { envVars: EnvVarsValue }) => React.ReactNode; /** Extra create-mode submit gate (e.g. incomplete provider config). */ createSubmitBlocked?: boolean; + /** + * True when "Where to run" targets a backend provider. The harness then comes + * from the REMOTE host's catalog, so the local-runtime requirements below do + * not apply — demanding a locally-installed runtime would make every + * remote-only harness unsubmittable. + */ + createRunsRemotely?: boolean; + /** + * The picked remote harness's model catalog, read from the HOST. Non-null + * only for a provider create with a harness picked; it then REPLACES local + * model discovery, because the local catalog answers for this computer and + * the agent is not going to run here. + */ + createRemoteModelDiscovery?: RemoteModelDiscoveryView | null; + /** + * Display label of the harness picked from the HOST's catalog. The summary + * would otherwise name the local default runtime, which for a remote create + * is a harness on the wrong computer that the deploy will never run. + */ + createRemoteHarnessLabel?: string | null; + /** + * Id of the harness pinned on the HOST, which owns every credential question + * for a provider create: the deploy writes this agent's env on the host, + * keyed off the REMOTE command (`deploy.rs::metadata_env`), so the local + * runtime id — seeded from whatever this computer happens to have installed + * — names the wrong env contract. The id spaces are identical by + * construction: the SSH provider's discovery emits the same `goose` / + * `buzz-agent` keys the local catalog uses. Null until a harness is pinned. + */ + createRemoteHarnessId?: string | null; + /** This EDIT's definition backs a provider record — see `createRuntimeSeedAction`. */ + editsProviderRecord?: boolean; }; export type AgentDefinitionSubmitOptions = { @@ -137,6 +174,11 @@ export function AgentDefinitionDialog({ publishCatalogUpdatesOnSave = false, createRunSection, createSubmitBlocked = false, + createRunsRemotely = false, + createRemoteModelDiscovery = null, + createRemoteHarnessLabel = null, + createRemoteHarnessId = null, + editsProviderRecord = false, }: AgentDefinitionDialogProps) { const [displayName, setDisplayName] = React.useState(""); const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false); @@ -168,6 +210,12 @@ export function AgentDefinitionDialog({ // Without this, clearing runtime back to "" via "No preference" would re- // trigger the effect (the `runtime` dep would pass the length guard) and // snap the dropdown back to the default — an edit-mode regression. + // + // One deliberate exception: shedding the seed for a remote create re-arms + // this, so returning "Where to run" to this computer seeds the local default + // again rather than leaving a create that requires a local harness with none. + // That path cannot collide with the "No preference" case above — an explicit + // dropdown choice clears `isRuntimeAutoSeededRef`, which the shed requires. const hasSeededForOpenRef = React.useRef(false); const [showAdvancedFields, setShowAdvancedFields] = React.useState(false); const [isAvatarUploadPending, setIsAvatarUploadPending] = @@ -233,56 +281,19 @@ export function AgentDefinitionDialog({ hasSeededForOpenRef.current = false; }, [initialValues, open]); - React.useEffect(() => { - if ( - !open || - !initialValues || - initialValues.runtime?.trim() || - runtimesLoading || - runtime.trim().length > 0 || - defaultRuntime === null || - hasSeededForOpenRef.current - ) { - return; - } - - setRuntime(defaultRuntime.id); - hasSeededForOpenRef.current = true; - if ("id" in initialValues) { - // Edit mode: record that this runtime was auto-seeded so the submit path - // can omit it from the payload for builtin definitions (canonical runtime - // null; sync would revert the value anyway). Explicit user changes via - // the dropdown clear this flag. - isRuntimeAutoSeededRef.current = true; - } - }, [defaultRuntime, initialValues, open, runtime, runtimesLoading]); - - // Keep an inherited Create runtime synced with defaults saved in-place. - React.useEffect(() => { - if ( - !open || - !initialValues || - "id" in initialValues || - initialValues.runtime?.trim() || - aiConfigurationMode !== "defaults" || - runtimesLoading || - defaultRuntime === null || - (runtime.trim().length > 0 && !isRuntimeAutoSeededRef.current) - ) { - return; - } - - if (runtime !== defaultRuntime.id) setRuntime(defaultRuntime.id); - isRuntimeAutoSeededRef.current = true; - hasSeededForOpenRef.current = true; - }, [ + useCreateRuntimeSeed({ aiConfigurationMode, + createRunsRemotely, + editsProviderRecord, defaultRuntime, + hasSeededForOpenRef, initialValues, + isRuntimeAutoSeededRef, open, runtime, runtimesLoading, - ]); + setRuntime, + }); // Keep setup guidance reachable when no available runtime can be inherited. React.useEffect(() => { @@ -342,6 +353,7 @@ export function AgentDefinitionDialog({ initialModel: initialValues.model, initialProvider: initialValues.provider, initialModelProviderEditableWithoutRuntime, + runsRemotely: createRunsRemotely, }); const namePool = parsePersonaNamePoolText(namePoolText); const namePoolInput = @@ -388,13 +400,21 @@ export function AgentDefinitionDialog({ } const selectedRuntime = runtimes.find((p) => p.id === runtime); + // The harness whose credential contract this dialog must satisfy — the + // host's pin for a remote create, the local runtime otherwise. See + // createGateHarnessId for why the local id is the wrong question remotely. + const effectiveHarnessId = createGateHarnessId({ + runsRemotely: createRunsRemotely, + runtime, + remoteHarnessId: createRemoteHarnessId, + }); const blankRuntimeModelProviderEditable = initialModelProviderEditableWithoutRuntime && runtime.trim().length === 0; const runtimeCanChooseLlmProvider = - runtimeSupportsLlmProviderSelection(runtime) || + runtimeSupportsLlmProviderSelection(effectiveHarnessId) || blankRuntimeModelProviderEditable; const llmProviderFieldVisible = - (runtime.trim().length > 0 && runtimeCanChooseLlmProvider) || + (effectiveHarnessId.trim().length > 0 && runtimeCanChooseLlmProvider) || blankRuntimeModelProviderEditable; const trimmedProvider = provider.trim(); // Required credential env keys for this runtime + provider combination. @@ -402,9 +422,17 @@ export function AgentDefinitionDialog({ // locked rows in the env vars editor. // File-layer config for the selected runtime (e.g. goose config.yaml). // Used to silence requirements already satisfied there. - const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(runtime, { - enabled: open, + const { data: localRuntimeFileConfig } = useRuntimeFileConfigQuery(runtime, { + enabled: open && !createRunsRemotely, }); + // The file layer reads THIS machine's ~/.config, so it can only answer for a + // local create. Letting a local goose config.yaml silence a requirement that + // belongs to a different host would turn a loud create-time block into a + // silent deploy-time failure. Disabling the query is not enough on its own: + // a disabled query still hands back whatever another surface cached. + const runtimeFileConfig = createRunsRemotely + ? undefined + : localRuntimeFileConfig; function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) { setHasUserChanges(true); setAiConfigurationMode(nextMode); @@ -433,10 +461,15 @@ export function AgentDefinitionDialog({ globalEnvVars: globalConfig.env_vars, globalProvider: inheritedProviderDefault.value, globalModel: inheritedModelDefault.value, + // Deliberately false even for a remote create. The credential keys this + // gate demands are the ones the agent's env carries, and a remote + // deploy writes that env to the host verbatim — so a missing key is + // just as fatal there, and silencing the gate would ship an agent that + // deploys and then cannot authenticate. isProviderMode: false, model, provider: trimmedProvider, - runtimeId: runtime, + runtimeId: effectiveHarnessId, runtimeFileConfig, }), [ @@ -447,7 +480,7 @@ export function AgentDefinitionDialog({ inheritedProviderDefault.value, model, trimmedProvider, - runtime, + effectiveHarnessId, runtimeFileConfig, ], ); @@ -480,8 +513,12 @@ export function AgentDefinitionDialog({ } = apiKeyFieldState; const providerIsRequired = aiConfigurationMode === "custom" && runtimeCanChooseLlmProvider; + // A remote create has no local runtime to key the field off — its harness + // lives on the host — so the host's own catalog makes the field meaningful. const modelFieldVisible = - runtime.trim().length > 0 || blankRuntimeModelProviderEditable; + runtime.trim().length > 0 || + blankRuntimeModelProviderEditable || + createRemoteModelDiscovery !== null; const isExplicitModelRequired = aiConfigurationMode === "custom"; // Gate the provider requirement on the field's actual visibility, not the raw // runtime capability. Codex/Claude hide the provider picker (they drive their @@ -494,47 +531,34 @@ export function AgentDefinitionDialog({ { provider, model }, runtimeCanChooseLlmProvider, ); - const selectedRuntimeIsAvailable = - runtime.trim().length === 0 || - selectedRuntime?.availability === "available"; - // Gate model/provider validity through missingNormalizedFields — single - // source of truth with the readiness gate so display and Save can't drift. - const canSubmit = - canSubmitPersonaDialog({ displayName, isPending }) && - (!isCreateMode || runtime.trim().length > 0) && - (!isCreateMode || selectedRuntimeIsAvailable) && - (!isCreateMode || !createSubmitBlocked) && - // Crash-loop guard, create AND edit: an empty allowlist would crash - // every instance minted from this definition at startup. - personaBehaviorDraftValid(behaviorDraft) && - // D1: localModeSatisfied covers both missingNormalizedFields AND - // missingEnvKeys — credential env keys now block submit, not just display. - localModeSatisfied && - customAiPairSatisfied && - !isAvatarUploadPending; - - // Merge global env as the base layer so credential keys satisfied via global - // config are available to model discovery — same rationale as in AgentInstanceEditDialog. - const envVarsForDiscovery = React.useMemo( - () => ({ ...globalConfig.env_vars, ...envVars }), - [globalConfig.env_vars, envVars], - ); + // How far the LOCAL catalog gates this create — see createRuntimeGate.ts. + const runtimeGate = { + isCreateMode, + runsRemotely: createRunsRemotely, + runtime, + selectedRuntime, + hasLocalDefaultRuntime: defaultRuntime !== null, + }; const { discoveredModelOptions, modelDiscoveryLoading, modelDiscoveryStatus, - } = usePersonaModelDiscovery({ - envVars: envVarsForDiscovery, - isCustomProviderEditing, - modelFieldVisible, - open, - // Gate provider by runtime: runtimes that don't support LLM provider - // selection (codex, claude) must not inherit the global provider — doing - // so causes them to discover models from the wrong provider. - provider: runtimeSupportsLlmProviderSelection(runtime) - ? effectiveProvider - : "", - selectedRuntime, + } = useRemoteAwareModelDiscovery({ + local: { + envVars, + globalEnvVars: globalConfig.env_vars, + isCustomProviderEditing, + modelFieldVisible, + open, + provider: effectiveProvider, + runtime, + selectedRuntime, + }, + remote: createRemoteModelDiscovery, + onHarnessChange: () => { + setModel(""); + setIsCustomModelEditing(false); + }, }); const staticModelOptions = getPersonaModelOptions(runtime, effectiveProvider); const runtimeModelOptions = getRuntimePersonaModelOptions(runtime); @@ -553,17 +577,28 @@ export function AgentDefinitionDialog({ modelFieldVisible, provider: effectiveProvider, }); - // On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot - // migration rewrites any persisted Databricks v1 values → v2. Hide the v1 - // option there so it is not offered for new selections. OSS builds have no - // baked provider, so v1 remains visible. - const hideProviderIds = React.useMemo( - () => - (bakedEnvKeys ?? []).includes("BUZZ_AGENT_PROVIDER") - ? BLOCK_BUILD_HIDDEN_PROVIDER_IDS - : new Set(), - [bakedEnvKeys], - ); + const { blocked: modelBlocked, status: modelStatus } = modelFieldStatus({ + catalog: discoveredModelOptions, + discoveryStatus: modelDiscoveryStatus, + isTypedEntry: isCustomModelEditing && showCustomModelInput, + model, + }); + // Gate model/provider validity through missingNormalizedFields — single + // source of truth with the readiness gate so display and Save can't drift. + const canSubmit = + canSubmitPersonaDialog({ displayName, isPending }) && + createRuntimeSelectionSatisfied(runtimeGate) && + (!isCreateMode || !createSubmitBlocked) && + // Crash-loop guard, create AND edit: an empty allowlist would crash + // every instance minted from this definition at startup. + personaBehaviorDraftValid(behaviorDraft) && + // D1: localModeSatisfied covers both missingNormalizedFields AND + // missingEnvKeys — credential env keys now block submit, not just display. + localModeSatisfied && + customAiPairSatisfied && + !modelBlocked && + !isAvatarUploadPending; + const hideProviderIds = hiddenProviderIdsForBuild(bakedEnvKeys); const providerOptions = getPersonaProviderOptions( trimmedProvider, runtime, @@ -578,18 +613,21 @@ export function AgentDefinitionDialog({ const showCustomProviderInput = llmProviderFieldVisible && isCustomProviderEditing; const runtimeDropdownValue = runtime.trim() || NO_RUNTIME_DROPDOWN_VALUE; - const { blankRuntimeOptionLabel, runtimeDropdownOptions } = - buildPersonaRuntimeDropdownOptions({ - defaultRuntimeId: defaultRuntime?.id, - isCreateMode, - runtime, - runtimes, - runtimesLoading, - }); + const runtimeDropdownOptions = buildRuntimeDropdownOptions({ + defaultRuntimeId: defaultRuntime?.id ?? null, + gate: runtimeGate, + runtimes, + runtimesLoading, + }); runtimeDropdownOptions.push(ADD_CUSTOM_HARNESS_OPTION); - const runtimeSummaryLabel = selectedRuntime - ? formatRuntimeOptionLabel(selectedRuntime) - : runtime.trim() || "Not configured"; + // The host's pick wins outright for a remote create: `runtime` still holds + // whatever the local seeding effects resolved, and naming that harness in + // the summary would describe a machine this agent will never run on. + const runtimeSummaryLabel = + createRemoteHarnessLabel ?? + (selectedRuntime + ? formatRuntimeOptionLabel(selectedRuntime) + : runtime.trim() || "Not configured"); const providerDropdownOptions: PersonaDropdownOption[] = [ ...providerOptions .filter((option) => option.id.trim().length > 0) @@ -783,55 +821,20 @@ export function AgentDefinitionDialog({ />
-
- -
- setDisplayName(event.target.value)} - placeholder="Fizz" - value={displayName} - /> -
-
+ {/* First, not last: every field below is scoped by the answer. The + harness comes from the chosen machine's catalog and the models + come from that harness, so asking this at the end would mean + answering the dependent questions against the wrong computer + and then silently re-scoping them. */} + {isCreateMode ? createRunSection?.({ envVars }) : null} -
- -
-