Skip to content

feat: remote agents over SSH — provider contract, buzz-backend-ssh, and where-runs-first - #3449

Open
troyhoffman-oss wants to merge 12 commits into
block:mainfrom
troyhoffman-oss:feat/remote-agents
Open

feat: remote agents over SSH — provider contract, buzz-backend-ssh, and where-runs-first#3449
troyhoffman-oss wants to merge 12 commits into
block:mainfrom
troyhoffman-oss:feat/remote-agents

Conversation

@troyhoffman-oss

Copy link
Copy Markdown

Summary

  • Specify the BackendKind::Provider wire contract that managed_agents/backend.rs already describes, and ship buzz-backend-ssh as its reference implementation
  • Move "where does this agent run" to the top of the create dialog, and make the harness list, the model list, and the credential contract read from that answer instead of assuming this computer
  • Add a read-only Remote servers section under Settings > Agents, with a typed recovery channel so a host failure the user can fix renders a button
  • Add a provision-buzz-host.sh preflight and Windows CI coverage for the provider crate

Fixes #3034.

Why

managed_agents/backend.rs has described BackendKind::Provider for a while: the desktop finds a buzz-backend-<id> executable on PATH, writes one JSON request to its stdin, reads one JSON response from its stdout. #2859 states the gap plainly — that path "has no OSS implementation," so the seam exists and nothing exercises it. #3034 is the user-facing shape of the same gap.

Two things follow from filling it. The first is a provider: crates/buzz-backend-ssh implements the five ops over SSH, one process per op, no daemon and no state. The second is that the desktop has to stop answering questions about a remote record out of this computer's catalog — the harness list, the model catalog, the credential contract, and the avatar precedence chain all change meaning once the host can be somewhere else. That is the bulk of the desktop diff, and it is why the create dialog's question order changes: every question below "where" is scoped by the answer to it, so asking last means answering the dependent questions against the wrong machine.

The contract is specified in docs/remote-agents.md and lands in the first commit, ahead of the code, so the design can be judged before a line of Rust.

Reading order

154 files, +18,069/−1,413 — 12,453 lines of production code, 4,686 of tests, 930 of markdown. 12 commits, each of which compiles and passes its tests standalone; review in order.

  1. d307c1d1d docs(remote-agents): specify the SSH provider contract — the five ops (info, check, discover_harnesses, probe_models, deploy), their schemas, the optional recovery key, the systemd unit and env-file contract, and the security invariants the implementation is required to hold. Everything after this is an implementation of this file.
  2. 1b14b6e2c feat(backend-ssh): add the SSH remote-agent provider crate — the reference implementation. Six modules, one protocol: protocol (wire types, the Secret that renders [REDACTED]), ssh (transport), tailscale (peer enumeration, re-auth URL), discover, install, deploy. One commit because they are one protocol implementation and do not separate — deploy reaches into install at 43 call sites, and discover/deploy are mutually dependent. 115 tests.
  3. 204ecf55b feat(desktop): discover and invoke backend providers on PATH — splits backend.rs into what a provider may make the desktop do (provider_recovery.rs) and which files on PATH are providers at all (provider_discovery.rs). invoke_provider returns ProviderFailure, not String; there is deliberately no From<ProviderFailure> for String, because flattening the recovery away is the bug the type exists to prevent.
  4. 489c1a59d feat(desktop): build a remote deploy payload from a provider record — every value the desktop derives from its own machine is wrong for a remote agent. Adds discover_harnesses and probe_models, and normalizes remote models through the same normalize_agent_models the local path uses so the picker needs no remote-specific rendering.
  5. c8ec0df04 feat(desktop): scope persona runtime sync to local records — the one behavioral change to existing desktop code in this series. A persona sync used to clear a provider-backed record's harness pin, because the local "is this a known runtime?" test cannot answer a question about another machine's filesystem.
  6. cf22ca946 feat(desktop): type and bind the backend-provider Tauri surface — the TypeScript half. Remote vocabulary gets its own backendProviderTypes.ts and tauriBackendProviders.ts rather than being added to the local-runtime modules next door; types.ts re-exports so existing importers are unaffected.
  7. 9ec77b7b6 feat(desktop): ask where an agent runs before anything else — grows your WhereToRunSection (126 lines) and whereToRunIntent (46) along the seam they already left: the section keeps owning "where", and every field the answer scopes now reads from it. "Where to run" moves to the top of the form; createRuntimeGate, useRemoteAwareModelDiscovery, and createGateHarnessId make the rest a function of it.
  8. dd04b629d feat(desktop): one agent per exclusive host identity — an exclusive catalog entry may back at most one agent, because it names a persistent identity on the host rather than an ephemeral runner. pinnedHarness is the single owner of "may this surface read the record instead of the local catalog?"; liveness comes from relay presence, not the write-once deployed flag.
  9. e8552f932 feat(settings): permanent Remote servers section under Settings > Agents — read-only by design and with no host list: a provider is a binary on PATH, so adding one is an install, and the host is a per-agent decision the create dialog pins onto the record. This is the frontend end of recovery: {action: "open_url"}; the message always stands alone and the recovery only ever adds a button.
  10. b0a0cb231 ci(windows): run buzz-backend-ssh's tests on Windows — the crate's Windows-specific surface is path resolution (tailscale.exe candidates, ssh.exe lookup), which no Linux or macOS runner exercises. Goes in the existing windows-rust job, which already triggers on crates/**.
  11. 5cfd9924f test(desktop): cover provider-backed edit routing and the empty remote gallery — two Playwright pins for claims about which component mounts. The routing spec asserts both the positive and the negative, because the bug it pins was a silently wrong surface rather than an error.
  12. 2a495cc18 feat(scripts): add a remote-host provisioning preflight — two items in the host contract fail in ways that do not look like misconfiguration (missing loginctl enable-linger presents as "the agent keeps dying"), so the preflight checks and names them.

Security invariants

  • Secrets travel on the SSH stdin channel only — never an argv, a log line, an error string, or a Debug rendering. The remote ps is world-readable.
  • A deploy without the desktop-minted private_key_nsec fails closed, inside Agent::from_request, so no caller can route around it. Env values pass a control-character guard, because a newline would split the assignment into a second attacker-chosen line.
  • 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 confined to addresses the local Tailscale daemon already lists as peers; for anything the user typed, host-key checking stays strict. The re-auth URL is constructed, never parsed out of host output.
  • Provider execution on Windows goes through an exec allowlist.
  • 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 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 provider crate is deliberately not bundled with the desktop app. 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 undermine the "Only use providers from trusted sources" warning the desktop already shows. It installs to ~/.local/bin, which discovery already covers.

Verification

  • cargo test -p buzz-backend-ssh — 115 passed
  • cargo test --manifest-path desktop/src-tauri/Cargo.toml — 1,890 passed
  • Desktop unit suite — 4,042 passed; tsc typecheck and Biome clean
  • pnpm test:e2e:smoke for the two new specs
  • shellcheck on scripts/provision-buzz-host.sh
  • File-size ratchet (fix(ci): ratchet file sizes against the base tree #3352) clean against the base tree
  • Every commit compiles and passes its own tests standalone (12/12), and every commit carries a Signed-off-by
  • No unsafe; no new unwrap/expect on a production path

Relationship to open work

Follow-ups

  • Protocol v1 has no undeploy op. Deleting a persona cascade-deletes its instances, but a provider-backed instance is a deployed unit this app can forget and not stop — so the cascade discloses the running remote units and refuses without an explicit force_remote_delete opt-in, rather than silently orphaning them.
  • Removing a provider-backed persona from a team still refuses silently. That is pre-existing behavior on the team path, unchanged here, and worth a separate fix.

Buzz already defines a remote-execution seam: `managed_agents/backend.rs`
describes `BackendKind::Provider`, where the desktop discovers a
`buzz-backend-<id>` executable on PATH, writes one JSON request to its
stdin, and reads one JSON response from its stdout. block#2859 states the gap
plainly — that path "has no OSS implementation."

This document specifies one, over SSH, ahead of the code that implements
it. Everything that follows in this branch is an implementation of this
file, so it lands first: a reviewer can judge the design here before
reading a line of Rust.

It covers the five ops (`info`, `check`, `discover_harnesses`,
`probe_models`, `deploy`) and their request/response schemas; the
optional `recovery` key, which is optional in both directions so an old
desktop and a new provider still interoperate; the configuration surface
and host prerequisites; the systemd unit and the env-file contract; the
install ordering; and the security invariants the implementation is
required to hold — secrets never reaching a log, an argv, or a `Debug`
rendering, a deploy without a desktop-minted key failing closed, and
trust-on-first-use being confined to addresses the local Tailscale
daemon already lists as peers.

Signed-off-by: Troy Hoffman <[email protected]>
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 <[email protected]>
`backend.rs` carried three separable concerns: what a provider is allowed to
make the desktop *do*, which files on PATH are providers at all, and invoking
one. This splits the first two out and gives provider failures a type that can
carry an actionable recovery.

- `provider_discovery.rs` — "is this file a provider I may execute?": the
  `buzz-backend-` prefix, the executability check, id validation, and
  `resolve_provider_binary`. Pure code motion out of `backend.rs`; every item
  moves with its doc comments and its tests, and the `managed_agents::*`
  re-export keeps callers' imports unchanged.
- `provider_recovery.rs` — `ProviderFailure` / `ProviderRecovery` and the
  validation deciding what a discovered subprocess may ask for. Today the one
  recovery is a Tailscale re-auth URL, and it is validated on the way in rather
  than at the opener, so an unvalidated URL never exists in desktop memory at
  all. `from_response` is `pub(super)` so `invoke_provider` stays its only
  caller and no second, unguarded construction path can appear in the crate.

`invoke_provider` now returns `ProviderFailure` rather than `String`. There is
deliberately no `From<ProviderFailure> for String` — that is the type-level
guard against a caller flattening the recovery away, which is the bug the type
exists to prevent. A caller with nothing to offer therefore has to say so:
`probe_backend_provider` takes `.message` explicitly, because `info` is a probe
whose failure has no action behind it.

A provider's stderr on a *successful* op is also no longer dropped. Providers
write their non-fatal complaints there, and `provider_stderr_notice` gives the
success path (which logs) and the failure paths (which fold it into the error)
one shared cap, so the snippet a warning shows and the snippet an error reports
are the same text.

Signed-off-by: Troy Hoffman <[email protected]>
A remote agent's harness lives on the host, not on this computer, so every
value the desktop derives from its own machine is wrong for one. This assembles
the payload from the record instead.

- `runtime/setup_payload.rs` extracts payload construction out of `runtime.rs`,
  which was deriving it inline from local runtime state. The remote case needs
  the same shape built from different inputs, and a function with named inputs
  is the seam that makes both readable.
- `create_time_agent_args` (in `discovery/overrides.rs`) splits create-time
  argument derivation away from the update path. They looked interchangeable
  and are not: at create time the harness choice arrives as an explicit pin
  from the provider's `discover_harnesses` catalog, with no prior record to
  reconcile against.
- Two new provider ops behind the same contract: `discover_harnesses` asks the
  host what is installed there (the local `KNOWN_ACP_RUNTIMES` probe describes
  THIS computer and says nothing about the host), and `probe_models` returns
  one harness's model catalog. `probe_models` carries its env under the
  `env_vars` key specifically because that is the only key `invoke_provider`
  scrubs from error surfaces — anywhere else and a provider could echo a
  credential back through an error string unredacted.
- `probe_provider_models` normalizes through the same `normalize_agent_models`
  the local path uses, so the model picker needs no remote-specific rendering.
  That reader now prefers the schema's `name` field, falling back to the
  pre-standardization `displayName` some adapters still emit.
- `CLAUDE_CODE_EXECUTABLE` joins `RESERVED_ENV_KEYS`: it selects which binary
  the harness runs, so a user-supplied value would silently override the
  harness pin the deploy payload just resolved.

The two provider commands return `ProviderFailure` rather than `String` so a
failure carrying a recovery keeps it to the frontend; failures with nothing to
offer convert through `.into()` and simply have none.

Signed-off-by: Troy Hoffman <[email protected]>
A persona sync used to overwrite every linked instance's harness the same way.
That is right for a local record — the definition is the authority and a pin
naming a runtime the definition no longer asks for is stale local preference —
and wrong for a provider-backed one, whose `agent_command_override` names a
harness on a remote host. The local "is this a known runtime?" test cannot
answer a question about another machine's filesystem, so applying it there
silently cleared a pin the user chose from the host's own catalog.

`apply_persona_snapshot` is now backend-scoped: a provider record keeps its
remote harness pin through a sync while still taking the definition's
model/provider quad; a local record keeps today's behavior. This is the one
behavioral change to existing desktop code in this series.

Alongside it, persona deletion learns the same distinction. Deleting a persona
cascade-deletes its instances, but a provider-backed instance is a deployed
unit this app can forget and not stop — the provider protocol has no undeploy.
`preflight_remote_deployed_cascade` makes that asymmetry explicit: the cascade
is refused unless the caller passes `force_remote_delete`, mirroring
`delete_managed_agent`'s existing flag, and the refusal names the instances. An
IPC caller that never learned about the flag sees the error it saw before.

`delete_persona` and its helpers move to `personas/delete.rs`, and the
backend-scoped snapshot cases to `persona_events/tests/harness_pin.rs` — both
sibling extractions in the house pattern, keeping the two touched files under
the size ratchet rather than growing them.

Signed-off-by: Troy Hoffman <[email protected]>
The TypeScript half of the provider contract: the vocabulary and bindings
the UI reads, split from the local-runtime surface rather than added to it.

Everything a backend provider answers is a question about a REMOTE machine,
and the modules next door answer the same questions about this one. Conflating
the two is precisely the mistake that makes an agent silently deploy against
the wrong host's harness catalog, so the remote vocabulary gets its own
`backendProviderTypes.ts` and its own `tauriBackendProviders.ts`. `types.ts`
re-exports the four remote types so existing importers are unaffected.

Note the `tauri.ts` change is a net shrink of a file that was already at its
size ceiling: the two provider bindings move out and nothing moves in, so a
file the ratchet had pinned at 1,206 lines ends at 1,188.

Two wire-boundary decisions carry their reasoning in the tests:

- `fromRawRemoteHarness` only carries `exclusive` when the provider asserted
  it. Absent is the default and every consumer reads it as "no limit", so
  spreading a `false` onto every other entry would have the desktop claiming
  something the provider never said.
- `providerRecoveryOf` reads the structured `{message, recovery}` payload off
  `TauriInvokeError` instead of parsing human-readable error text. It returns
  `null` for every other error shape, which is the common case — a provider
  failure without a recovery is an ordinary failure and renders as its
  message alone. This is the frontend end of the `recovery: {action:
  "open_url"}` key on the provider wire protocol.

`backendProviderHooks.ts` is its own module rather than another block in
`hooks.ts` for the same reason: providers are the one axis of the agent
surface that is about WHERE an agent lives, and the three surfaces that ask
share nothing else in that file. The per-provider `info` probe is deliberately
not folded into provider discovery — discovery is a PATH walk cheap enough for
any surface to run on mount, while probing spawns each binary, so only the
surface whose entire subject is the providers pays for it.

Signed-off-by: Troy Hoffman <[email protected]>
`WhereToRunSection` and `whereToRunIntent` are your components -- 126 and 46
lines on main. This commit grows them along the seam they already left: the
section keeps owning "where", and every field the answer scopes now reads from
it instead of assuming this computer.

The create dialog asked "where" last, or not at all. That ordering is wrong
once a host can be somewhere else, because every question below it is scoped by
the answer. The harness list comes from the chosen machine's catalog, and the
model list comes from that harness -- so asking at the end means answering the
dependent questions against the wrong computer and silently re-scoping them.
"Where to run" moves to the top of the form and the rest of the dialog becomes
a function of it:

- `createRuntimeGate` holds the local catalog's authority. A remote create must
  not require a locally installed harness (that would make every remote-only
  harness unsubmittable) and must not disable an "unavailable" option --
  availability describes the wrong machine.
- `useRemoteAwareModelDiscovery` routes discovery to whichever machine will run
  the agent. A picked remote harness REPLACES local discovery rather than
  merging with it; the local catalog answers for a computer the agent is never
  going to run on.
- `createGateHarnessId` decides whose credential contract applies. The deploy
  writes env on the host keyed off the remote command, so the local runtime id
  names the wrong contract.
- `createRunSection` becomes a render prop so the host's model probe can carry
  this dialog's unsaved credential env, which the global layer does not have yet.

`agentAiConfigurationPolicy` makes a typed model that the catalog does not know
block Save instead of silently resolving to the adapter's default at runtime.

This also fixes block#3220 more strongly than the fix proposed there: the `draftRef`
spread in `WhereToRunSection` keeps the section's draft authoritative across the
remount, so a rapid edit cannot be clobbered by an in-flight probe. Cited rather
than absorbed -- close block#3220 or land both, your call.

Extracting `AgentLlmProviderField` and `AgentDefinitionIdentityFields` keeps
`AgentDefinitionDialog` under its size cap; `shouldRenderModelControl` moves
beside `modelFieldStatus`, since both answer what the model catalog's state
means for the model control.

Signed-off-by: Troy Hoffman <[email protected]>
A remote agent's identity is its host. Once an agent can run somewhere else,
every surface that shows one has to say where -- and the app has to stop
answering questions about a remote record out of this computer's catalog.

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. Deploying `claude` N
times to one host is the point; two agents on one profile are two puppeteers on
one body. `exclusiveRemoteHarness` decides "already taken" generically (same
provider, same config, same resolved command+args), and the picker renders a
taken entry disabled with an "(added)" suffix rather than inventing a badge.
Nothing in the desktop knows what Hermes or a profile is.

`pinnedHarness` is the single owner of "may this surface read the record
instead of the local catalog?". It answers null for a local agent, whose
catalog entry genuinely describes it and whose rendering does not change. For a
provider record every local lookup is either a miss (a Hermes agent describing
itself as "Buzz Agent") or a name collision, so the record answers from itself:
its own harness derivation, its own avatar precedence chain, and its own
editing route -- the instance dialog, never the definition one, since
`to_definition_view` drops `backend` and `agent_command` by design.

Liveness comes from relay presence, not a local "deployed" flag. That flag is
written once on a successful deploy and never cleared -- the provider protocol
has no undeploy -- so it means "this desktop deployed it" and keeps meaning
that after the remote process dies. `managedAgentPresenceStatus` keeps the
control plane authoritative for a local record and defers to relay presence for
a provider-backed one, the same channel the delete path already trusts.

Deleting a persona now names what it cannot stop. A cascade over
provider-backed instances leaves their remote units running, so the confirm
dialog lists them and the backend refuses the cascade without that
acknowledgement -- an honest delete rather than a silent orphan.

`agentRunsOnLabel` names the PROVIDER, never the host: a provider id has a
bounded shape and a host does not, and reading `backend.config` would grow a
host vocabulary per provider.

Signed-off-by: Troy Hoffman <[email protected]>
Harnesses on this machine, then the machines that are not this one. Remote
servers sits under Agents rather than Compute because it is about where an
AGENT is hosted; Compute is about model serving.

The card 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 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 (every other surface renders
ids); `"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 a row that spins forever.

This is also the frontend end of the `recovery: {action: "open_url"}` key the
provider contract introduces: a host failure the user can fix carries a typed
recovery, and "Authenticate in browser" is the button it renders. The message
always stands alone -- the recovery only ever ADDS a button, so a provider that
sends none 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.

Onboarding gains the same vocabulary rather than a second one. 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.

Signed-off-by: Troy Hoffman <[email protected]>
The SSH provider is a standalone binary a user puts on their own PATH, on
every desktop platform Buzz ships to. Its Windows-specific surface is path
resolution — `tailscale.exe` candidate directories, `ssh.exe` lookup — plus
JSON framing, and none of that is exercised by a Linux or macOS runner.
Untested path resolution on Windows is a shipped bug, so gate it in CI.

The step goes in the existing `windows-rust` job, which already triggers on
`crates/**`, so no new job and no new trigger surface — roughly 40s added to
a job that already runs on every Rust change. Unlike the `buzz-dev-mcp` step
above it, these tests need no `--test-threads=1`: the crate's tests mutate no
process-global environment. The shell-script execution tests are `#[cfg(unix)]`
and cover the remote side, which is always POSIX.

`scripts/run-tests.sh` gets the same crate in its unit-test set. The deploy
tests run the generated provisioning script against a local `/bin/sh` with a
stubbed `HOME`, so they need no Postgres, no Redis, and no remote host — and
they are the only place the shell-injection canary actually executes.

Signed-off-by: Troy Hoffman <[email protected]>
…e gallery

Two end-to-end pins for claims the unit tests cannot reach, because both are
about which component mounts rather than what a function returns.

The first is a routing pin. Every provider-created agent carries a personaId,
and the profile panel's Edit action used to read that as "this is a persona, so
open the DEFINITION dialog". That dialog projects an AgentDefinition, which has
no slot for `backend` or `agent_command` — so a remote agent opened on a blank
harness, this computer's local default was re-seeded over the record's own pin,
and Save was then blocked behind a provider + API-key demand for a machine the
agent does not run on. The spec asserts the positive (the instance dialog opens
showing the record's own pinned harness) and the negative (the definition
dialog never mounts), because the bug was a silently wrong surface, not an
error — an assertion that only checked "a dialog opened" would still pass.

The second covers the Remote servers card with no provider installed. The mock
bridge answers `discover_backend_providers` with `[]`, which is also the
overwhelmingly common real case: a provider is a separate binary that nothing
bundles, so the teaching empty state is the copy most users will ever see.
The spec additionally asserts the loading branch has settled to zero rather
than merely that the empty state rendered — those are different failures, and
only the second is a bug.

Both go in the `smoke` project. `edit-agent-provider-routing.spec.ts` needs a
`testMatch` entry; the harness-management case joins a file already listed.

Signed-off-by: Troy Hoffman <[email protected]>
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 <[email protected]>
@troyhoffman-oss
troyhoffman-oss requested a review from a team as a code owner July 29, 2026 02:13

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2a495cc189

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Key remote units by agent identity

When two agents with the same display name are deployed to the same SSH account, this produces the same slug, systemd instance, environment-file path, and backend_agent_id for both. The second deployment therefore overwrites the first agent's nsec and configuration, and starting either record controls whichever identity was written last. Derive the suffix from the stable agent pubkey or another unique record identifier rather than the non-unique name.

Useful? React with 👍 / 👎.

};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if let Some(id) = name.strip_prefix(prefix) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Remove the Windows executable suffix from provider IDs

On Windows, Cargo installs this binary as buzz-backend-ssh.exe, so this derives the provider ID ssh.exe. Creation then passes that ID to resolve_provider_binary, whose [a-z0-9][a-z0-9_-]* validation rejects the dot, making the new SSH provider unusable from the Windows desktop despite its Windows-specific support. Strip .exe before deriving and deduplicating the ID.

Useful? React with 👍 / 👎.

template=$(cat <<'BUZZ_UNIT_EOF'
{unit}BUZZ_UNIT_EOF
)
printf '%s\n' "${{template%%@BUZZ_ACP_BIN@*}}$acp${{template#*@BUZZ_ACP_BIN@}}" > "$unit_file.new"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Quote the resolved executable in ExecStart

When the optional buzz_acp_path resolves to an executable whose absolute path contains whitespace, this inserts the path verbatim into ExecStart, where systemd parses the remainder as separate arguments instead of part of the executable name. The schema accepts such paths and the SSH resolution code correctly shell-quotes them, but deployment then fails to start the unit; escape or quote $acp using systemd's command-line syntax before writing the template.

Useful? React with 👍 / 👎.

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.

[Bug] Provider-backed agents display local Claude config instead of remote runtime and expose env values

1 participant