From d307c1d1d3ef4c0dd004313fce2e9b599989e69a Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Wed, 29 Jul 2026 01:55:05 +0200 Subject: [PATCH 01/19] docs(remote-agents): specify the SSH provider contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buzz already defines a remote-execution seam: `managed_agents/backend.rs` describes `BackendKind::Provider`, where the desktop discovers a `buzz-backend-` executable on PATH, writes one JSON request to its stdin, and reads one JSON response from its stdout. #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 --- docs/remote-agents.md | 622 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 622 insertions(+) create mode 100644 docs/remote-agents.md diff --git a/docs/remote-agents.md b/docs/remote-agents.md new file mode 100644 index 0000000000..5b3b4846c1 --- /dev/null +++ b/docs/remote-agents.md @@ -0,0 +1,622 @@ +# Remote agents over SSH + +A **remote agent** is a managed agent whose harness runs on another host. The desktop still owns +the agent: it mints the agent's nostr key, holds the record, and renders it beside local agents. +It does not own the process. On the host, `buzz-acp` runs as a `systemd --user` unit, and the only +liveness signal the desktop has is the agent's presence on the relay — there is no status op, no +polling channel, and no open connection between deploys. + +`buzz-backend-ssh` is the provider binary that puts it there. It is not bundled with the desktop: +`discover_provider_candidates` prepends the app bundle's own directory to the provider search path, +so shipping it inside the bundle would give every install an auto-discovered SSH-deploy capability +and quietly undermine the "only use providers from trusted sources" warning the create dialog +shows. Install it to `~/.local/bin`, which is already on the discovery path. + +## Provider protocol + +The desktop enumerates PATH (plus the executable's own directory and `~/.local/bin`) for files +named `buzz-backend-`, and resolves `` against `^[a-z0-9][a-z0-9_-]*$`. It spawns the +binary, writes one JSON request to stdin, closes it, and reads one JSON response from stdout. One +process per op; no daemon, no state, no version negotiation. + +`buzz-backend-ssh` implements five ops. + +| op | opens SSH | provider budget | desktop budget | desktop caller | +|---|---|---|---|---| +| `info` | no | — | 10s | `probe_backend_provider`: the host field, and Settings → Remote servers to name and version each row | +| `check` | yes | 8s | — | none yet | +| `discover_harnesses` | yes | 40s | 60s | `WhereToRunSection`, on "check host" | +| `probe_models` | yes | 110s | 150s | `WhereToRunSection`, after a harness resolves | +| `deploy` | yes | 300s | 600s | `deploy_to_provider`, from create and from start | + +The provider budget always fires first, so a timeout arrives as a structured error rather than as a +killed child. Any other `op` value is rejected before a connection is opened, so a typo costs a +parse and not an SSH handshake. + +Every desktop-side entry point resolves the provider through `resolve_discovered_provider` before +spawning it, so a frontend or IPC caller that names a `binaryPath` cannot steer execution at an +arbitrary binary. + +`info` is the only op that runs before a host is configured — it is what produces the host field — +so it never opens a session and never requires `provider_config`. + +```json +{"op": "info", "request_id": ""} +``` + +```json +{"ok": true, "name": "SSH", "version": "…", + "description": "Run agents on a remote host over SSH, supervised by systemd --user.", + "config_schema": {"type": "object", "required": ["ssh_host"], "properties": {…}}} +``` + +`check` is a preflight: `echo buzz-ok` over the configured session. Failures are classified into +actionable guidance (`Permission denied` → `authorized_keys` / `tailscale set --ssh`, +`Host key verification failed` → known_hosts, `Could not resolve hostname` → address or tailnet, +`Connection refused`/`timed out` → reachability). Anything unclassified passes through verbatim +rather than being flattened. + +`discover_harnesses` probes `buzz-acp` and every candidate harness in **one** generated `sh` +script. N sequential `ssh` invocations would spend the whole budget on handshakes over a +200 ms link and the harness picker would visibly hang. Two details in that script are load-bearing: +every probed child gets `/profiles/*/`, `HERMES_HOME` +honored and trimmed back to the root) in the same single script round trip, gated on `hermes` +resolving — `hermes profile list` is a human table with no `--json`, and the directory layout is +what Hermes itself resolves a profile against. Names are untrusted remote input on their way into an +id and an argv, so only `[a-z0-9][a-z0-9_-]*` (Hermes's own rule, and a subset of the desktop's +harness-id rule) is accepted; anything else is skipped whole rather than sanitized, and the count is +capped at 32 with the remainder logged to stderr. Absent Hermes, the catalog is byte-identical to +what it was before; present with a Hermes root but no `profiles/` store, it is the plain entry plus +`hermes-default`, since the root directory *is* the default profile; present with no Hermes root at +all, it is just the plain entry. + +Those per-profile entries carry `"exclusive": true`, the catalog's one statement about identity: the +entry names a persistent identity on the host — its own memory, sessions and credentials — rather +than an ephemeral runner. Deploying `claude` or the plain `hermes-acp` entry N times to one host is +the point; pinning two agents to *the same profile* is two puppeteers driving one body, so the +desktop refuses the second. Every other entry omits the key, and an absent key means "deploy as many +as you like" — the flag is the only hermes-aware thing here, and the desktop reads it generically +(`isExclusiveRemoteHarnessAdded`): an entry counts as already taken when an existing agent is backed +by the *same provider and provider config* and pinned to the *same command and args*, in which case +the harness picker renders it disabled with an "(added)" suffix and auto-pick skips it. Config +equality is exact (after trimming, dropping blanks and sorting keys), not host resolution, so +`10.0.0.4` and `vps.tail1234.ts.net` read as different hosts and the guard simply does not fire — +it under-matches rather than ever falsely blocking a create. Resolving aliases needs a +host-identity answer from the provider, which is the real fix rather than a normalization table in +the desktop. + +`probe_models` exports the harness env inside the script, then runs `buzz-acp models --json` on the +host and returns the document verbatim under `models_raw`. The desktop feeds it straight into the +same `normalize_agent_models` the local path uses, so the model picker needs no remote-specific +code. That host-side command carries its own budget inside the provider's 110s: `MODELS_TIMEOUT`, +60s, matched to what the normal agent-init path gives the same adapter spawn — a shorter one only +fails probes the real spawn would have survived, since a cold node adapter on a busy host takes +tens of seconds to reach `initialize`. Model env must be nested under `agent.env_vars` — that is the only place the desktop's +`env_secrets_from_request` scrubber looks. A flat `model_env` is accepted but loses that second +redaction layer. + +`deploy` provisions and starts the unit, and returns `{"ok": true, "agent_id": "buzz-acp@"}`. +The desktop persists `agent_id` in `record.backend_agent_id`. + +`deploy` also **verifies or installs** two host-side tools. When the payload carries the optional +path — a path on the *desktop* machine to a Linux binary — and the host resolves none, that binary is +installed to `~/.local/bin` inside the provisioning round trip, before anything else is written. +The fields are seams, not modes: there is no second op, no provisioning step, and no new UI state. +A payload carrying neither field sends no binary and opens no extra round trip — the script is the +one the crate has always sent, plus the CLI resolution block, which is unconditional because its +whole job is to notice a host that has no CLI. A test pins that script byte for byte. + +| payload field | tool | installed as | host has neither it nor a payload | +|---|---|---|---| +| `agent.buzz_acp_binary` | `buzz-acp`, the harness | `~/.local/bin/buzz-acp` | **exit 90** — the deploy stops | +| `agent.buzz_cli_binary` | `buzz`, the agent-facing CLI | `~/.local/bin/buzz` | a `WARNING:` line on stderr; the deploy **continues** | + +**The asymmetry is deliberate.** `buzz-acp` *is* the agent, so its absence is fail-closed. The `buzz` +CLI is what a remote agent's own system prompt tells it to reply with (`buzz messages send --reply-to +`, `buzz feed get`) — a local agent gets it because the desktop bundles it as a sidecar and +prepends its directory to the spawned harness's `PATH`. Without it a remote agent still runs; it just +cannot use the CLI, and in practice spends its first minutes hunting the filesystem for a command +that is not there. That is worth a warning and never worth failing a deploy over. Integrity failures +(exit 93/94) are fatal for **both**: a payload that arrives damaged is evidence the stream is damaged, +and that stream also carries the minted nsec. + +**Resolution is `PATH` *or* `~/.local/bin/`, never `PATH` alone.** A non-interactive SSH +command reads no profile, so `~/.local/bin` — the documented convention and the install destination — +is not on the ambient `PATH`. That is exactly why the unit's env file pins +`PATH="$HOME/.local/bin:$PATH"` itself (see the env file contract). A `command -v`-only rule would +therefore never see the copy a previous deploy installed: since deploy is the start path, every agent +start would re-stream tens of megabytes and swap the binary underneath a running fleet. The probe and +the deploy script apply the same two-part rule, so they cannot disagree. + +**Staleness rule: push-when-missing only.** A host that already resolves a tool keeps the binary it +has, whatever its version. Deploy is the start path, so a version-comparing rule would reinstall +underneath a running fleet on every start, and a desktop pinned to an older artifact would +*downgrade* the host. Refreshing an existing install belongs to the release-artifact follow-up below. + +**Setting either field costs one extra round trip, and only when one is set.** Deploy is the start +path, so embedding binaries unconditionally would stream tens of megabytes of base64 on every start of +every agent, forever, to hosts that were provisioned on day one. So when — and only when — at least +one field is present, `deploy` asks the host the resolution question above first, for both tools in a +single probe; anything the host already has is never even read from disk. The probe is an +optimization, never the decision: the deploy script re-checks on the host and installs only into an +empty variable, so a host that gains or loses a tool between the two round trips still lands correct. + +The install rides the SSH stdin channel with everything else, which dictates its shape: + +- **base64, not raw bytes.** The script is text; a NUL or a stray newline inside an ELF section + would corrupt the *script*, not just the payload. The encoded alphabet (`A-Za-z0-9+/=`) contains + no shell metacharacter and no `_`, so no data line can terminate a `BUZZ_ACP_B64_EOF` / + `BUZZ_CLI_B64_EOF` heredoc early. The delimiters are quoted as well, so the remote shell expands + nothing in either body, and they differ so one script can carry both. +- **sha256 before install.** The digest is computed on the desktop and travels in the script in the + clear (a fingerprint, not a credential); the host runs `sha256sum -c` against the decoded temp + file and aborts with exit 94 on a mismatch. Nothing is made executable before it verifies. +- **Atomic.** Decode goes to `~/.local/bin/..tmp.$$` — same directory as the target, so the + `mv` is a rename — then `chmod 755`, then `mv`. Every failure path removes the temp file first, so + no run leaves a half-written executable where `ExecStart` would name it. +- **`base64` and `sha256sum` must exist on the host** (coreutils). Their absence is exit 92 with a + clear message, never a silent skip of the integrity check. +- **The desktop refuses the payload before the session opens** when the path is missing, is not a + file, is empty, is over 200 MB, or is not an ELF binary — and the message names which tool it is + about. Pushing a Mach-O from a macOS desktop would otherwise install cleanly and restart-loop on + `Exec format error` every five seconds after a deploy that reported success. This holds for the CLI + too: a *missing* CLI is tolerable, but a desktop that pointed the seam at the wrong file has a bug + worth naming. +- **The secret discipline is untouched.** The pushed bytes are not secret, but they share the stream + with the minted nsec; base64 is what keeps them from corrupting it. `umask 077`, the `chmod 600` + env file and the "nothing secret on any argv" rule are unchanged. +- **Only `buzz-acp` reaches the unit.** `ExecStart` is substituted from the resolved harness path. + The CLI is reached purely through the env file's `PATH`, which is why installing it and pinning + that `PATH` are one change and not two. + +The fields are filled desktop-side from the `BUZZ_ACP_PUSH_BINARY` and `BUZZ_CLI_PUSH_BINARY` +environment variables, read at deploy time (`deploy_payload_json`), so a developer can point either at +a fresh build without restarting the app. Those are dev/dogfood seams, not the destination: the +release build should resolve the artifacts for the host's platform by version, with no user-visible +path at all. **Fetching release artifacts is out of scope here and is the immediate follow-up**, along +with the version-refresh rule that only becomes safe once the desktop knows which version it is +offering. + +Non-fatal host-side complaints — today, only the missing-CLI warning — reach the desktop on the +provider's **stderr**, prefixed `WARNING: ` and scrubbed by the same redactor the failure path uses. +`invoke_provider` writes them to the desktop log (`tracing::warn`) when the op succeeds, and folds +them into the error message when it fails. The op's JSON response is unchanged either way: the deploy +succeeded, and a warning is not a result. + +Errors are `{"ok": false, "error": "…"}` on stdout, human detail on stderr, and **exit 0 always**. +A non-zero exit makes `invoke_provider` discard stdout entirely and report raw stderr, which throws +the structured error away. + +A failure the user can act on may carry an optional `recovery` alongside `error`: + +```json +{ "ok": false, + "error": "this host requires Tailscale SSH authentication in a browser: https://login.tailscale.com/a/…", + "recovery": { "action": "open_url", "url": "https://login.tailscale.com/a/…" } } +``` + +`recovery` is optional in both directions, so there is no negotiation and no flag: a desktop that +does not read it still renders `error`, which names the problem and carries the URL as text, and a +desktop that does read it finds nothing there from an older provider. The only `action` today is +`open_url`, and the only URL is Tailscale's login host — the SSH provider **constructs** that URL +from a fixed prefix plus a charset-constrained token rather than parsing one out of remote output, +so no host, scheme, or query from the host can reach the browser opener. The desktop re-validates +the prefix anyway before opening, on the same "the provider is a subprocess, not a trusted peer" +footing as its secret re-redaction. + +The provider emits this when a tailnet ACL uses Tailscale SSH's `check` action, which makes `ssh` +print the URL and then block for a human that `BatchMode` cannot supply. It is detected by peeking +at buffered stderr during the poll loop, so the op fails in one 25 ms tick instead of burning its +whole budget (8 s for `check`, 300 s for `deploy`) and reporting a bare timeout. + +On the desktop, `invoke_provider` returns `ProviderFailure { message, recovery }` rather than a +`String`, and `ProviderRecovery::from_response` is where the URL is re-validated — on entry, so an +unvalidated one never exists in desktop memory at all and no later reader of the payload can become +a second, unguarded way to open it. There is deliberately no `From for String`: +that is the type-level guard against a caller flattening the recovery away, which is the one bug +this plumbing exists to prevent. The provider commands carry the type out to the frontend, which +reads it off `TauriInvokeError.payload` via `providerRecoveryOf`. + +Two paths drop the recovery **explicitly**, each at one named site, because their surface cannot +render an action: `start_managed_agent` (a toast) and `create_managed_agent`'s `spawn_error` (a +reported field of a succeeding create). Nothing is lost to the user there — the message names the +problem and carries the URL as text — but widening either needs its surface to grow an action +first. The agent record's `last_error` is a plain string for a different reason: it is read back +long after the fact, and an auth URL is a one-shot token that is stale by then. + +**Recovery is a manual retry.** The create dialog renders an "Authenticate in browser" button beside +the failure and nothing else: the desktop cannot tell when the user has finished authenticating in a +browser it does not own, so an auto-retry would be guessing at a delay. "Check the host again" is +already the retry, and it is the same button every other host failure offers. + +## Configuration + +`validate_provider_config` rejects any config key whose word-split contains +`secret`/`password`/`token`/`key`/`credential`, and drops it silently. That is why the identity +field is `ssh_identity_file` and not `ssh_key_path`. + +| key | required | notes | +|---|---|---| +| `ssh_host` | yes | hostname, IP, or `user@host`. Rejected if it starts with `-` or contains whitespace/control characters. Carries a `oneOf` of tailnet devices when one is available. | +| `ssh_user` | no | Ignored when `ssh_host` already contains `@`. | +| `ssh_port` | no | Number or numeric string, `1..=65535`. Default 22. | +| `ssh_identity_file` | no | Passed as `ssh -i`. Defaults to `~/.ssh/config` and the agent. | +| `buzz_acp_path` | no | Absolute path to `buzz-acp` on the host. Defaults to whatever is on the host's PATH. | + +There is no `unit_scope`. All deploys are `systemctl --user`. + +The `oneOf` is a **generic decoration, not an SSH feature**. Any provider may attach +`oneOf: [{ const, title }]` to any config property; the desktop renders a dropdown over the +`const` values labelled by `title`, always with an "Other…" row that swaps back to the plain +text field. Nothing in the desktop knows what a tailnet is, and a value the list does not +contain — one carried over from before the decoration existed, or a peer that has since left +the tailnet — stays in the text field rather than reading as unselected. Omit the `oneOf` and +the field is exactly the text input it was before. + +## Host prerequisites + +`scripts/provision-buzz-host.sh` checks all of these on a candidate host and prints what is +missing. It is a preflight, not an installer. + +1. **A non-root user.** The whole flow is root-free. The env file lands under that user's + ownership, beside the harness credentials that already live there (`~/.claude`, + `~/.config/goose`). + +2. **`loginctl enable-linger `.** This is the one non-obvious prerequisite. Without lingering, + the user manager is torn down when the last session ends, so the agent is killed the moment the + deploy's own SSH session closes — which reads as a flaky agent, not as a configuration problem. + Lingering also creates `/run/user/$(id -u)`, without which every `systemctl --user` call fails + to reach the bus. `deploy` runs `loginctl enable-linger` itself, before any bus traffic, but + best-effort: some hosts gate it behind polkit, and failing it must not fail an otherwise good + deploy. On those hosts, run it once by hand as root. + +3. **`buzz-acp` on the host's PATH or at `~/.local/bin/buzz-acp`** — `deploy` resolves both, since a + non-interactive SSH `PATH` does not contain the latter — or an absolute path in + `buzz_acp_path`. `discover_harnesses` reports its absence without failing. `deploy` installs it + when the desktop supplied one (`BUZZ_ACP_PUSH_BINARY`, see the `deploy` section) and otherwise + refuses. Installing it needs `base64` and `sha256sum` on the host — coreutils, present on any + normal Linux — and nothing else. + + **The `buzz` CLI is the same story with a softer ending.** Agents are told by their system prompt + to reply with `buzz messages send`, so a host without it produces an agent that cannot. `deploy` + resolves it the same two ways, installs it from `BUZZ_CLI_PUSH_BINARY` when the host has none, and + otherwise emits a warning and provisions the agent anyway. Not a prerequisite — but a host that + satisfies it gets noticeably better agents. + +4. **At least one harness CLI**, named exactly as `discover_harnesses` probes it. Most harnesses + require only their ACP adapter: `codex-acp` for Codex, `goose` for Goose, `cursor-agent`, `omp`, + `grok`, `opencode`, `kimi`, `amp-acp`, `hermes-acp`, `openclaw`, or `buzz-agent`. Claude is the + deliberate exception: it requires both `claude-agent-acp` or `claude-code-acp` **and** the + vendor `claude` CLI whose stable launcher is bound into the adapter. + +5. **SSH key auth.** Every invocation is `BatchMode=yes`, so a password prompt is an immediate + failure and never a hang. Add the desktop machine's public key to `~/.ssh/authorized_keys`, or + run `tailscale set --ssh` on the host. + +6. **Tailscale (optional).** When the desktop's own `tailscale status --json` reports + `BackendState: "Running"`, its peers decorate the `ssh_host` field as a device picker. Phones and + TVs are filtered out; `Self` is never offered. The label carries reachability and, when the peer + advertises `sshHostKeys`, a `· Tailscale SSH` marker — that field's absence is the negative + signal, not an unknown. Tailscale absent, logged out, or empty produces a schema byte-identical + to the plain one; manual SSH is the unchanged fallback. + +`XDG_RUNTIME_DIR` needs no host action: a non-interactive SSH command often gets none, and `deploy` +sets it when the session did not supply one. + +Windows hosts are never deploy targets. The provider runs on Windows — it resolves +`%SystemRoot%\System32\OpenSSH\ssh.exe` before PATH and suppresses the console window for every +child — but the remote side is POSIX `sh` and `systemd --user` throughout. + +## Security invariants + +These are properties of the code, not conventions to uphold. + +- **Secrets cross on stdin only.** Every op sends its script to a remote `sh -s`; the remote argv is + the literal string `sh -s`, and the local argv is ssh options. The remote `ps` is world-readable + and the desktop's redaction has no reach there, so a secret on the remote argv would leak the + agent identity to every user on the box. +- **The env file is owner-only.** Written under `umask 077`, `chmod 600`, then moved into place, so + a failed write never leaves a half-written identity behind. +- **A pushed `buzz-acp` cannot corrupt the script carrying the nsec.** It travels base64-encoded + inside a quoted heredoc, so no byte of it is ever read as shell syntax. It is verified against a + desktop-computed sha256 before it is made executable, and installed by a same-directory rename, so + a damaged or interrupted push leaves nothing runnable behind. +- **A deploy without the minted nsec fails closed.** An agent that mints its own key on the host + looks deployed and is permanently unreachable: presence, mentions, `!shutdown`, badges and the + NIP-OA auth tag all key off the pubkey the desktop minted. +- **A deploy without the harness pin fails closed.** The pin is the only channel by which the + harness choice reaches the host. A blank one would fall through to `buzz-agent`, silently + provisioning a harness the user never chose, so it is refused rather than substituted. +- **Reserved env keys are refused**, as are env names that are not POSIX identifiers and env values + containing control characters. A newline in a value would otherwise end the assignment and start a + line of the value's own choosing — including one that re-sets `BUZZ_PRIVATE_KEY`. The list is a + verbatim copy of the desktop's `RESERVED_ENV_KEYS`, so a leak needs two independent failures. +- **`Secret` renders as `[REDACTED]`** in both `Debug` and `Display` and zeroizes on drop. + `Agent` and `ssh::Output` deliberately do not derive `Debug` at all: the first holds provider API + keys in plain `String`s, the second holds raw remote stderr, and only `Output::failure()` runs + that through the scrubber. +- **Host-key trust is never relaxed for a typed address.** `StrictHostKeyChecking=ask` by default; + `accept-new` only for an address this machine's own Tailscale daemon lists as a peer, which was + already reached over a WireGuard-authenticated tunnel. +- **Provider binaries are resolved by discovery, never by name.** Every deploy, start and probe path + resolves through `discover_provider_candidates`, so a frontend or IPC caller that names a + `binaryPath` cannot steer execution at an arbitrary binary and feed it the agent's private key. + +## The systemd unit + +One templated `buzz-acp@.service` per host, instantiated per agent. + +```ini +[Unit] +Description=Buzz agent %i +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +EnvironmentFile=%h/.config/buzz-acp/%i.env +ExecStart=@BUZZ_ACP_BIN@ +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target +``` + +- `StartLimitIntervalSec=0` — a long-running agent must never be rate-limited into staying down. A + unit held by the start limiter looks exactly like an agent that silently died, and only + `systemctl reset-failed` clears it. +- `EnvironmentFile` — holds the minted nsec; systemd reads it as the owning user. +- `ExecStart` is an absolute path, substituted at install time from the host's resolved `buzz-acp`. + systemd does not expand environment variables in the program position, and the shell indirection + that would work around that is not worth adding to a unit whose environment carries a private key. + The substitution is shell parameter expansion, not `sed`: `sed -i` is a GNU extension that BSD and + macOS hosts reject. Resolution runs *first*; the install only fills an empty `$acp`, so a deploy + that installed `buzz-acp` writes the path of the copy it just installed, not a stale one. + +The instance name is derived from the agent name: lowercased, non-alphanumerics collapsed to `-`, +truncated to 32 characters, plus an 8-hex FNV-1a suffix of the original name. The suffix is not +decoration — the payload carries no stable agent identifier, so without it two agents whose names +differ only in punctuation would share one unit and one env file. + +## Env file contract + +The local spawn contract from `runtime.rs`, transcribed. Values resolved on the host — the absolute +harness path, `git-credential-nostr`, `PATH` — are appended by the remote script. + +| var | value | +|---|---| +| `BUZZ_ACP_AGENT_COMMAND` | the pinned harness, resolved on the host with `command -v` | +| `CLAUDE_CODE_EXECUTABLE` | for a Claude ACP adapter, `~/.local/bin/claude` when executable, otherwise the host's `claude` launcher resolved from `PATH` | +| `PATH` | `$HOME/.local/bin:$PATH`, **expanded by the host's shell at deploy time** — see below | +| `BUZZ_PRIVATE_KEY` | payload `private_key_nsec` | +| `BUZZ_RELAY_URL`, `BUZZ_AUTH_TAG` | payload (auth tag omitted when absent) | +| `BUZZ_ACP_AGENT_ARGS` | comma-joined | +| `BUZZ_ACP_MCP_COMMAND` | empty | +| `BUZZ_ACP_LAZY_POOL` | `true` — the pool warms on the first accepted event instead of at startup; queued work is not dropped, and a restarted or idle unit does not hold N harness subprocesses | +| `BUZZ_ACP_AGENTS` | payload `parallelism` | +| `BUZZ_ACP_MULTIPLE_EVENT_HANDLING` | `steer` | +| `BUZZ_ACP_DEDUP` | `queue` | +| `BUZZ_ACP_RELAY_OBSERVER` | `true` | +| `BUZZ_ACP_RESPOND_TO` (+ `_ALLOWLIST`) | payload; `allowlist` mode with an empty list is refused | +| `BUZZ_ACP_SYSTEM_PROMPT`, `BUZZ_ACP_MODEL` | payload, omitted when empty | +| runtime model/provider env | payload `model` / `provider`, under the runtime's own names — see below | +| `BUZZ_ACP_IDLE_TIMEOUT`, `BUZZ_ACP_MAX_TURN_DURATION` | emitted only when set, so the harness's own defaults win | +| `NOSTR_PRIVATE_KEY`, `GIT_TERMINAL_PROMPT`, `GIT_CONFIG_*` | only when `git-credential-nostr` is on the host | +| user `env_vars` | written last, so they override — matching the local layering | + +**The `PATH` line is the remote half of the desktop's own PATH contract.** Local spawn prepends +`/.local/bin` (and the bundled sidecar directory) to the spawned harness's `PATH` +(`managed_agents::runtime::path::build_augmented_path`), which is why a local agent can run the +`buzz` CLI its system prompt tells it to reply with. Remotely the harness runs under `systemd --user`, +whose `PATH` is the user manager's: no profile, no login shell, and on many distributions no +`~/.local/bin` at all. Without this line every tool `deploy` installs would be installed and +unreachable. + +It is composed **by the host's shell during the deploy**, not written into the unit as +`Environment=PATH=$HOME/.local/bin:$PATH`. systemd expands no variable in `Environment=` or in an +`EnvironmentFile`, so that form would hand the harness the five literal characters `$PATH`. The right +half is the non-interactive SSH shell's own `PATH`, captured at deploy time — which is the same +`PATH` the install machinery just searched, so anything `command -v` found on the host stays findable +for the agent. The harness passes its environment to its children unchanged, so this is what makes +`buzz` a command a remote agent can actually run. + +The runtime model/provider pair is the remote half of `runtime_metadata_env_vars`. `BUZZ_ACP_MODEL` +is what `buzz-acp` reads; these are what the harness underneath it reads, and local spawn writes +both. Without them a remote Goose would fall back to whatever `~/.config/goose/config.yaml` on the +host says, with the user's model pick silently ignored. + +| pinned command | model var | provider var | +|---|---|---| +| `goose` | `GOOSE_MODEL` | `GOOSE_PROVIDER` | +| `buzz-agent` | `BUZZ_AGENT_MODEL` | `BUZZ_AGENT_PROVIDER` | + +The lookup is keyed on the command's file name, so an absolute pin +(`/home/ubuntu/.local/bin/goose`) resolves to the same runtime — matching `known_acp_runtime` +locally. Runtimes absent from the table declare no such vars in `KNOWN_ACP_RUNTIMES` either: +Claude is `provider_locked`, and neither Claude nor Codex has a model env var. An unset payload +field writes no key at all. + +Claude has one additional executable binding. When the pinned harness is +`claude-agent-acp` or `claude-code-acp`, deploy prefers the stable +`~/.local/bin/claude` launcher and otherwise resolves `claude` from the host's +`PATH`, then writes it as `CLAUDE_CODE_EXECUTABLE`. This matches local desktop +spawn behavior and prevents the adapter from silently using the point-in-time +Claude binary bundled with its SDK dependency. The launcher path is preserved +instead of dereferencing its native-install symlink, so newly spawned ACP +children follow subsequent Claude Code updates. Deploy fails with exit 95 +before writing the unit when the adapter is present but the Claude CLI is not. + +`BUZZ_MANAGED_AGENT` is deliberately absent. It is the desktop's process-ownership marker for +reclaiming orphaned local children; where systemd owns the lifecycle it would be actively +misleading. + +`turn_timeout_seconds` is deliberately never read. The payload still carries it, but +`BUZZ_ACP_TURN_TIMEOUT` is deprecated and ignored by the harness, and local spawn does not write it +either — `idle_timeout_seconds` and `max_turn_duration_seconds` are the live controls. A test pins +that no `TURN_TIMEOUT` key can reappear in the env file. + +## Lifecycle + +**Deploy is the start path.** `start_managed_agent` re-enters `deploy_to_provider`, so start and +redeploy are one code path and everything in it is idempotent. Non-idempotence would surface as +duplicate units, not as an error. One deploy is one round trip (plus the cheap resolution probe, +only when a push field is set) that resolves — or, on a host that has none and a payload that +carries one, installs — `buzz-acp` and the `buzz` CLI, resolves the +harness, writes the env file atomically, enables lingering, installs the unit template, +`daemon-reload`s only when the unit content actually changed, then `enable --now` and `restart`. +The restart is what makes an already-running unit adopt the rewritten env file. + +**Stop is `!shutdown`.** The desktop's `stop_managed_agent` command rejects non-local agents +outright. The frontend sends a signed `!shutdown` @mention; the harness consumes it, drains +in-flight prompts, publishes `offline` presence, and exits. `Restart=always` then restarts the unit +after `RestartSec=5` — a `!shutdown` stops the current process, not the unit. To stop the unit, +`systemctl --user stop buzz-acp@.service` on the host. + +**There is no `undeploy` op.** Deleting a deployed remote agent requires `force_remote_delete: true` +and permanently orphans a systemd unit and an env file containing an nsec on the host. This is the +strongest candidate for the immediate follow-up PR. + +**Logs** are `journalctl --user -u buzz-acp@ -f` on the host. + +## Troubleshooting + +**"Failed to connect to bus" during deploy.** Lingering is off and `loginctl enable-linger` was +rejected (polkit), so `/run/user/$UID` does not exist and no `systemctl --user` call can reach the +user manager. Run `sudo loginctl enable-linger ` once and redeploy. + +**Agent goes online, then offline as soon as the deploy finishes.** Same cause, softer symptom: the +bus was reachable through the deploy's own session, and the user manager was torn down with it. +Enable lingering. + +**`buzz-acp not found on the server's PATH or in ~/.local/bin` (exit 90).** Neither `command -v +buzz-acp` nor `~/.local/bin/buzz-acp` resolved on the host, and the payload carried no binary to +install. Install it to `~/.local/bin`, set `buzz_acp_path`, or +point `BUZZ_ACP_PUSH_BINARY` at a Linux `buzz-acp` on the desktop and let the deploy install it. +Note that `discover_harnesses` reports this non-fatally, so it can first appear at deploy time. + +**`WARNING: no 'buzz' CLI on the server's PATH or in ~/.local/bin`.** The deploy **succeeded** — this +is a warning on the provider's stderr, not an error. The agent is running, but it cannot answer with +`buzz messages send --reply-to …` the way its own system prompt tells it to, so it will fall back to +slower replies (and, left to itself, waste its first turns looking for the command). Install `buzz` +into `~/.local/bin` on the host, or point `BUZZ_CLI_PUSH_BINARY` at a Linux `buzz` on the desktop and +redeploy. This is the one host-side complaint that is deliberately not fatal. + +**Agent replies are slow, or it reports it cannot find `buzz`.** Either the CLI is not installed +(above), or it is installed somewhere the unit's `PATH` does not reach. The env file pins +`PATH="$HOME/.local/bin:"`; `systemctl --user show-environment` and +`cat ~/.config/buzz-acp/.env` show what the harness actually got. A tool installed after the +last deploy into a directory that was not on the deploying shell's `PATH` needs one redeploy. + +**`the server has no 'base64' / 'sha256sum'` (exit 92).** The host is missing coreutils, so a pushed +binary cannot be decoded or — the part that is not negotiable — verified. Install coreutils, or +install the tool on the host by hand. The deploy stops before writing anything. + +**`the pushed buzz-acp` / `the pushed buzz did not decode` / `failed its sha256 check` (exit 93 / +94).** The binary was damaged between the desktop and the host. Nothing is installed and no temp file +survives; the env file and unit are never written. Fatal for both tools — including the +non-load-bearing CLI, because the damaged stream is the one carrying the nsec. Retry, and if it +repeats, the local file named by the corresponding `BUZZ_*_PUSH_BINARY` is the suspect. + +**`the buzz-acp binary to push is not a Linux (ELF) executable`** (or `the buzz binary …`). +`BUZZ_ACP_PUSH_BINARY` / `BUZZ_CLI_PUSH_BINARY` points at the desktop's own build (Mach-O or PE) +rather than a Linux one. Caught locally, before the session opens; the message names which variable to +fix. The alternative is a unit that restart-loops on `Exec format error`, or a `buzz` on the host that +fails on every invocation, after a deploy that reported success. + +**The model list never arrives, or reports `agent timed out (60s)`.** There are three nested +budgets on that path and the innermost one is on the host: `buzz-acp models --json` allows +`MODELS_TIMEOUT` — 60s, deliberately the same budget the normal agent-init path gives the very same +adapter spawn — for the harness to reach `initialize`. Outside it, the provider allows 110s for the +whole `probe_models` op and the desktop allows 150s. A cold node adapter (`codex-acp`) on a busy +host can take tens of seconds on its first spawn and be instant warm, so a probe that times out +once and succeeds on "check host again" is a slow host, not a broken harness. A probe that keeps +hitting 60s is the harness failing to start: run `buzz-acp models --json` on the host by hand, where +the adapter's own stderr is visible. Longer than 110s and the failure changes shape — the provider +budget fires first and the desktop reports a provider timeout rather than an agent one. + +**`harness not found on the server's PATH` (exit 91).** The pinned harness is not installed +under that name. Deploy stops before writing anything — no env file, no unit. Install the ACP +adapter and re-run discovery so the pin names a binary that exists. + +**`Claude Code CLI not found in ~/.local/bin or on the server's PATH` (exit 95).** A Claude ACP +adapter is installed, but the vendor CLI it drives is not. Install Claude Code through its native +installer so `~/.local/bin/claude` exists, or put another `claude` launcher on the deploying +shell's `PATH`, then redeploy. The adapter's bundled SDK binary is deliberately not used. + +**`Permission denied (publickey)`.** `BatchMode=yes` means SSH declined rather than prompting. Add +the public key to `~/.ssh/authorized_keys`, or `tailscale set --ssh` on the host. + +**`Host key verification failed`.** The host key is not in `known_hosts`, and `BatchMode` cannot +prompt to accept it. Connect once with `ssh` by hand to review and accept the key. This is expected +for any manually typed address; tailnet peers are exempt. + +**The device dropdown disappeared.** `tailscale status --json` no longer reports +`BackendState: "Running"` — most often a logged-out daemon, which exits 0 with +`BackendState: "NeedsLogin"`. The field degrades to plain text and manual SSH still works; a +MagicDNS name typed into it will fail with `Could not resolve hostname`. + +## Known limitations + +- `runtimeSupportsLlmProviderSelection` is a hardcoded id test (`buzz-agent` or `goose`). A remote + harness whose id matches gets the LLM-provider selector; any other remote id does not, however + the host's own catalog describes it. +- `BUZZ_ACP_TEAM_INSTRUCTIONS` is not carried to the host — the deploy payload has no team field, so + a team-linked remote agent silently loses its team instructions. +- `MCP_HOOK_SERVERS` is not emitted. `mcp_hooks` is local catalog metadata the provider cannot + compute, so remote agents have no `_Stop`/`_PostCompact` hook tools. +- `check` is implemented but has no desktop caller. `discover_harnesses` serves as the de facto + preflight, since it is the first op the create flow runs against a host. +- The credential gate cannot see what the host already supplies. It asks the pinned REMOTE harness + which env keys matter, but the runtime file layer (`~/.config/goose/config.yaml`) is local, so it + is suppressed entirely for a remote create rather than answering for the wrong machine. A host + whose config file already carries the credentials is therefore still asked for them. Closing this + needs a `check`-style round trip that reports the host's own configuration — a protocol addition. +- The tailnet device picker filters out phones and TVs, but still offers Windows peers, which + cannot be deploy targets. Picking one fails at deploy, not at selection. +- **Neither host-side tool is installed unless the desktop supplies one.** `deploy` installs the + binaries named by `agent.buzz_acp_binary` / `agent.buzz_cli_binary` (from `BUZZ_ACP_PUSH_BINARY` / + `BUZZ_CLI_PUSH_BINARY`) on a host that has none; with the variables unset a missing `buzz-acp` is + still exit 90 and a missing `buzz` is still just a warning. Resolving the right release artifacts + for the host — which is what makes the create dialog's deploy-will-install promise true, and what + gives every remote agent CLI parity with a local one without a developer setting an env var — is + the immediate follow-up, and the version-refresh rule rides with it. +- An already-installed tool is never upgraded by a deploy, by design (see the staleness rule). A host + stuck on an old `buzz-acp` or `buzz` has to be updated by hand until artifact fetching lands. From 1b14b6e2c1808514b083206b2f99b6c812920929 Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Wed, 29 Jul 2026 01:59:48 +0200 Subject: [PATCH 02/19] feat(backend-ssh): add the SSH remote-agent provider crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference implementation of the `BackendKind::Provider` seam that `managed_agents/backend.rs` already describes and #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-` 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 --- Cargo.lock | 11 + Cargo.toml | 1 + Justfile | 6 + crates/buzz-backend-ssh/Cargo.toml | 31 + .../buzz-backend-ssh/assets/buzz-acp@.service | 24 + crates/buzz-backend-ssh/src/deploy.rs | 1962 +++++++++++++++++ crates/buzz-backend-ssh/src/discover.rs | 1266 +++++++++++ crates/buzz-backend-ssh/src/install.rs | 790 +++++++ crates/buzz-backend-ssh/src/main.rs | 161 ++ crates/buzz-backend-ssh/src/protocol.rs | 369 ++++ crates/buzz-backend-ssh/src/ssh.rs | 478 ++++ crates/buzz-backend-ssh/src/tailscale.rs | 473 ++++ 12 files changed, 5572 insertions(+) create mode 100644 crates/buzz-backend-ssh/Cargo.toml create mode 100644 crates/buzz-backend-ssh/assets/buzz-acp@.service create mode 100644 crates/buzz-backend-ssh/src/deploy.rs create mode 100644 crates/buzz-backend-ssh/src/discover.rs create mode 100644 crates/buzz-backend-ssh/src/install.rs create mode 100644 crates/buzz-backend-ssh/src/main.rs create mode 100644 crates/buzz-backend-ssh/src/protocol.rs create mode 100644 crates/buzz-backend-ssh/src/ssh.rs create mode 100644 crates/buzz-backend-ssh/src/tailscale.rs 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")); + } + } +} From 204ecf55bfef32b514b12762f69bc3766a502c07 Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Wed, 29 Jul 2026 02:18:40 +0200 Subject: [PATCH 03/19] feat(desktop): discover and invoke backend providers on PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 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 --- .../src-tauri/src/commands/agent_providers.rs | 4 + desktop/src-tauri/src/commands/agents.rs | 6 +- .../src-tauri/src/managed_agents/backend.rs | 241 ++++++------------ desktop/src-tauri/src/managed_agents/mod.rs | 3 + .../src/managed_agents/provider_discovery.rs | 157 ++++++++++++ .../src/managed_agents/provider_recovery.rs | 213 ++++++++++++++++ 6 files changed, 457 insertions(+), 167 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/provider_discovery.rs create mode 100644 desktop/src-tauri/src/managed_agents/provider_recovery.rs diff --git a/desktop/src-tauri/src/commands/agent_providers.rs b/desktop/src-tauri/src/commands/agent_providers.rs index 178ec0bb6d..3d57f9f09a 100644 --- a/desktop/src-tauri/src/commands/agent_providers.rs +++ b/desktop/src-tauri/src/commands/agent_providers.rs @@ -43,4 +43,8 @@ pub async fn probe_backend_provider(binary_path: String) -> Result { - rec.last_error = Some(e.clone()); + // The message only: `last_error` is read back long after the deploy, + // and a recovery URL is a one-shot token that is stale by then. + rec.last_error = Some(e.message.clone()); rec.updated_at = now_iso(); save_managed_agents(app, &records)?; - return Err(e.clone()); + return Err(e.message.clone()); } } save_managed_agents(app, &records)?; diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 2a72af92d7..b91d34d0a9 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,7 @@ 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()) } /// Validate provider_config: flat object, scalar values, no secret-like keys. @@ -409,123 +444,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 +611,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/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index b0e86f8edb..43be311924 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,7 @@ pub use nest::*; pub use personas::*; #[cfg(windows)] pub use process_lifecycle::*; +pub use provider_discovery::*; 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/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" }) + ); + } +} From 489c1a59d4b633dcb859cfef0b1be6889a16f787 Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Wed, 29 Jul 2026 02:23:53 +0200 Subject: [PATCH 04/19] feat(desktop): build a remote deploy payload from a provider record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src-tauri/src/commands/agent_models.rs | 11 +- .../src/commands/agent_models_tests.rs | 62 ++++++++ .../src-tauri/src/commands/agent_providers.rs | 104 +++++++++++--- desktop/src-tauri/src/commands/agents.rs | 61 +++++--- .../src-tauri/src/commands/agents_deploy.rs | 65 +++++++++ .../src-tauri/src/commands/agents_tests.rs | 116 +++++++++++++++ desktop/src-tauri/src/lib.rs | 2 + .../src-tauri/src/managed_agents/backend.rs | 46 ++++++ .../src-tauri/src/managed_agents/discovery.rs | 4 +- .../src/managed_agents/discovery/overrides.rs | 49 ++++++- .../src/managed_agents/discovery/tests.rs | 1 + .../discovery/tests/create_time_args.rs | 79 ++++++++++ .../src-tauri/src/managed_agents/env_vars.rs | 1 + .../src/managed_agents/env_vars/tests.rs | 8 ++ desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src-tauri/src/managed_agents/runtime.rs | 71 ++------- .../managed_agents/runtime/setup_payload.rs | 135 ++++++++++++++++++ .../src/managed_agents/runtime_commands.rs | 14 ++ desktop/src-tauri/src/managed_agents/types.rs | 8 ++ .../src/managed_agents/types/tests.rs | 46 ++++++ 20 files changed, 775 insertions(+), 109 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/discovery/tests/create_time_args.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/setup_payload.rs diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index ca1fe9bdf6..95137fd22a 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -1099,11 +1099,18 @@ pub(super) fn normalize_agent_models( if seen_ids.insert(value.to_string()) { models.push(AgentModelInfo { id: value.to_string(), + // The schema field is `name`; `displayName` is + // a pre-standardization spelling some adapters + // still emit. Same order as buzz-acp's reader. name: o - .get("displayName") + .get("name") + .or_else(|| o.get("displayName")) + .and_then(|v| v.as_str()) + .map(str::to_string), + description: o + .get("description") .and_then(|v| v.as_str()) .map(str::to_string), - description: None, }); } } 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 3d57f9f09a..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!({ @@ -43,8 +62,51 @@ 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 42cb32d4ca..5ba59620e6 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -4,15 +4,15 @@ use tauri::{AppHandle, State}; use crate::{ app_state::AppState, managed_agents::{ - build_managed_agent_summary, current_instance_id, discover_provider_candidates, - ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, - load_teams, managed_agent_avatar_url, normalize_agent_args, provider_deploy, + build_managed_agent_summary, create_time_agent_args, current_instance_id, + discover_provider_candidates, ensure_persona_is_active, find_managed_agent_mut, + load_managed_agents, load_personas, load_teams, managed_agent_avatar_url, provider_deploy, 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, - DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + ManagedAgentSummary, ProviderFailure, RelayMeshConfig, DEFAULT_ACP_COMMAND, + DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -454,8 +454,15 @@ pub(super) async fn start_local_agent_with_preflight( /// 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 +/// Returns Ok(()) on success, Err(failure) on failure. Either way the record is /// updated and saved before returning. +/// +/// The error is a [`ProviderFailure`] so a deploy that fails on a tailnet +/// asking for browser re-auth keeps its recovery for callers that can render +/// one; each caller drops it explicitly where it cannot. The record's +/// `last_error` stays a plain string either way — it is a human-readable +/// post-mortem read long after the fact, and an auth URL is a one-shot token +/// that is stale by the time anyone reads it back. async fn deploy_to_provider( app: &AppHandle, state: &AppState, @@ -464,7 +471,7 @@ async fn deploy_to_provider( config: &serde_json::Value, agent_json: serde_json::Value, cached_binary_path: Option<&str>, -) -> Result<(), String> { +) -> 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. @@ -509,7 +516,7 @@ async fn deploy_to_provider( rec.last_error = Some(e.message.clone()); rec.updated_at = now_iso(); save_managed_agents(app, &records)?; - return Err(e.message.clone()); + return Err(e.clone()); } } save_managed_agents(app, &records)?; @@ -739,15 +746,10 @@ 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::>(), - ); + // Local args are normalized against the LOCAL runtime catalog; a + // provider create's are pinned from the remote host's catalog. See + // `create_time_agent_args` for why the two must not share a path. + 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 @@ -1019,7 +1021,11 @@ 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), + // `spawn_error` is a reported field of a SUCCEEDING create, not + // the command's error, so it stays a string. The agent record + // exists either way; a failed first deploy is retried through + // `start_managed_agent`, which is where the recovery lands. + Err(e) => Some(e.message), } } else { spawn_error @@ -1064,6 +1070,14 @@ pub async fn create_managed_agent( } /// Data needed for background profile reconciliation after agent start. +/// +/// Returns `String` rather than [`ProviderFailure`]: every surface that starts +/// an agent renders the failure as a toast, and a toast has no room for an +/// action. The conversion is explicit at the one site below rather than an +/// `impl From for String`, which would let any future caller +/// drop a recovery by accident. Nothing is lost to the user here — the message +/// names the problem and carries the URL as text — but a button on this path +/// needs the toast layer to grow an action first. #[tauri::command] pub async fn start_managed_agent( pubkey: String, @@ -1155,7 +1169,12 @@ pub async fn start_managed_agent( agent_json, cached_binary_path.as_deref(), ) - .await?; + .await + // The one place a recovery is deliberately dropped, named rather + // than implicit: this command's failures are rendered as toasts, + // which have no room for an action. The message still carries the + // URL as text. Give the toast layer an action before widening this. + .map_err(|failure| failure.message)?; // Return updated summary. let _store_guard = state @@ -1362,9 +1381,9 @@ pub async fn delete_managed_agent( mod deploy; use deploy::build_deploy_payload; #[cfg(test)] -use deploy::deploy_payload_json; -#[cfg(test)] pub(crate) use deploy::resolve_deploy_model_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..ab9b18cad0 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -99,12 +99,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 +155,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 +174,26 @@ 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()) +} 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/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 b91d34d0a9..3cbdc56762 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -411,6 +411,52 @@ pub fn provider_deploy( .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. pub fn validate_provider_config(config: &serde_json::Value) -> Result<(), String> { let obj = config diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index eecbf4de3e..12b337d0b6 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -368,7 +368,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..af7056e8c5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -753,6 +753,7 @@ fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() { // ── probe_codex_acp_version ─────────────────────────────────────────────────── +mod create_time_args; mod managed_path_resolution; #[cfg(unix)] 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 43be311924..13490baaeb 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -63,6 +63,7 @@ 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/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..63afd59959 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -162,6 +162,14 @@ 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, and only safely so by invariant. `AgentDefinition` has no slot for + /// `backend`, `team_id`, `agent_command`, `idle_timeout_seconds`, + /// `relay_mesh`, `respond_to` or `parallelism`, so a round-trip back through + /// `into_agent_record` resets each to its constant. This is benign today + /// because definitions never carry non-default values for those fields; the + /// moment one does — a definition learning a backend, say — this projection + /// silently reverts it. 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; From c8ec0df046754ce544f4fee69af9c47eba4f00d2 Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Wed, 29 Jul 2026 02:39:36 +0200 Subject: [PATCH 05/19] feat(desktop): scope persona runtime sync to local records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src-tauri/src/commands/agent_models.rs | 17 +- desktop/src-tauri/src/commands/agents.rs | 118 ++------ .../src-tauri/src/commands/agents_deploy.rs | 86 +++++- .../src-tauri/src/commands/personas/delete.rs | 260 ++++++++++++++++++ .../commands/personas/delete_cascade_tests.rs | 108 +++++++- .../src-tauri/src/commands/personas/mod.rs | 203 +------------- .../src-tauri/src/managed_agents/discovery.rs | 8 +- .../src/managed_agents/discovery/tests.rs | 15 +- .../src/managed_agents/persona_events.rs | 63 +++-- .../managed_agents/persona_events/tests.rs | 2 + .../persona_events/tests/harness_pin.rs | 155 +++++++++++ desktop/src-tauri/src/managed_agents/types.rs | 11 +- 12 files changed, 678 insertions(+), 368 deletions(-) create mode 100644 desktop/src-tauri/src/commands/personas/delete.rs create mode 100644 desktop/src-tauri/src/managed_agents/persona_events/tests/harness_pin.rs diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 95137fd22a..132378362e 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -1097,20 +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(), - // The schema field is `name`; `displayName` is - // a pre-standardization spelling some adapters - // still emit. Same order as buzz-acp's reader. - name: o - .get("name") - .or_else(|| o.get("displayName")) - .and_then(|v| v.as_str()) - .map(str::to_string), - description: o - .get("description") - .and_then(|v| v.as_str()) - .map(str::to_string), + // `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/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 5ba59620e6..dc7be33997 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -5,14 +5,13 @@ use crate::{ app_state::AppState, managed_agents::{ build_managed_agent_summary, create_time_agent_args, current_instance_id, - discover_provider_candidates, ensure_persona_is_active, find_managed_agent_mut, - load_managed_agents, load_personas, load_teams, managed_agent_avatar_url, provider_deploy, - resolve_provider_binary, save_managed_agents, start_managed_agent_process, - stop_managed_agent_process, stop_managed_agent_workspace_pair, + ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, + 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, ProviderFailure, RelayMeshConfig, DEFAULT_ACP_COMMAND, - DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, + DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -447,82 +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(failure) on failure. Either way the record is -/// updated and saved before returning. -/// -/// The error is a [`ProviderFailure`] so a deploy that fails on a tailnet -/// asking for browser re-auth keeps its recovery for callers that can render -/// one; each caller drops it explicitly where it cannot. The record's -/// `last_error` stays a plain string either way — it is a human-readable -/// post-mortem read long after the fact, and an auth URL is a one-shot token -/// that is stale by the time anyone reads it back. -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: `last_error` is read back long after the deploy, - // and a recovery URL is a one-shot token that is stale by then. - 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(()) -} - // 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 @@ -746,9 +669,8 @@ pub async fn create_managed_agent( &personas, agent_command_override.as_deref(), ); - // Local args are normalized against the LOCAL runtime catalog; a - // provider create's are pinned from the remote host's catalog. See - // `create_time_agent_args` for why the two must not share a path. + // 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 @@ -1021,10 +943,9 @@ pub async fn create_managed_agent( }; match deploy_to_provider(&app, &state, &pubkey, id, config, agent_json, None).await { Ok(()) => spawn_error, - // `spawn_error` is a reported field of a SUCCEEDING create, not - // the command's error, so it stays a string. The agent record - // exists either way; a failed first deploy is retried through - // `start_managed_agent`, which is where the recovery lands. + // 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 { @@ -1070,14 +991,6 @@ pub async fn create_managed_agent( } /// Data needed for background profile reconciliation after agent start. -/// -/// Returns `String` rather than [`ProviderFailure`]: every surface that starts -/// an agent renders the failure as a toast, and a toast has no room for an -/// action. The conversion is explicit at the one site below rather than an -/// `impl From for String`, which would let any future caller -/// drop a recovery by accident. Nothing is lost to the user here — the message -/// names the problem and carries the URL as text — but a button on this path -/// needs the toast layer to grow an action first. #[tauri::command] pub async fn start_managed_agent( pubkey: String, @@ -1170,10 +1083,11 @@ pub async fn start_managed_agent( cached_binary_path.as_deref(), ) .await - // The one place a recovery is deliberately dropped, named rather - // than implicit: this command's failures are rendered as toasts, - // which have no room for an action. The message still carries the - // URL as text. Give the toast layer an action before widening this. + // 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. @@ -1379,9 +1293,9 @@ pub async fn delete_managed_agent( #[path = "agents_deploy.rs"] mod deploy; -use deploy::build_deploy_payload; #[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}; diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index ab9b18cad0..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. @@ -197,3 +203,75 @@ fn binary_to_push(var: &str) -> Option { .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/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/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 12b337d0b6..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(); diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index af7056e8c5..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,11 +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/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/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 63afd59959..464eb348f7 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -163,13 +163,10 @@ impl ManagedAgentRecord { /// surface serves until Phase 1B unifies the UI. Inverse of /// [`AgentDefinition::into_agent_record`] for the fields personas carry. /// - /// LOSSY, and only safely so by invariant. `AgentDefinition` has no slot for - /// `backend`, `team_id`, `agent_command`, `idle_timeout_seconds`, - /// `relay_mesh`, `respond_to` or `parallelism`, so a round-trip back through - /// `into_agent_record` resets each to its constant. This is benign today - /// because definitions never carry non-default values for those fields; the - /// moment one does — a definition learning a backend, say — this projection - /// silently reverts it. + /// 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 { From cf22ca946827a4226992b3218bc589a1deae9e2c Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Wed, 29 Jul 2026 02:57:14 +0200 Subject: [PATCH 06/19] feat(desktop): type and bind the backend-provider Tauri surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../features/agents/backendProviderHooks.ts | 64 ++++++++ .../src/shared/api/backendProviderTypes.ts | 57 +++++++ desktop/src/shared/api/tauri.test.mjs | 111 +++++++++++++ desktop/src/shared/api/tauri.ts | 19 +-- .../src/shared/api/tauriBackendProviders.ts | 149 ++++++++++++++++++ desktop/src/shared/api/types.ts | 19 +-- 6 files changed, 389 insertions(+), 30 deletions(-) create mode 100644 desktop/src/features/agents/backendProviderHooks.ts create mode 100644 desktop/src/shared/api/backendProviderTypes.ts create mode 100644 desktop/src/shared/api/tauriBackendProviders.ts 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/shared/api/backendProviderTypes.ts b/desktop/src/shared/api/backendProviderTypes.ts new file mode 100644 index 0000000000..1253ac78df --- /dev/null +++ b/desktop/src/shared/api/backendProviderTypes.ts @@ -0,0 +1,57 @@ +/** + * Types for backend providers: the `buzz-backend-*` binaries that run an agent + * somewhere other than this computer. + * + * Kept out of `types.ts` because everything here describes a REMOTE machine, + * and conflating it with the local-runtime vocabulary is precisely the mistake + * that makes a remote agent silently deploy the wrong harness. + */ + +export type BackendProviderCandidate = { + id: string; + binaryPath: string; +}; + +export type BackendProviderProbeResult = { + ok: boolean; + name?: string; + version?: string; + description?: string; + config_schema?: Record; +}; + +/** + * One harness on the machine a provider deploys to, from the provider's + * `discover_harnesses` op. `command`/`args`/`env` describe the REMOTE host, so + * they are pinned onto the agent record verbatim at create time — nothing + * re-resolves them, because a provider-backed agent never spawns locally. + */ +export type RemoteHarness = { + id: string; + label: string; + command: string; + args: string[]; + env: Record; + available: boolean; + binaryPath: string | null; + version: string | null; + /** + * The entry names a persistent IDENTITY on the host rather than an ephemeral + * runner, so at most one agent may be pinned to it. + * + * Absent (the default, and what every entry emitted before this field looked + * like) means the opposite: deploying the same harness N times against one + * host is the point of a runner, and nothing about it is scarce. A provider + * sets this when a second agent on the same entry would share one identity's + * memory, sessions and credentials — two puppeteers on one body. The desktop + * knows nothing about WHICH harnesses those are; it only refuses to add a + * marked one twice (`isExclusiveRemoteHarnessAdded`). + */ + exclusive?: boolean; +}; + +export type RemoteHarnessCatalog = { + /** `null` when buzz-acp is not installed on the host — actionable, not fatal. */ + buzzAcp: { path: string; version: string } | null; + harnesses: RemoteHarness[]; +}; diff --git a/desktop/src/shared/api/tauri.test.mjs b/desktop/src/shared/api/tauri.test.mjs index f4692a7d2a..e0ab76bea6 100644 --- a/desktop/src/shared/api/tauri.test.mjs +++ b/desktop/src/shared/api/tauri.test.mjs @@ -211,6 +211,117 @@ test("fromRawAcpRuntimeCatalogEntry env round-trips through edit payload shape", ); }); +// ── fromRawRemoteHarness: the remote catalog wire boundary ─────────────────── +// +// The provider emits `exclusive: true` only for entries that name a persistent +// identity on the host. Absent must stay absent: the desktop reads "no field" +// as "deploy as many as you like", and inventing a `false` would have the app +// asserting something the provider never said. + +const { fromRawRemoteHarness } = await import("./tauri.ts"); + +test("fromRawRemoteHarness carries an asserted exclusive flag", () => { + const harness = fromRawRemoteHarness({ + id: "hermes-default", + label: "Hermes (default)", + command: "hermes", + args: ["--profile", "default", "acp"], + available: true, + binaryPath: "/usr/local/bin/hermes", + exclusive: true, + }); + assert.equal(harness.exclusive, true); + assert.deepStrictEqual(harness.args, ["--profile", "default", "acp"]); +}); + +test("fromRawRemoteHarness leaves exclusive absent when the provider is silent", () => { + const harness = fromRawRemoteHarness({ + id: "claude", + label: "Claude Code", + command: "claude-code-acp", + available: true, + }); + assert.equal( + Object.hasOwn(harness, "exclusive"), + false, + "an absent flag must not become a claim the provider never made", + ); + // Everything else still degrades to today's defaults. + assert.deepStrictEqual(harness.args, []); + assert.deepStrictEqual(harness.env, {}); + assert.equal(harness.binaryPath, null); + assert.equal(harness.version, null); +}); + +test("fromRawRemoteHarness treats a false or null exclusive as not exclusive", () => { + for (const exclusive of [false, null]) { + const harness = fromRawRemoteHarness({ + id: "codex", + command: "codex", + available: true, + exclusive, + }); + assert.equal(Object.hasOwn(harness, "exclusive"), false); + assert.equal( + harness.label, + "codex", + "a missing label falls back to the id", + ); + } +}); + +// ── providerRecoveryOf: the actionable half of a provider failure ──────────── + +const { providerRecoveryOf, TauriInvokeError } = await import("./tauri.ts"); + +/** A provider command rejection as `invokeTauri` produces it. */ +function rejection(payload) { + return new TauriInvokeError(payload.message ?? "failed", payload); +} + +test("providerRecoveryOf reads the recovery off a structured provider failure", () => { + const recovery = providerRecoveryOf( + rejection({ + message: "this host requires Tailscale SSH authentication in a browser", + recovery: { + action: "open_url", + url: "https://login.tailscale.com/a/1a2b3c4d", + }, + }), + ); + assert.deepEqual(recovery, { + action: "open_url", + url: "https://login.tailscale.com/a/1a2b3c4d", + }); +}); + +test("providerRecoveryOf returns null for an ordinary failure", () => { + // The overwhelmingly common case: a failure with no recovery renders as its + // message alone, with no button. + assert.equal(providerRecoveryOf(rejection({ message: "exit 255" })), null); + assert.equal(providerRecoveryOf(new Error("ssh failed")), null); + assert.equal(providerRecoveryOf("a bare string"), null); + assert.equal(providerRecoveryOf(null), null); + assert.equal(providerRecoveryOf(undefined), null); +}); + +test("providerRecoveryOf ignores a malformed or unknown recovery", () => { + for (const recovery of [ + null, + "https://login.tailscale.com/a/tok", + { action: "run_command", command: "rm -rf /" }, + { action: "open_url" }, + { action: "open_url", url: "" }, + { action: "open_url", url: 42 }, + ]) { + assert.equal( + providerRecoveryOf(rejection({ message: "failed", recovery })), + null, + JSON.stringify(recovery), + ); + } +}); + // ── Teardown ────────────────────────────────────────────────────────────────── test("teardown — restore Date.now", () => { diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index c57525480e..47f8451e5b 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -6,8 +6,6 @@ import { import type { AddChannelMembersInput, AddChannelMembersResult, - BackendProviderCandidate, - BackendProviderProbeResult, CanvasResponse, GetHomeFeedInput, HomeFeedResponse, @@ -38,6 +36,7 @@ import type { RuntimeConfigSurface, } from "@/shared/api/types"; +export * from "@/shared/api/tauriBackendProviders"; export * from "@/shared/api/tauriChannels"; type RawPresenceLookup = Record; @@ -1127,22 +1126,6 @@ export async function updateManagedAgent( }; } -// ── Backend provider discovery ──────────────────────────────────────────────── - -export async function discoverBackendProviders(): Promise< - BackendProviderCandidate[] -> { - return invokeTauri("discover_backend_providers"); -} - -export async function probeBackendProvider( - binaryPath: string, -): Promise { - return invokeTauri("probe_backend_provider", { - binaryPath, - }); -} - // ── NIP-44 encrypt-to-self ─────────────────────────────────────────────────── export async function nip44EncryptToSelf(plaintext: string): Promise { diff --git a/desktop/src/shared/api/tauriBackendProviders.ts b/desktop/src/shared/api/tauriBackendProviders.ts new file mode 100644 index 0000000000..501dd05ae8 --- /dev/null +++ b/desktop/src/shared/api/tauriBackendProviders.ts @@ -0,0 +1,149 @@ +/** + * The Tauri surface for backend providers: the `buzz-backend-*` binaries that + * run an agent somewhere other than this computer. + * + * Split out of `tauri.ts` for the same reason `backendProviderTypes.ts` is + * split out of `types.ts` — every call here answers a question about a REMOTE + * machine, and the local-runtime bindings next door answer it about this one. + * Keeping the two apart is what stops a remote agent silently probing the + * wrong host's harness catalog. + */ + +import type { + AgentModelsResponse, + BackendProviderCandidate, + BackendProviderProbeResult, + RemoteHarness, + RemoteHarnessCatalog, +} from "@/shared/api/types"; +import { TauriInvokeError, invokeTauri } from "@/shared/api/tauri"; + +/** + * An actionable step attached to a failed provider op, read off the error a + * provider command rejected with. + * + * The only action is opening a URL, and the only URL the Rust side will ever + * put here is a Tailscale login link — it validates the prefix and the token + * charset before the value reaches this process, and refuses anything else. + * See `ProviderRecovery` in `managed_agents/provider_recovery.rs`. + */ +export type ProviderRecovery = { action: "open_url"; url: string }; + +/** + * The recovery on a rejected provider command, or `null`. + * + * Reads the wire payload rather than the message: `TauriInvokeError` already + * carries the whole rejected value, so a structured `{message, recovery}` needs + * no parsing of human text. Returns `null` for every other error shape, which + * is the overwhelmingly common case — a provider failure without a recovery is + * an ordinary failure and renders as its message alone. + */ +export function providerRecoveryOf(error: unknown): ProviderRecovery | null { + if (!(error instanceof TauriInvokeError)) return null; + const payload = error.payload; + if (typeof payload !== "object" || payload === null) return null; + const recovery = (payload as { recovery?: unknown }).recovery; + if (typeof recovery !== "object" || recovery === null) return null; + const { action, url } = recovery as { action?: unknown; url?: unknown }; + if (action !== "open_url" || typeof url !== "string" || url === "") { + return null; + } + return { action, url }; +} + +export async function discoverBackendProviders(): Promise< + BackendProviderCandidate[] +> { + return invokeTauri("discover_backend_providers"); +} + +export async function probeBackendProvider( + binaryPath: string, +): Promise { + return invokeTauri("probe_backend_provider", { + binaryPath, + }); +} + +type RawRemoteHarness = { + id: string; + label?: string | null; + command: string; + args?: string[] | null; + env?: Record | null; + available?: boolean | null; + binaryPath?: string | null; + version?: string | null; + exclusive?: boolean | null; +}; + +type RawRemoteHarnessCatalog = { + buzz_acp?: { path: string; version: string } | null; + harnesses?: RawRemoteHarness[] | null; +}; + +/** + * One catalog row, wire → app. + * + * Exported for the same reason `fromRawAcpRuntimeCatalogEntry` is: the mapping + * is the API boundary contract, and a test that re-implements it proves + * nothing. + */ +export function fromRawRemoteHarness(harness: RawRemoteHarness): RemoteHarness { + return { + id: harness.id, + label: harness.label ?? harness.id, + command: harness.command, + args: harness.args ?? [], + env: harness.env ?? {}, + available: harness.available ?? false, + binaryPath: harness.binaryPath ?? null, + version: harness.version ?? null, + // Only carried when the provider asserted it. Spreading a `false` for + // every other entry would put the desktop in the business of claiming + // something the provider never said; absent IS the default, and every + // consumer reads it as "no limit". + ...(harness.exclusive === true ? { exclusive: true } : {}), + }; +} + +/** + * The harness catalog of the machine the provider deploys to. + * + * The local ACP runtime catalog describes THIS computer, which for a remote + * agent is the wrong machine entirely — this is what the create dialog reads + * instead, and the picked entry's `command` becomes the create-time harness + * pin that the deploy ships to the host. + */ +export async function discoverProviderHarnesses( + binaryPath: string, + config: Record, +): Promise { + const raw = await invokeTauri( + "discover_provider_harnesses", + { binaryPath, config }, + ); + return { + buzzAcp: raw.buzz_acp ?? null, + harnesses: (raw.harnesses ?? []).map(fromRawRemoteHarness), + }; +} + +/** + * Model catalog for one remote harness. Normalized backend-side through the + * same `normalize_agent_models` the local path uses, so the model picker needs + * no remote-specific rendering. + */ +export async function probeProviderModels( + binaryPath: string, + config: Record, + harness: Pick, + envVars?: Record, +): Promise { + return invokeTauri("probe_provider_models", { + binaryPath, + config, + harness: { command: harness.command, args: harness.args }, + envVars, + }); +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 689c400b03..cd60af20b2 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -407,18 +407,13 @@ export type ManagedAgent = { */ export type RespondToMode = "owner-only" | "allowlist" | "anyone"; -export type BackendProviderCandidate = { - id: string; - binaryPath: string; -}; - -export type BackendProviderProbeResult = { - ok: boolean; - name?: string; - version?: string; - description?: string; - config_schema?: Record; -}; +// Backend-provider vocabulary (remote machines) lives in its own module. +export type { + BackendProviderCandidate, + BackendProviderProbeResult, + RemoteHarness, + RemoteHarnessCatalog, +} from "./backendProviderTypes"; export type RelayMeshConfig = { modelRef: string; From 9ec77b7b654a7039caaedad9a0b9c8df0511ee79 Mon Sep 17 00:00:00 2001 From: Troy Hoffman Date: Wed, 29 Jul 2026 03:17:30 +0200 Subject: [PATCH 07/19] feat(desktop): ask where an agent runs before anything else `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 #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 #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 --- .../agents/lib/backendProviderLabel.test.mjs | 58 ++ .../agents/lib/backendProviderLabel.ts | 53 ++ .../lib/exclusiveRemoteHarness.test.mjs | 216 ++++++++ .../agents/lib/exclusiveRemoteHarness.ts | 146 +++++ .../agents/lib/pinnedHarness.test.mjs | 229 ++++++++ .../src/features/agents/lib/pinnedHarness.ts | 195 +++++++ .../features/agents/ui/AgentConfigFields.tsx | 68 +-- .../agents/ui/AgentDefinitionDialog.tsx | 419 +++++++------- .../ui/AgentDefinitionIdentityFields.tsx | 82 +++ .../src/features/agents/ui/AgentDialog.tsx | 19 +- .../agents/ui/AgentLlmProviderField.tsx | 81 +++ .../agents/ui/PersonaDropdownField.tsx | 3 +- .../agents/ui/PersonaModelCombobox.tsx | 3 +- .../features/agents/ui/PersonaOptionRow.tsx | 29 + .../agents/ui/ProviderConfigFields.tsx | 198 ++++++- .../features/agents/ui/WhereToRunSection.tsx | 519 ++++++++++++++++-- .../ui/agentAiConfigurationPolicy.test.mjs | 98 ++++ .../agents/ui/agentAiConfigurationPolicy.ts | 107 ++++ .../agents/ui/agentConfigControls.tsx | 11 +- .../ui/agentConfigFieldsContract.test.mjs | 2 +- .../agents/ui/agentConfigOptions.test.mjs | 23 + .../features/agents/ui/agentConfigOptions.tsx | 76 ++- .../ui/agentDefinitionSubmitPayload.test.mjs | 48 ++ .../agents/ui/agentDefinitionSubmitPayload.ts | 12 +- .../agents/ui/createRuntimeGate.test.mjs | 409 ++++++++++++++ .../features/agents/ui/createRuntimeGate.ts | 250 +++++++++ .../agents/ui/providerConfigFields.test.mjs | 178 ++++++ .../agents/ui/relayMeshModelPicker.ts | 6 +- .../agents/ui/useCreateRuntimeSeed.ts | 133 +++++ .../ui/useRemoteAwareModelDiscovery.test.mjs | 82 +++ .../agents/ui/useRemoteAwareModelDiscovery.ts | 120 ++++ .../agents/ui/whereToRunIntent.test.mjs | 435 ++++++++++++++- .../features/agents/ui/whereToRunIntent.ts | 325 ++++++++++- .../features/onboarding/ui/RuntimeIcon.tsx | 78 ++- .../onboarding/ui/presetLogos.test.mjs | 61 +- 35 files changed, 4418 insertions(+), 354 deletions(-) create mode 100644 desktop/src/features/agents/lib/backendProviderLabel.test.mjs create mode 100644 desktop/src/features/agents/lib/backendProviderLabel.ts create mode 100644 desktop/src/features/agents/lib/exclusiveRemoteHarness.test.mjs create mode 100644 desktop/src/features/agents/lib/exclusiveRemoteHarness.ts create mode 100644 desktop/src/features/agents/lib/pinnedHarness.test.mjs create mode 100644 desktop/src/features/agents/lib/pinnedHarness.ts create mode 100644 desktop/src/features/agents/ui/AgentDefinitionIdentityFields.tsx create mode 100644 desktop/src/features/agents/ui/AgentLlmProviderField.tsx create mode 100644 desktop/src/features/agents/ui/PersonaOptionRow.tsx create mode 100644 desktop/src/features/agents/ui/createRuntimeGate.test.mjs create mode 100644 desktop/src/features/agents/ui/createRuntimeGate.ts create mode 100644 desktop/src/features/agents/ui/providerConfigFields.test.mjs create mode 100644 desktop/src/features/agents/ui/useCreateRuntimeSeed.ts create mode 100644 desktop/src/features/agents/ui/useRemoteAwareModelDiscovery.test.mjs create mode 100644 desktop/src/features/agents/ui/useRemoteAwareModelDiscovery.ts 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/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} -
- -
-