From 89c9a2292f91177aaab819aff39029d61e68f38c Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 18 Jun 2026 22:35:31 -0400 Subject: [PATCH 1/5] fix(desktop): make local agents always use the workspace relay Local managed agents are an invariant-workspace-relay class, but several relay operations read each record's frozen per-record relay_url instead of the session's workspace relay. A record minted under an old build froze at ws://localhost:3000, so #933's boot reconcile loop hit a dead relay and printed a "relay unreachable" line on every just staging run. Fix it at read time, with no on-disk migration (goose is a valid runtime and rewriting relay_url would ping-pong the #1121-symlinked shared file across worktrees). A new effective_agent_relay_url helper centralizes the local-vs-remote decision; reconcile, spawn (BUZZ_RELAY_URL + git-credential URL), and the rename re-sync all route local agents to the workspace relay while remote (Provider) agents keep their per-record relay. The create path forces the workspace relay for local agents so the invariant holds at birth, and the picker defaults new agents to buzz-agent. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/scripts/check-file-sizes.mjs | 2 +- .../src-tauri/src/commands/agent_models.rs | 9 ++- desktop/src-tauri/src/commands/agents.rs | 46 ++++++++--- .../src-tauri/src/managed_agents/restore.rs | 1 + .../src-tauri/src/managed_agents/runtime.rs | 18 ++++- desktop/src-tauri/src/relay.rs | 77 +++++++++++++++++-- .../features/agents/ui/CreateAgentDialog.tsx | 4 +- 7 files changed, 134 insertions(+), 23 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 28af882295..ff664059c4 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -32,7 +32,7 @@ const rules = [ const overrides = new Map([ ["src-tauri/src/commands/agents.rs", 1294], ["src-tauri/src/managed_agents/nest.rs", 1420], - ["src-tauri/src/managed_agents/runtime.rs", 1940], + ["src-tauri/src/managed_agents/runtime.rs", 1953], ["src-tauri/src/managed_agents/personas.rs", 1080], ["src-tauri/src/managed_agents/persona_card.rs", 1050], ["src/shared/api/tauri.ts", 1196], diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 1c8b9925fb..08a4c50119 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -248,7 +248,14 @@ pub async fn update_managed_agent( let sync_params = if name_changed { let agent_keys = Keys::parse(&record.private_key_nsec) .map_err(|e| format!("failed to parse agent keys: {e}"))?; - let relay_url = record.relay_url.clone(); + // Local agents always live on the workspace relay; re-publish the + // renamed profile there rather than to a possibly-stale per-record + // relay. Mirrors reconcile and spawn for the same invariant. + let relay_url = crate::relay::effective_agent_relay_url( + record.backend == crate::managed_agents::BackendKind::Local, + &relay_ws_url_with_override(&state), + &record.relay_url, + ); let display_name = record.name.clone(); let avatar_url = record .avatar_url diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index dc64dc8266..cfa2488d0e 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -384,13 +384,20 @@ pub async fn create_managed_agent( .to_bech32() .map_err(|error| format!("failed to encode private key: {error}"))?; - let resolved_relay_url = input - .relay_url - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| relay_ws_url_with_override(&state)); + // Local agents always live on the workspace relay — ignore any + // user-supplied relay_url so the local-relay invariant holds at birth. + // Remote (Provider) agents legitimately set their own relay. + let resolved_relay_url = if input.backend == BackendKind::Local { + relay_ws_url_with_override(&state) + } else { + input + .relay_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| relay_ws_url_with_override(&state)) + }; (keys, private_key_nsec, pubkey, resolved_relay_url, input) }; @@ -732,6 +739,10 @@ pub(crate) struct ProfileReconcileData { pub(crate) private_key_nsec: String, pub(crate) name: String, pub(crate) relay_url: String, + /// Whether this agent uses the `Local` backend. Local agents always live on + /// the workspace relay, so reconciliation targets the workspace relay for + /// them rather than the (possibly stale) per-record `relay_url`. + pub(crate) backend_is_local: bool, /// Expected avatar URL for the published profile. `None` for legacy records /// that predate the `avatar_url` field — these will be backfilled from the /// relay's existing kind:0 profile on first reconciliation. @@ -790,6 +801,7 @@ pub async fn start_managed_agent( private_key_nsec: record.private_key_nsec.clone(), name: record.name.clone(), relay_url: record.relay_url.clone(), + backend_is_local: record.backend == BackendKind::Local, avatar_url: record.avatar_url.clone(), auth_tag: record.auth_tag.clone(), pubkey: record.pubkey.clone(), @@ -908,9 +920,11 @@ fn resolve_legacy_avatar( /// profile — and persists the updated record. After backfill, normal /// reconciliation proceeds. /// -/// Query and publish both target the agent's stored `relay_url` so that, under -/// an active workspace relay override, reconciliation reads and writes the same -/// relay the agent's profile actually lives on. +/// Query and publish target the workspace relay for `Local` agents (which always +/// live on the workspace relay) and the agent's stored `relay_url` for remote +/// agents (whose profile lives on a per-record relay). For local agents this +/// makes reconciliation follow the session's relay rather than a frozen +/// per-record value that may have drifted from where the agent actually runs. pub(crate) async fn reconcile_agent_profile( state: &AppState, app: &AppHandle, @@ -919,8 +933,16 @@ pub(crate) async fn reconcile_agent_profile( ) -> Result<(), String> { use crate::relay::{query_agent_profile, sync_managed_agent_profile}; + // Local agents always live on the workspace relay; remote agents keep their + // per-record relay. Resolved once and used for both the read and write-back. + let relay_url = crate::relay::effective_agent_relay_url( + data.backend_is_local, + &relay_ws_url_with_override(state), + &data.relay_url, + ); + // Query the relay for the agent's existing kind:0 profile. - let existing = query_agent_profile(state, &data.relay_url, agent_pubkey).await?; + let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?; // Resolve the expected avatar — backfilling for legacy records that have no // stored avatar_url yet. @@ -974,7 +996,7 @@ pub(crate) async fn reconcile_agent_profile( sync_managed_agent_profile( state, - &data.relay_url, + &relay_url, &agent_keys, &data.name, Some(&expected_avatar), diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 3469dc80d7..6ed8c6754d 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -209,6 +209,7 @@ pub async fn restore_managed_agents_on_launch( private_key_nsec: record.private_key_nsec.clone(), name: record.name.clone(), relay_url: record.relay_url.clone(), + backend_is_local: record.backend == BackendKind::Local, avatar_url: record.avatar_url.clone(), auth_tag: record.auth_tag.clone(), pubkey: record.pubkey.clone(), diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 328b888fd5..2deb633f1e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1519,6 +1519,20 @@ pub fn spawn_agent_child( .map(|p| p.display().to_string()) .unwrap_or_else(|| record.agent_command.clone()); + // Local agents always live on the workspace relay; resolve once so the + // child connects (BUZZ_RELAY_URL) and authenticates git (credential-helper + // URL) on the host reconciliation targets, never a stale per-record value. + // Remote agents keep their per-record relay. + let effective_relay_url = { + use tauri::Manager; + let state = app.state::(); + crate::relay::effective_agent_relay_url( + record.backend == crate::managed_agents::BackendKind::Local, + &crate::relay::relay_ws_url_with_override(&state), + &record.relay_url, + ) + }; + // Augment PATH for DMG launches so child processes can find: // - bundled CLI via ~/.local/bin symlink // - bundled sidecars (buzz, buzz-acp, etc.) via exe parent (Contents/MacOS/) @@ -1558,7 +1572,7 @@ pub fn spawn_agent_child( } command.env("RUST_LOG", child_rust_log_filter()); command.env("BUZZ_PRIVATE_KEY", &record.private_key_nsec); - command.env("BUZZ_RELAY_URL", &record.relay_url); + command.env("BUZZ_RELAY_URL", &effective_relay_url); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); match &resolved_mcp_command { @@ -1681,7 +1695,7 @@ pub fn spawn_agent_child( // // NOSTR_PRIVATE_KEY mirrors BUZZ_PRIVATE_KEY — keep in sync. if let Some(cred_helper) = resolve_command("git-credential-nostr") { - let relay_http_url = crate::relay::relay_http_base_url(&record.relay_url); + let relay_http_url = crate::relay::relay_http_base_url(&effective_relay_url); command.env("NOSTR_PRIVATE_KEY", &record.private_key_nsec); command.env("GIT_TERMINAL_PROMPT", "0"); diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 1ee3e8505f..0fe9660540 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -49,6 +49,25 @@ pub fn relay_api_base_url_with_override(state: &AppState) -> String { } } +/// Selects the relay a managed agent should use for a relay operation. +/// +/// Local agents always live on the workspace relay, so their frozen per-record +/// `relay_url` is ignored — this is the invariant that lets a local record +/// carrying a stale relay still reconcile, spawn, and re-sync on the session's +/// relay. Remote (Provider) agents keep their per-record relay, where their +/// profile genuinely lives. +pub fn effective_agent_relay_url( + is_local: bool, + workspace_relay: &str, + record_relay: &str, +) -> String { + if is_local { + workspace_relay.to_string() + } else { + record_relay.to_string() + } +} + pub fn relay_http_base_url(relay_url: &str) -> String { let trimmed = relay_url.trim().trim_end_matches('/'); @@ -405,9 +424,10 @@ pub async fn sync_managed_agent_profile( /// Query the relay for an agent's kind:0 profile event. /// -/// Queries the relay identified by `relay_url` (typically the agent's stored -/// `relay_url`) so the query targets the same host the profile is published to, -/// even when a workspace relay override is active. +/// Queries the relay identified by `relay_url`. Callers pass the workspace relay +/// for `Local` agents (which always live on the workspace relay) and the agent's +/// stored per-record `relay_url` for remote agents, so the query targets the +/// host the profile is actually published to. /// /// Returns the parsed profile content (display_name, picture) if a kind:0 event /// exists for the given pubkey, or `None` if no profile is published. @@ -554,11 +574,58 @@ pub async fn submit_event_with_keys( #[cfg(test)] mod tests { use super::{ - build_profile_event, classify_intercepted_response, parse_command_response, - relay_http_base_url, + build_profile_event, classify_intercepted_response, effective_agent_relay_url, + parse_command_response, relay_http_base_url, }; use serde::Deserialize; + // ── effective_agent_relay_url: the local-agent relay invariant ─────────── + + #[test] + fn local_agent_uses_workspace_relay_ignoring_stale_record() { + // The bug: a local record frozen at a dead relay must reconcile/spawn on + // the session's workspace relay, never the stale stored value. + assert_eq!( + effective_agent_relay_url(true, "wss://staging.example.com", "ws://localhost:3000"), + "wss://staging.example.com" + ); + } + + #[test] + fn local_agent_uses_workspace_relay_even_when_record_already_matches() { + // No special-casing when the stored value happens to already be correct. + assert_eq!( + effective_agent_relay_url( + true, + "wss://staging.example.com", + "wss://staging.example.com" + ), + "wss://staging.example.com" + ); + } + + #[test] + fn remote_agent_keeps_its_per_record_relay() { + // Remote (Provider) profiles genuinely live on a per-record relay that + // differs from the workspace — that relay must be preserved. + assert_eq!( + effective_agent_relay_url(false, "wss://staging.example.com", "wss://relay.other.com"), + "wss://relay.other.com" + ); + } + + #[test] + fn remote_agent_relay_unchanged_even_when_equal_to_workspace() { + assert_eq!( + effective_agent_relay_url( + false, + "wss://staging.example.com", + "wss://staging.example.com" + ), + "wss://staging.example.com" + ); + } + // ── relay_http_base_url loopback normalization ─────────────────────────── #[test] diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index 37f135031e..52e74ad1a4 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -57,7 +57,7 @@ export function CreateAgentDialog({ const backendProvidersQuery = useBackendProvidersQuery(); const { lastRuntimeId, setLastRuntime } = useLastRuntime(); const [acpCommand, setAcpCommand] = React.useState("buzz-acp"); - const [agentCommand, setAgentCommand] = React.useState("goose"); + const [agentCommand, setAgentCommand] = React.useState("buzz-agent"); const [agentArgs, setAgentArgs] = React.useState("acp"); const [mcpCommand, setMcpCommand] = React.useState(""); const [mcpToolsets, setMcpToolsets] = React.useState(""); @@ -236,7 +236,7 @@ export function CreateAgentDialog({ setSpawnAfterCreate(true); setStartOnAppLaunch(true); setAcpCommand("buzz-acp"); - setAgentCommand("goose"); + setAgentCommand("buzz-agent"); setAgentArgs("acp"); setMcpCommand(""); setMcpToolsets(""); From 2615e557c569bb60fa6334eb7b25d10942034d39 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 22 Jun 2026 13:44:24 -0400 Subject: [PATCH 2/5] fix(desktop): hide per-agent relay URL for local agents and relabel malformed responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit made local agents resolve the workspace relay at every relay touchpoint, but the Create/Edit Agent dialogs still rendered an editable "Relay URL" field whose value is now ignored for local agents — a user could type a relay, save, and have it silently dropped. Gate that field behind a single isProviderMode predicate so it only appears for Provider agents, where a per-record relay genuinely lives. Create derives the flag from its runtime toggle; Edit derives it from the existing record's backend, since an agent's backend is fixed once created. Both call sites pass the same boolean, so the gate is one predicate to review. Also re-prefix a successful-but-undeserializable relay response from "relay unreachable:" to "relay returned malformed response:". A reached-but-malformed body (protocol mismatch, relay bug, corrupted body) is not a connectivity failure, so it must fall outside the unreachable bucket the frontend classifier keys on and surface loudly instead of being mistaken for a transient outage. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/relay.rs | 9 +++- .../features/agents/ui/CreateAgentDialog.tsx | 1 + .../agents/ui/CreateAgentDialogSections.tsx | 46 +++++++++++-------- .../features/agents/ui/EditAgentDialog.tsx | 6 +++ 4 files changed, 40 insertions(+), 22 deletions(-) diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 0fe9660540..6d5ce15657 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -230,11 +230,16 @@ pub(crate) async fn parse_json_response( return Err(msg); } - // Drop the reqwest error detail — it contains the raw URL. + // A successful HTTP response whose body fails to deserialize means the relay + // was reached but returned something unexpected (protocol mismatch, relay bug, + // corrupted body) — NOT a connectivity failure. Keep it off the + // "relay unreachable:" bucket so it surfaces loudly instead of being treated + // as a transient unreachable-relay condition. The reqwest error detail is + // dropped because it contains the raw URL. response .json::() .await - .map_err(|_| "relay unreachable: response was not valid JSON".to_string()) + .map_err(|_| "relay returned malformed response: not valid JSON".to_string()) } pub async fn relay_error_message(response: reqwest::Response) -> String { diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index 52e74ad1a4..a5e099bf20 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -602,6 +602,7 @@ export function CreateAgentDialog({ acpCommand={acpCommand} agentArgs={agentArgs} agentCommand={agentCommand} + isProviderMode={isProviderMode} mcpCommand={mcpCommand} mcpToolsets={mcpToolsets} onParallelismChange={setParallelism} diff --git a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx index 9dc19d9fba..ab4d37b8b3 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx @@ -105,6 +105,7 @@ export function CreateAgentRuntimeFields({ acpCommand, agentArgs, agentCommand, + isProviderMode, mcpCommand, mcpToolsets, parallelism, @@ -125,6 +126,7 @@ export function CreateAgentRuntimeFields({ acpCommand: string; agentArgs: string; agentCommand: string; + isProviderMode: boolean; mcpCommand: string; mcpToolsets: string; parallelism: string; @@ -145,26 +147,30 @@ export function CreateAgentRuntimeFields({ return ( <>
-
- - onRelayUrlChange(event.target.value)} - placeholder="Leave blank to use the desktop relay" - value={relayUrl} - /> -

- WebSocket URL of the relay this agent connects to. Leave blank to - use the built-in desktop relay. -

-
+ {/* Relay URL is a per-record value that only Provider agents honor; + local agents always use the workspace relay, so hide it for them. */} + {isProviderMode ? ( +
+ + onRelayUrlChange(event.target.value)} + placeholder="Leave blank to use the desktop relay" + value={relayUrl} + /> +

+ WebSocket URL of the relay this agent connects to. Leave blank to + use the built-in desktop relay. +

+
+ ) : null}