Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d307c1d
docs(remote-agents): specify the SSH provider contract
troyhoffman-oss Jul 28, 2026
1b14b6e
feat(backend-ssh): add the SSH remote-agent provider crate
troyhoffman-oss Jul 28, 2026
204ecf5
feat(desktop): discover and invoke backend providers on PATH
troyhoffman-oss Jul 29, 2026
489c1a5
feat(desktop): build a remote deploy payload from a provider record
troyhoffman-oss Jul 29, 2026
c8ec0df
feat(desktop): scope persona runtime sync to local records
troyhoffman-oss Jul 29, 2026
cf22ca9
feat(desktop): type and bind the backend-provider Tauri surface
troyhoffman-oss Jul 29, 2026
9ec77b7
feat(desktop): ask where an agent runs before anything else
troyhoffman-oss Jul 29, 2026
dd04b62
feat(desktop): one agent per exclusive host identity
troyhoffman-oss Jul 29, 2026
e8552f9
feat(settings): permanent Remote servers section under Settings > Agents
troyhoffman-oss Jul 29, 2026
b0a0cb2
ci(windows): run buzz-backend-ssh's tests on Windows
troyhoffman-oss Jul 29, 2026
5cfd992
test(desktop): cover provider-backed edit routing and the empty remot…
troyhoffman-oss Jul 29, 2026
2a495cc
feat(scripts): add a remote-host provisioning preflight
troyhoffman-oss Jul 29, 2026
449a5b9
fix(backend-ssh): key remote units by agent identity
troyhoffman-oss Jul 29, 2026
285924d
fix(desktop): derive provider ids without executable extensions
troyhoffman-oss Jul 29, 2026
9bc0f62
fix(backend-ssh): quote the resolved harness path in ExecStart
troyhoffman-oss Jul 29, 2026
7bbb95d
feat(backend-ssh): run remote units with NoNewPrivileges
troyhoffman-oss Jul 29, 2026
071974d
feat(desktop): disclose that remote agents lose team instructions
troyhoffman-oss Jul 29, 2026
d45c25e
docs(remote-agents): report the Git helper and the missing workspace
troyhoffman-oss Jul 29, 2026
41b8a62
docs(remote-agents): name the stale unit a rekeyed slug leaves behind
troyhoffman-oss Jul 29, 2026
e8a8e34
fix(backend-ssh): resolve harnesses from ~/.local/bin over non-intera…
troyhoffman-oss Jul 29, 2026
c4a0e16
fix(scripts): let the provisioning PATH check pass without a login shell
troyhoffman-oss Jul 29, 2026
879f57d
docs(remote-agents): note the observer needs a resolved owner
troyhoffman-oss Jul 29, 2026
21fc951
Merge remote-tracking branch 'origin/main' into feat/remote-agents
troyhoffman-oss Aug 2, 2026
30e7546
feat(backend-ssh): declare protocol_version in the info response
troyhoffman-oss Aug 2, 2026
0483c5c
fix(backend-ssh): derive the agent identity from the nsec, never trus…
troyhoffman-oss Aug 2, 2026
8cacdfd
test(desktop): make provider discovery tests independent of the host'…
troyhoffman-oss Aug 2, 2026
310b694
fix(acp,backend-ssh): stop restarting an agent the relay has permanen…
troyhoffman-oss Aug 2, 2026
99674a0
feat(backend-ssh): allow a non-default known_hosts file
troyhoffman-oss Aug 2, 2026
459ca10
feat(acp): answer --version so the host probe can report the harness …
troyhoffman-oss Aug 2, 2026
6c20552
docs: point each remote-agents reference at the doc that owns the topic
troyhoffman-oss Aug 2, 2026
7b528f0
fix(backend-ssh): drop the expect() from identity derivation and eras…
troyhoffman-oss Aug 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,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).
Expand Down
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,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
Expand Down
37 changes: 36 additions & 1 deletion crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,14 @@ pub struct AuthenticateArgs {
#[derive(Debug, Parser)]
#[command(
name = "buzz-acp",
about = "ACP harness that bridges Buzz events to AI agents"
about = "ACP harness that bridges Buzz events to AI agents",
// `buzz-backend-ssh`'s host probe reads `<command> --version` for every
// binary it resolves, including this one, and reports it as
// `buzz_acp.version` in `discover_harnesses`. Without this the field came
// back empty and the desktop could not tell a current host from one still
// running a harness installed months ago. clap short-circuits `--version`
// before it enforces required arguments, so it answers without a key.
version
)]
pub struct CliArgs {
#[arg(long, env = "BUZZ_RELAY_URL", default_value = "ws://localhost:3000")]
Expand Down Expand Up @@ -1429,6 +1436,34 @@ mod tests {
use crate::filter::{ChannelScope, SubscriptionRule};
use clap::{Parser, ValueEnum};

/// `buzz-backend-ssh`'s host probe runs `buzz-acp --version` with no
/// environment and no key, and reports the first line as
/// `buzz_acp.version`. Two properties make that work, and both are easy to
/// break: the flag must exist, and clap must answer it *before* enforcing
/// the required `--private-key`, or the probe gets a usage error on stderr
/// and records an empty version.
#[test]
fn version_is_answerable_without_a_private_key() {
let error = CliArgs::try_parse_from(["buzz-acp", "--version"])
.expect_err("--version short-circuits parsing, so it surfaces as an Err");
assert_eq!(error.kind(), clap::error::ErrorKind::DisplayVersion);
let rendered = error.to_string();
assert!(rendered.starts_with("buzz-acp "), "{rendered}");
assert!(
rendered.contains(env!("CARGO_PKG_VERSION")),
"{rendered} should carry the crate version"
);
// The probe reads one line and would otherwise record a fragment.
assert_eq!(rendered.trim().lines().count(), 1, "{rendered}");

// Without the flag, a missing key is still a hard error — --version is
// an exemption for exactly one argument-free query, not a relaxation.
let missing = CliArgs::try_parse_from(["buzz-acp"])
.expect_err("--private-key is required")
.kind();
assert_ne!(missing, clap::error::ErrorKind::DisplayVersion);
}

/// Build a minimal Config for testing without CLI parsing.
fn test_config(mode: SubscribeMode) -> Config {
Config {
Expand Down
55 changes: 53 additions & 2 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,40 @@ mod usage;

pub use usage::TurnUsage;

/// Process exit code for a relay rejection that retrying cannot fix.
///
/// The harness normally runs under a supervisor that restarts it on any exit —
/// `Restart=always` in the SSH binding's systemd unit — because almost every
/// way it can die is transient and an agent that stays down is worse than one
/// that flaps. A relay auth *rejection* is the exception: `invalid:`,
/// `auth-required:`, `restricted:` and `blocked:` are the relay refusing this
/// identity or configuration (bad signature, ban, non-member, allowlist
/// denial), and nothing about restarting changes the identity or the config it
/// is refusing. Under a 5-second restart policy that becomes an indefinite
/// reconnect loop against a relay that has already said no.
///
/// So terminal auth failure exits with a code no other path uses, and the unit
/// pairs it with `RestartPreventExitStatus=` (see
/// `crates/buzz-backend-ssh/assets/buzz-acp@.service`). The agent stops once,
/// in `failed` state, where `systemctl --user status` shows the reason —
/// instead of hiding it inside a restart loop. Every other exit still
/// restarts.
///
/// `78` is `EX_CONFIG` from `sysexits.h`: "something was found in an
/// unconfigured or misconfigured state", which is exactly this case. It is
/// outside the range shells use for signals and clear of the 90–95 range the
/// SSH provider's deploy script uses for host-preflight failures.
pub const EXIT_TERMINAL_AUTH_FAILURE: i32 = 78;

// The SSH binding's unit hard-codes this number in `RestartPreventExitStatus=`
// (that crate does not link this one), so changing it here without changing
// `crates/buzz-backend-ssh/assets/buzz-acp@.service` would silently restore the
// restart loop. Both sides assert the literal; this is the reminder.
const _: () = assert!(
EXIT_TERMINAL_AUTH_FAILURE == 78,
"update RestartPreventExitStatus= in buzz-backend-ssh/assets/buzz-acp@.service to match"
);

use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::time::Duration;
Expand Down Expand Up @@ -1339,9 +1373,26 @@ async fn tokio_main() -> Result<()> {
.and_then(|s| buzz_sdk::nip_oa::parse_auth_tag(&s).ok());

let mut relay =
HarnessRelay::connect(&config.relay_url, &config.keys, &pubkey_hex, relay_auth_tag)
match HarnessRelay::connect(&config.relay_url, &config.keys, &pubkey_hex, relay_auth_tag)
.await
.map_err(|e| anyhow::anyhow!("relay connect error: {e}"))?;
{
Ok(relay) => relay,
// The relay rejecting this identity is not a failure a restart can
// clear, and this process is normally supervised by something that
// restarts it every 5 seconds. Exit with the code the unit's
// `RestartPreventExitStatus=` names, so the agent stops once in
// `failed` state with the reason visible instead of looping against
// a relay that has already refused it.
Err(e) if relay::is_terminal_auth_rejection(&e) => {
tracing::error!(
"relay rejected this agent's identity: {e}. This will not succeed on retry — \
exiting {EXIT_TERMINAL_AUTH_FAILURE} so a supervisor does not restart it. \
Check the agent's key, its community membership, and BUZZ_AUTH_TAG."
);
std::process::exit(EXIT_TERMINAL_AUTH_FAILURE);
}
Err(e) => return Err(anyhow::anyhow!("relay connect error: {e}")),
};

// Tell the relay background task the watermark so it can use
// `since = watermark - 5s` on the first REQ instead of `since=now`.
Expand Down
75 changes: 75 additions & 0 deletions crates/buzz-acp/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3787,6 +3787,23 @@ fn is_terminal_auth_failure(message: &str) -> bool {
!message.trim_start().starts_with("error:")
}

/// Whether a failed connect was the relay *rejecting this identity or config*,
/// as opposed to any other way the handshake can fail.
///
/// Narrower than [`is_terminal_connect_error`] on purpose. That predicate
/// decides "should the startup retry loop give up?", and answers yes for
/// deterministic transport failures (a malformed URL, an incompatible TLS
/// peer) as well as for auth rejections. This one decides "is restarting the
/// process pointless?", and only an auth rejection earns that: a bad URL is a
/// misconfiguration a redeploy fixes, whereas a ban or an allowlist denial is
/// the relay's standing answer to this key.
///
/// Drives [`crate::EXIT_TERMINAL_AUTH_FAILURE`], which the SSH binding's unit
/// pairs with `RestartPreventExitStatus=`.
pub(crate) fn is_terminal_auth_rejection(err: &RelayError) -> bool {
matches!(err, RelayError::AuthFailed(message) if is_terminal_auth_failure(message))
}

/// Retry `op` with bounded jittered backoff, stopping immediately on a
/// terminal error (see [`is_terminal_connect_error`]). Used by
/// `HarnessRelay::connect()` so a transient failure during the initial
Expand Down Expand Up @@ -5619,6 +5636,64 @@ mod tests {
);
}

/// The predicate behind [`crate::EXIT_TERMINAL_AUTH_FAILURE`], and the one
/// place the "restarting is pointless" claim is decided.
///
/// It must be strictly narrower than `is_terminal_connect_error`: that one
/// also stops the *retry loop* for deterministic transport failures, but a
/// bad URL or an incompatible TLS peer is a misconfiguration a redeploy
/// fixes, so suppressing restart for those would strand an agent a fix
/// would otherwise revive.
#[test]
fn only_an_auth_rejection_suppresses_the_supervisor_restart() {
// Explicit rejections of this identity/config: nothing a restart does
// changes the answer.
for message in [
"invalid: bad signature",
"auth-required: not a member",
"restricted: not on the allowlist",
"blocked: banned",
"unrecognized-prefix: something new",
] {
let err = RelayError::AuthFailed(message.to_string());
assert!(is_terminal_auth_rejection(&err), "{message}");
// Every rejection is also terminal for the retry loop.
assert!(is_terminal_connect_error(&err), "{message}");
}

// The relay failing closed on its own dependency is transient, so it
// neither stops the retry loop nor suppresses restart.
let dependency_fault = RelayError::AuthFailed("error: ban lookup failed".into());
assert!(!is_terminal_auth_rejection(&dependency_fault));
assert!(!is_terminal_connect_error(&dependency_fault));

// Terminal for the retry loop, but NOT an identity rejection: these
// must keep restarting, because a redeploy can fix them.
for err in [
RelayError::Http("404 Not Found".into()),
RelayError::UnexpectedMessage("garbage".into()),
] {
assert!(
is_terminal_connect_error(&err),
"{err} should stop the retry loop"
);
assert!(
!is_terminal_auth_rejection(&err),
"{err} must not suppress restart"
);
}

// Plainly transient failures: neither.
for err in [
RelayError::ConnectionClosed,
RelayError::Timeout,
RelayError::NoAuthChallenge,
] {
assert!(!is_terminal_auth_rejection(&err), "{err}");
assert!(!is_terminal_connect_error(&err), "{err}");
}
}

/// A relay-side dependency fault (NIP-01 `error:` prefix) is transient —
/// the relay is failing closed on itself, not rejecting this identity —
/// so it must be retried rather than surfaced immediately like a real
Expand Down
39 changes: 39 additions & 0 deletions crates/buzz-backend-ssh/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
[package]
name = "buzz-backend-ssh"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Buzz backend provider that deploys managed agents to a remote host over SSH"

# Deliberately binary-only and deliberately NOT bundled with the desktop app
# (not in `tauri.conf.json` externalBin, not in `scripts/bundle-sidecars.sh`).
# `discover_provider_candidates` prepends the app bundle's own directory to the
# provider search path, so shipping this inside the bundle would give every
# install an auto-discovered SSH-deploy capability and quietly undermine the
# "Only use providers from trusted sources" warning the desktop shows. It is a
# release artifact the user installs to `~/.local/bin`, which is already on the
# discovery path.
[[bin]]
name = "buzz-backend-ssh"
path = "src/main.rs"

[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
zeroize = { workspace = true }
# Deploy can install `buzz-acp` on the host by streaming it inside the script
# that already travels on the SSH stdin channel. base64 is what keeps raw bytes
# from corrupting that stream; sha2 is what lets the host refuse a payload that
# arrived damaged. Both are already workspace dependencies.
base64 = { workspace = true }
sha2 = { workspace = true }
# Step 0 of the deploy reconciliation loop (docs/remote-agents.md) requires the
# provider to derive the agent's identity from `private_key_nsec` rather than
# trust a caller-supplied pubkey. bech32 decodes the NIP-19 nsec; secp256k1
# turns those bytes into the x-only public key every host-side name is keyed on.
# Versions match the ones already resolved in the workspace lockfile
# (buzz-pair-relay pins the same secp256k1 major).
bech32 = "0.11"
secp256k1 = "0.31"
52 changes: 52 additions & 0 deletions crates/buzz-backend-ssh/assets/buzz-acp@.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
[Unit]
Description=Buzz agent %i
After=network-online.target
Wants=network-online.target
# A long-running agent must never be rate-limited into staying down: a unit
# held by the start limiter looks exactly like an agent that silently died,
# and only `systemctl reset-failed` clears it.
StartLimitIntervalSec=0

[Service]
Type=simple
# The agent runs arbitrary code by design, so the SSH user's own privileges are
# the intended ceiling — but without this the harness can climb past them
# through any setuid/setgid binary on the host, or through passwordless sudo
# granted to that user. A VPS pilot exercised the harness, the Buzz CLI,
# NIP-OA owner-reviewed draft creation and repository branch pushes with this
# set; all remained functional.
#
# Deliberately the whole hardening delta. ProtectSystem/ProtectHome would also
# apply here, but the agent has no modeled workspace yet (see the REPOS /
# working-directory limitation in docs/remote-agents-ssh.md) — so until writable
# paths are something the protocol states, those directives would be guessing
# at which of the user's home an agent legitimately needs.
NoNewPrivileges=true
# Holds the agent's minted nsec. Written by the provider with umask 077 and
# chmod 600; systemd reads it as the owning user.
EnvironmentFile=%h/.config/buzz-acp/%i.env
# Absolute path, substituted at install time from the host's resolved
# `buzz-acp` and double-quoted there — systemd splits an unquoted value on
# whitespace, and the configurable `buzz-acp path on the server` may name a
# directory that contains some. systemd does not expand environment variables
# in the program position, and the shell indirection that would work around
# that is not worth adding to a unit whose environment carries a private key.
ExecStart=@BUZZ_ACP_BIN@
Restart=always
RestartSec=5
# The one exit that must not restart. `buzz-acp` exits 78 when the relay
# rejects this agent's identity outright — a bad signature, a ban, a
# non-member, an allowlist denial (buzz_acp::EXIT_TERMINAL_AUTH_FAILURE). The
# relay has already given its standing answer for this key, so `Restart=always`
# would reconnect every 5 seconds forever against a "no", burning host and
# relay resources and hiding the reason inside a restart loop. Preventing
# restart for exactly this code leaves the unit in `failed` state, where
# `systemctl --user status` shows the cause.
#
# Everything else still restarts. A crash, an OOM kill, a dropped link, a
# harness that exits on its own — all transient, and an agent that stays down
# is worse than one that flaps.
RestartPreventExitStatus=78

[Install]
WantedBy=default.target
Loading