Skip to content

feat: add SSH/systemd remote agent backend - #6052

Closed
jonssfredrik wants to merge 14 commits into
block:mainfrom
jonssfredrik:codex/systemd-backend
Closed

feat: add SSH/systemd remote agent backend#6052
jonssfredrik wants to merge 14 commits into
block:mainfrom
jonssfredrik:codex/systemd-backend

Conversation

@jonssfredrik

Copy link
Copy Markdown

Summary

  • add the buzz-backend-ssh provider for SSH deployment into per-agent systemd user units
  • add host/harness discovery, model probing, Tailscale recovery, optional binary push, and a VPS preflight script
  • key remote units from the pubkey derived from the nsec and keep secrets on SSH stdin
  • consume Desktop’s resolved launch block so remote command, args, env precedence, owner fallback, and effective parallelism match local semantics
  • document installation, trust boundaries, lifecycle, and host requirements

Verification

  • cargo test -p buzz-backend-ssh — 133 passed
  • cargo clippy -p buzz-backend-ssh --all-targets -- -D warnings
  • bash -n scripts/provision-buzz-host.sh
  • git diff --check

Desktop Rust tests were attempted but this VPS lacks the GLib development package required by glib-sys; compilation stopped before project tests. No system packages were installed.

Based on the field-tested SSH provider work from #3449, ported as a focused successor onto current main.

troyhoffman-oss and others added 13 commits August 16, 2026 11:05
The reference implementation of the `BackendKind::Provider` seam that
`managed_agents/backend.rs` already describes and block#2859 says has no OSS
implementation. One process per op, no daemon, no state: the desktop
writes one JSON request to stdin and reads one JSON response from
stdout, and this crate turns that into ssh.

Six modules, one protocol implementation:

  protocol   the wire types and the `Secret` that renders `[REDACTED]`
  ssh        the transport; every secret rides the stdin channel
  tailscale  peer enumeration and the re-auth recovery URL
  discover   `check`, `discover_harnesses`, `probe_models`
  install    resolve-or-install for `buzz-acp` and the `buzz` CLI
  deploy     the remote script, the env file, the systemd unit

They are one commit because they are one protocol implementation, and
because they do not separate: `deploy.rs` reaches into `install` at 43
call sites and `include_str!`s the unit template, and `discover` and
`deploy` are mutually dependent (`string_list` one way, `env_map` and
`is_well_formed_env_key` the other). Any smaller unit would be a file
state that never compiled.

Three invariants hold across every op, stated in `main.rs`'s module doc
and enforced at named sites:

1. Secrets never reach a log, an error string, a `Debug` rendering, or
   an argv. They travel only inside the script body on the SSH stdin
   channel, because the remote `ps` is world-readable.
2. A deploy without the desktop-minted `private_key_nsec` fails closed,
   inside `Agent::from_request` so no caller can route around it. Env
   values are written through a control-character guard, because a
   newline would split the assignment into a second, attacker-chosen
   line.
3. Tailscale is an enhancement, never a dependency: absent, logged out,
   or empty, the `info` schema is byte-identical to the plain one.
   Trust-on-first-use is allowed for exactly one class of address — a
   device this machine's own Tailscale daemon already lists as a peer,
   which required a WireGuard-authenticated tunnel to reach. For
   anything the user typed, host-key checking stays strict. The re-auth
   URL is constructed, never parsed out of host output, so a compromised
   host cannot make the desktop open an arbitrary URL.

Harness discovery extends upstream's `hermes` preset rather than
replacing it. Because a host commonly runs several Hermes profiles out
of one install, `discover_harnesses` fans that single preset out into
`hermes-<name>` entries carrying `--profile` args, so each configuration
appears as its own harness. Profile names are untrusted input on their
way into a catalog id and then an argument vector, so they are filtered
against Hermes's own charset and capped — enforced twice, once in the
script so the bytes are never sent and once on the way back in, because
a host is free to ignore the script it was handed.

The crate is deliberately NOT bundled with the desktop app. Provider
discovery prepends the app bundle's own directory to the search path, so
shipping it inside the bundle would give every install an
auto-discovered SSH-deploy capability and quietly undermine the "Only
use providers from trusted sources" warning the desktop already shows.
It installs to `~/.local/bin`, which discovery already covers.

115 tests, no `unsafe`, no `unwrap`/`expect` on a production path.
`base64` inherits from the workspace, matching the `sha2` line beside it
and the comment above both, which already claimed the inheritance.

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
The host contract in docs/remote-agents.md is short, but two of its items fail
in ways that do not look like misconfiguration. Without `loginctl enable-linger`
the user manager is torn down when the last session ends — so the agent is
killed the moment the deploy's own SSH session closes, and the symptom is "the
agent keeps dying", not "lingering is off". Without lingering there is also no
/run/user/$(id -u), so every `systemctl --user` call fails at the bus before it
reaches a unit. This script turns both into a row in a table you read before
deploying instead of a mystery you debug after.

It fixes what it safely can (enables lingering, creates ~/.local/bin, appends
the PATH export to ~/.profile only when absent) and installs nothing. Harness
CLIs carry their own installers and their own interactive logins, which cannot
run over a non-interactive SSH deploy; buzz-acp and the buzz CLI the deploy op
itself resolves or pushes. Reporting them is useful, installing them here would
duplicate a code path that already exists and is tested.

Only three checks decide the exit code — lingering, ~/.local/bin on PATH, and a
reachable systemd --user bus — because only those three make a deploy fail.
Everything else, Tailscale included, is a note: Tailscale buys the desktop's
device picker, and plain SSH is the unchanged fallback, so a host without it is
correctly configured, not degraded.

Three details are deliberate and easy to "clean up" wrongly. It refuses to run
as root: the env file holding the minted nsec must land owned by the user whose
home already holds the harness credentials, and a root run would provision the
wrong home. It checks the systemd bus under the same missing-XDG_RUNTIME_DIR
assumption the deploy makes, since a check that only passes in a login shell
would pass on hosts the deploy then fails on. And it reads Tailscale's SSH
state from prefs rather than from `Self.sshHostKeys` in the status document —
that field is populated for peers and reads null when a node looks at itself,
so the obvious source reports a false negative on an SSH-enabled host.

POSIX sh, no jq, no bashisms: it has to run on a bare host, which is the one
place none of those are guaranteed. Idempotent — a provisioned host is a no-op.

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
The systemd instance, env-file path and returned `backend_agent_id` were
all derived from the agent's display name. A name is not unique: two agents
called the same thing on one SSH account collapsed onto one unit and one env
file, so the second deploy overwrote the first agent's minted nsec and
starting either record drove whichever identity was written last.

Key them on the agent's pubkey instead — the record's own primary key, and
the only stable identifier the payload can carry. The slug keeps the name as
its readable stem and appends a 12-hex fragment of the key, so
`systemctl --user status` still reads as the agent a human recognizes.

The payload field is required, not optional: falling back to the name on an
absent `pubkey` would reintroduce the collision silently, so deploy refuses
a payload without a 64-character hex key.

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
ExecStart= splits an unquoted value on whitespace, so a "buzz-acp path on the
server" naming a directory with a space in it — which the config schema
accepts — made systemd run the first word with the rest as arguments, and the
unit never started. The SSH resolution code already shell-quotes such a path;
systemd's command-line syntax is a different question.

Write the substituted path double-quoted, escaping backslash and double quote
first since systemd unquotes C-style escapes inside double quotes. The escape
pass filters through sed rather than editing in place, so the portability
argument that kept sed -i out still holds.

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
A remote agent runs arbitrary code by design, so the SSH user's own privileges
are the intended ceiling. Without NoNewPrivileges the harness can climb past
them through any setuid/setgid binary on the host, or through passwordless
sudo granted to that user. A VPS pilot exercised the harness, the Buzz CLI,
NIP-OA owner-reviewed draft creation and repository branch pushes with it set;
all remained functional.

Deliberately the whole hardening delta. ProtectSystem/ProtectHome belong in
this unit too, but an agent has no modeled workspace yet, so until writable
paths are something the protocol states those directives would be guessing at
which of the user's home an agent legitimately needs.

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
git-credential-nostr had no preflight row of its own. It is a third tool with
a third policy: deploy resolves it, never installs it, and writes the agent's
GIT_CONFIG_* block only when the host already has it — so a remote agent on a
host without it cannot push to a Buzz repository, with no deploy-time warning
and no error until the agent tries. Folding that into the non-fatal buzz CLI
check hid a different failure behind a different tool's status. Give it its
own row, still reported and still never mandatory.

Record the workspace gap as a known limitation while it is one. A local
managed agent runs inside Desktop's REPOS workspace; the unit here has no
WorkingDirectory at all, so a remote agent has no Buzz-native project
checkout and in practice operates in a separately provisioned one. Closing it
is a protocol addition — a workspace field the desktop states, rather than the
provider guessing which project to clone.

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
…ctive SSH

A non-interactive `ssh host sh -s` reads no profile. On stock Debian that
leaves PATH at `/usr/local/bin:/usr/bin:/bin:/usr/games`, which does not
contain `~/.local/bin` — where every Buzz tool installs, and where `pipx`
and `npm --prefix=~/.local` put the harness adapters.

Every lookup that went through a bare `command -v` therefore answered for
the wrong PATH. On a host with all twelve adapters installed, `discover`
reported the entire catalog as `available: false`, and the deploy that
followed refused the operator's pin with exit 91, "harness not found".

The env file already pinned `PATH="$HOME/.local/bin:$PATH"` for the unit,
so the contract was only half-applied: the spawned harness could see the
install destination, but the scripts that resolved the harness could not.
Prepend it in the scripts too, via one shared `install::PATH_PREAMBLE`,
so both sides ask the same question.

That also simplifies the env file's own PATH line. It now writes the
deploy shell's `$PATH` verbatim, which already leads with the install
destination — one prepend, in one place, instead of a second one that
could drift from the first.

`resolve` is unchanged: it tests `-x ~/.local/bin/<tool>` explicitly
because it must also answer for an absolute configured path.

Covered by two tests that run the generated scripts against a real
`/bin/sh` with the stock Debian PATH and an adapter installed only to
`~/.local/bin` — the reported case, in both the discover and the deploy
direction. Both fail without the preamble.

The provisioning preflight gets the same prepend for the same reason,
so it stops reporting a tool MISSING on a host where the deploy finds it.

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
The `~/.local/bin` row was mandatory and asked whether the directory was
on the PATH of the process running the check. Over a non-interactive
`ssh host /path/to/provision-buzz-host.sh` it never can be: ~/.profile is
read by a login shell, so the check always landed in the NOTE arm and a
fully provisioned host exited 1 against its own preflight.

The strict form was not load-bearing. Nothing in the deploy path consults
the login PATH — the deploy composes the unit's PATH itself, and both
remote scripts prepend the install destination before they resolve
anything. What is worth requiring is that the directory exists and that a
login shell on the host is configured to find it, which the ~/.profile
arms already guarantee.

So those arms report OK and carry the difference in the ACTION column:
whether a re-login is still owed. A host with neither the PATH entry nor
the ~/.profile line still gets the line appended and still says so.

Exit semantics are otherwise unchanged: a genuinely unsatisfied mandatory
row — no lingering, no user bus — still exits 1.

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
docs/remote-agents.md §Info fixes the provider contract at protocol_version 1
and requires an integer. §Discovery makes it load-bearing rather than
informational: the desktop's pre-secret negotiation gate stages the provider
binary, calls info on the staged bytes, and must validate an explicit supported
protocol_version before it sends a request carrying private_key_nsec. Absence is
an error there, not a presumed 1 — so a provider that omits the field is not
degraded, it is undeployable.

Emit it as protocol::PROTOCOL_VERSION, distinct from CARGO_PKG_VERSION: the
software version says nothing about wire compatibility. The test asserts the
JSON type as well as the value, because a string "1" would fail the gate exactly
like an absent field.

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
…t the payload

docs/remote-agents.md §Deploy, Step 0: "the provider MUST parse
private_key_nsec and derive the public key from it; a malformed or undecodable
key is an immediate in-band error. Every selector, name, and comparison below
uses the *derived* pubkey — never a caller-supplied one."

The provider was taking the pubkey straight off the payload, which is the
caller-supplied identity the spec rules out. It matters here because that value
is not decorative: the first 12 hex characters of it name the systemd unit, the
env file and backend_agent_id. A payload whose pubkey and nsec disagreed would
name every host-side artifact for one identity while the harness authenticated
to the relay as another — an agent that looks deployed and is permanently
unreachable, which is the exact failure the fail-closed nsec check already
exists to prevent.

New identity module: bech32-decode the nsec (rejecting a wrong hrp, so an npub
in the private key's field is caught by name), take the secp256k1 x-only public
key, and reconcile against the payload's pubkey if it carried one. The derived
value always wins and is never replaced by the assertion; a well-formed
assertion for a different key is a fatal in-band error. Decoded key bytes are
Zeroizing, and no error string echoes any part of a key — both pubkeys are
truncated to a 12-character fragment, since the response is persisted in the
desktop's last_error.

Test fixtures now use real nsec/pubkey pairs, because an arbitrary pubkey
beside an unrelated nsec no longer parses at all — which is the point.

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
Requested on PR block#3449: a deploying user whose host keys do not live at
~/.ssh/known_hosts — a shared or generated file, or one kept per-fleet rather
than per-user — had no way to point the provider at it, and copying entries
across is not a fix.

Optional ssh_known_hosts_file in SshConfig and the info config_schema,
forwarded as `-o UserKnownHostsFile=<path>`. It selects which file the host key
is checked against and nothing else: StrictHostKeyChecking is untouched, so a
key missing from the named file fails exactly as one missing from the default
would. This is not a way to relax host-key trust.

Left unset, the option is not passed at all — the test asserts the argv is
byte-identical to the pre-existing one, since an optional field that perturbs
the default connection is not optional.

Named to survive the desktop's validate_provider_config, which word-splits a
config key and silently drops anything containing `key`. The obvious
ssh_host_key_file spelling would arrive as absent with no error anywhere, the
same trap that made the identity field ssh_identity_file rather than
ssh_key_path; both names are now pinned by the schema test.

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
…e the key

Self-review of the Step 0 work found three things worth correcting.

The length check was `if bytes.len() != 32` followed by
`try_from(...).expect("length checked above")` — a panic in a production path,
which this repo forbids, and one whose safety depended on two separate
statements staying in sync. Taking `try_from` as the fallible conversion makes
the panic-free property structural instead of asserted.

secp256k1::SecretKey does not zeroize on drop; it exposes an opt-in
`non_secure_erase`, which this now calls. The module doc previously implied
guaranteed erasure — it is corrected to what the code actually achieves, which
is best-effort hygiene on a value that lives for microseconds. Overclaiming in
a comment about key handling is worse than not commenting.

`fragment` slices by byte index and is only safe because `char_indices` yields
char boundaries. It is reached with hex today, but a helper must not depend on
its caller's validation to avoid panicking, so a test pins it against
multi-byte input.

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
Signed-off-by: Fredrik Jonsson <jonssfredrik@gmail.com>
@jonssfredrik
jonssfredrik requested a review from a team as a code owner August 16, 2026 15:16
Signed-off-by: Fredrik Jonsson <jonssfredrik@gmail.com>
@jonssfredrik

Copy link
Copy Markdown
Author

Synced this branch with current main and performed a fresh self-review. The review found that the provider-side defense-in-depth copy of RESERVED_ENV_KEYS had drifted after recent Desktop changes; commit 0e613b8ea updates it to match the canonical Desktop list and updates the regression test. Verified at that exact commit: cargo test -p buzz-backend-ssh (133/133), cargo clippy -p buzz-backend-ssh --all-targets -- -D warnings, release build, bash -n scripts/provision-buzz-host.sh, and git diff --check all pass. DCO is green. The PR still needs an upstream maintainer review/merge.

@themiguelamador themiguelamador left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I found several release-blocking correctness and security issues. I fixed all of them on Complear:review/pr-6052-fix at 3d858fd7a1efdc46919229a5799588b83549bc58.

Findings:

  • P0 — every current Desktop launch payload is rejected. Desktop always emits reserved BUZZ_ACP_RELAY_OBSERVER and may emit BUZZ_ACP_DISPLAY_NAME in launch.policy_env, while the provider allowed only BUZZ_ACP_AGENTS. A normal deploy therefore failed before opening SSH. The fix distinguishes Desktop-owned policy output from user env while continuing to reject identity/access-key forgery.
  • P1 — documented binary push was never wired from Desktop. The provider and docs consume agent.buzz_acp_binary / agent.buzz_cli_binary, but build_deploy_payload never serialized the documented BUZZ_ACP_PUSH_BINARY / BUZZ_CLI_PUSH_BINARY values. Fresh-host dogfood deploys therefore always failed at missing buzz-acp. The fix wires both paths at the deploy boundary with an environment-independent unit test.
  • P1 — clean shutdown and inactivity expiry were undone by systemd. Restart=always resurrected an owner !shutdown or clean inactivity exit after five seconds. The unit now uses Restart=on-failure, preserving crash recovery and clean lifecycle controls.
  • P1 — one host-wide unit template caused cross-agent executable mutation. Two provider definitions targeting the same host with different buzz_acp_path values rewrote the same buzz-acp@.service; a later restart silently moved an existing agent to the other definition's executable. Deploy now materializes one concrete unit per agent, with a two-agent regression test.
  • P1 — concurrent first installs could overwrite the shared fleet binary. The old check-then-mv sequence let racing desktops install different payloads to the same path, with the last writer replacing the first. Installation now uses an atomic same-filesystem hard-link create that cannot overwrite a winner, and rejects directories that merely satisfy -x.
  • P1 — concurrent starts of one record shared .new files. Env and unit writes could interleave, including the nsec-bearing env file. PID-scoped temporary names, cleanup traps, and atomic renames make concurrent deploys complete-file last-writer-wins updates.
  • P1 — host-resolved paths could corrupt env/unit syntax. Harness, PATH, credential-helper, Claude CLI, and buzz-acp paths were written without EnvironmentFile escaping; ExecStart also failed to escape systemd % specifiers. Control characters now fail before the secret directory is created, quoted values are escaped, and literal % survives systemd parsing.
  • P2 — 48-bit unit identity was unnecessarily collision-prone. A 12-hex pubkey prefix selected the env file containing an nsec. The complete 64-character derived pubkey now identifies the unit and fits comfortably within systemd's limit.
  • P2 — malformed wire values silently fell back or disappeared. Non-object launch, missing resolved commands, malformed args/env/policy maps, invalid numbers, ports, access modes/pubkeys, config strings, and model-probe values were variously dropped or replaced with defaults. The provider now fails closed while preserving argument bytes exactly; surrounding nsec whitespace is canonicalized consistently for derivation and runtime emission.
  • P2 — local Tailscale discovery could consume the outer operation budget. tailscale status --json had no timeout, so a wedged daemon could hang an info or deploy call. It now has an internal two-second kill budget and bounded output handling.
  • P2 — the Tailscale picker offered guaranteed-failure hosts. macOS, Windows, phone, TV, and unknown peers were offered even though this binding always provisions systemd --user. Only Linux peers are now offered; manual SSH remains unchanged.
  • P2 — the claimed hermetic test suite failed on macOS. Generated-shell tests assumed Linux sha256sum; the fake host now supplies the Linux spelling through macOS shasum.
  • Minor — provisioning could treat a comment mentioning .local/bin as PATH configuration. The preflight now requires an active PATH assignment. Installation and lifecycle documentation was updated to match the implemented behavior and remove stale limitations already solved by the resolved launch block.

Verification:

  • cargo test -p buzz-backend-ssh142 passed
  • cargo clippy -p buzz-backend-ssh --all-targets -- -D warnings — passed
  • bash -n scripts/provision-buzz-host.sh — passed
  • git diff --check — passed
  • just test-unit — seven suites passed (buzz-core, buzz-auth, buzz-cli, buzz-db, buzz-conformance, buzz-push-gateway, buzz-backend-kubernetes); buzz-voice could not build because sherpa-onnx-sys timed out downloading its macOS archive from GitHub.
  • Focused Desktop Rust test — blocked before crate compilation by the same sherpa-onnx-sys archive download; an earlier attempt also reached the checkout's missing required Tauri sidecar resource (desktop/src-tauri/binaries/buzz-acp-aarch64-apple-darwin). The edited Rust file was formatted by the repository pre-commit hook.

Please incorporate the fix commit (or equivalent changes) before merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants