feat(desktop): connect self-hosted agents - #4006
Conversation
Signed-off-by: dspury <demitriusspury@gmail.com>
Buzz has only ever had one answer to "where does this agent's key live" —
Buzz minted it and it is in this machine's keyring — so nothing in the code
asked. A self-hosted agent breaks that: a real agent with a real pubkey whose
secret was minted on, and never leaves, a machine the user owns. Every
lifecycle affordance Buzz offers (start, stop, deploy, tombstone, profile
republish) is predicated on holding that key.
`ConnectedAgentRecord` in `connected-agents.json`, beside `managed-agents.json`
and read by nothing else. The type carries a pubkey, a Buzz-local name, an
`~/.ssh/config` host, and an observed harness label — no key, no command, no
timeout, no auto-start flag, no pid.
Never-spawned is structural. It is not that the lifecycle paths skip these
records; it is that they cannot receive one. `load_managed_agents` — the reader
behind spawn, deploy, auto-start restore, owner-signed kind:30177 reconcile,
profile republish, and delete-with-tombstone — returns `ManagedAgentRecord`,
and a connected agent is a different type in a different file. There is no
filter to maintain and no field for a future reader to misinterpret.
An earlier cut of this got there differently: `KeyCustody { Local, Remote }` on
`ManagedAgentRecord`, plus a custody filter inside `load_managed_agents`. It
worked and it was tested, but it had three costs. It made "Buzz must not act on
this agent" a value every reader had to interpret correctly rather than a shape
they could not misuse. It required `save_managed_agents` to re-read the
connected third, because otherwise each of the dozens of existing
`load → mutate → save` sites would silently erase every connected agent — a
sharp edge that needed its own regression test. And it obliged all ~20
`ManagedAgentRecord { .. }` literals in the tree to name a custody field they
have no opinion on, which put eight already-oversized upstream files over the
desktop size ratchet.
Separating the type retires all three. `storage.rs`, `storage_tests.rs`,
`types.rs`, and `reconcile.rs` are byte-identical to upstream; the churn in the
other 23 files is gone; the erase-on-unrelated-save failure mode does not exist,
because the two stores share no payload to drop a half of. Nothing splits an
upstream file and nothing touches the ratchet.
What a type cannot state on its own is covered by cross-store tests: a
connected row does not satisfy `ManagedAgentRecord` and an owned row does not
satisfy `ConnectedAgentRecord` (which is why `host` is a plain `String` — as an
`Option` an owned row read from the wrong file would deserialize into a
connected agent that can never be probed); a connected save leaves
`managed-agents.json` byte-identical; the connected store holds nothing
key-shaped, which is what makes the ordinary non-`0o600` write correct.
Uniqueness is the one property that does not partition, so `connect` checks
both stores: one identity with a row in each would be two answers to "who is
this pubkey", and a duplicate name is ambiguous at every mention site. It also
covers the key-less definition half, whose names are just as mentionable.
Two publish paths stay cut. Connecting emits no kind:30177 — that event is the
owner asserting "I manage this agent" and is what `delete_managed_agent`
tombstones, so publishing it would let Buzz claim, and later revoke, the
directory entry for an agent it cannot restart. A self-hosted agent's directory
presence is its own replaceable kind:10100. And `disconnect_remote_agent` is
not `delete_managed_agent`: it drops Buzz's local pointer with no process stop,
no keyring delete, and no tombstone or NIP-IA archive, because those would
remove a working agent from every member picker on the relay.
Desktop surface: a Connected Agents section listing host, harness, and
reachability, with a connect dialog driven by the parsed ssh config. It renders
no start/stop control — `ConnectedAgentSummary` has no `status`, `pid`, or
`needsRestart` to render one from, which is a property of the type rather than a
rule someone has to remember.
1910 backend tests and 3782 desktop JS tests pass; fmt, clippy, tsc, biome, and
all three `pnpm check` guards are clean against base `upstream/main`.
Signed-off-by: dspury <demitriusspury@gmail.com>
(cherry picked from commit 0987111)
Signed-off-by: dspury <demitriusspury@gmail.com>
(cherry picked from commit 4a20284)
(cherry picked from commit f43c379)
Signed-off-by: dspury <demitriusspury@gmail.com>
Signed-off-by: dspury <demitriusspury@gmail.com>
|
I reviewed the connected-agent path at the current head (the latest
The product-level split I would preserve is: community agents come from signed relay identity/membership facts; this file supplies only optional local endpoint annotations keyed by pubkey. That retains the good secret/lifecycle isolation here without making SSH a prerequisite for visibility or implying an unproven host↔identity relationship. |
Stacked on #3990. The incremental review for this PR is two commits:
feat(desktop): connect self-hosted agents Buzz does not ownrefactor(desktop): split initial window helpersSummary
lib.rsso new command registrations stay within the desktop file-size guardWhy
Some agents already run outside the Buzz desktop process. Buzz should be able to remember and display those identities without taking custody of their keys, command, process lifecycle, or environment.
The additional Tauri commands also pushed the app entrypoint across its file-size limit. The initial-window helpers now live in a focused module with no behavior change.
Validation
cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --checkcargo test --manifest-path desktop/src-tauri/Cargo.toml remote_agent_connect --libcargo test --manifest-path desktop/src-tauri/Cargo.toml connected_agents --libjust desktop-tauri-clippycd desktop && pnpm checkcd desktop && pnpm typecheckcd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/agents/ui/connectAgentIntent.test.mjsgit diff --check origin/pr/remote-harness-discovery..HEAD