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..8a4e8401b5 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -188,13 +188,11 @@ pub async fn update_managed_agent( if let Some(turn_timeout_seconds) = input.turn_timeout_seconds { record.turn_timeout_seconds = turn_timeout_seconds; } + // Store the relay override exactly as supplied (trimmed). An explicit + // value pins the agent; empty falls back to the workspace relay at + // read-time. A name-only edit (relay_url == None) leaves the pin intact. if let Some(relay_url) = input.relay_url { - let trimmed = relay_url.trim(); - record.relay_url = if trimmed.is_empty() { - relay_ws_url_with_override(&state) - } else { - trimmed.to_string() - }; + record.relay_url = relay_url.trim().to_string(); } if let Some(acp_command) = input.acp_command { record.acp_command = acp_command; @@ -248,7 +246,12 @@ 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(); + // Re-publish the renamed profile to the agent's effective relay: + // an explicit per-agent relay wins; empty falls back to workspace. + let relay_url = crate::relay::effective_agent_relay_url( + &record.relay_url, + &relay_ws_url_with_override(&state), + ); 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..5112c3ead5 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -145,6 +145,7 @@ async fn start_local_agent_with_preflight( /// empty map would surface as an opaque 401 from the provider. fn build_deploy_payload( app: &AppHandle, + state: &AppState, record: &ManagedAgentRecord, ) -> Result { // Merge persona env_vars + agent env_vars for provider deploy. Same @@ -174,7 +175,15 @@ fn build_deploy_payload( Ok(serde_json::json!({ "name": &record.name, - "relay_url": &record.relay_url, + // Resolve the per-agent pin against the active workspace relay here: + // this payload crosses the host boundary to a remote provider harness + // that has no notion of the desktop's workspace, so the blank→workspace + // fallback (otherwise applied at read-time in `effective_agent_relay_url`) + // must be materialized into a concrete URL before serializing. + "relay_url": crate::relay::effective_agent_relay_url( + &record.relay_url, + &relay_ws_url_with_override(state), + ), "private_key_nsec": &record.private_key_nsec, "auth_tag": &record.auth_tag, "agent_command": &record.agent_command, @@ -384,13 +393,15 @@ pub async fn create_managed_agent( .to_bech32() .map_err(|error| format!("failed to encode private key: {error}"))?; + // Store the relay override exactly as supplied (trimmed). An explicit + // value pins the agent; empty stays empty and resolves to the active + // workspace relay at read-time. Uniform for Local and Provider. 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)); + .unwrap_or("") + .to_string(); (keys, private_key_nsec, pubkey, resolved_relay_url, input) }; @@ -665,7 +676,7 @@ pub async fn create_managed_agent( .iter() .find(|r| r.pubkey == pubkey) .ok_or_else(|| "agent disappeared".to_string())?; - build_deploy_payload(&app, rec) + build_deploy_payload(&app, &state, rec) }; // The agent was already persisted in Phase 3 — converting a // persona-resolution failure into `spawn_error` (rather than @@ -803,7 +814,7 @@ pub async fn start_managed_agent( StartTarget::Provider { backend: record.backend.clone(), cached_binary_path: record.provider_binary_path.clone(), - agent_json: build_deploy_payload(&app, record)?, + agent_json: build_deploy_payload(&app, &state, record)?, } }; @@ -908,9 +919,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 relay returned by `effective_agent_relay_url` +/// for every agent regardless of backend: an explicit per-agent `relay_url` +/// wins, and a blank one falls back to the active workspace relay. This keeps +/// reconciliation following the session's relay for never-pinned agents while +/// honoring a deliberate pin wherever it points. pub(crate) async fn reconcile_agent_profile( state: &AppState, app: &AppHandle, @@ -919,8 +932,15 @@ pub(crate) async fn reconcile_agent_profile( ) -> Result<(), String> { use crate::relay::{query_agent_profile, sync_managed_agent_profile}; + // An explicit per-agent relay wins; an empty one falls back to the active + // workspace relay. Resolved once and used for both the read and write-back. + let relay_url = crate::relay::effective_agent_relay_url( + &data.relay_url, + &relay_ws_url_with_override(state), + ); + // 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 +994,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/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 328b888fd5..b85b4e51e4 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1519,6 +1519,18 @@ pub fn spawn_agent_child( .map(|p| p.display().to_string()) .unwrap_or_else(|| record.agent_command.clone()); + // The agent's effective relay drives both the child's relay connection + // (BUZZ_RELAY_URL) and git credential-helper URL: an explicit per-agent + // relay wins; an empty one falls back to the active workspace relay. + let effective_relay_url = { + use tauri::Manager; + let state = app.state::(); + crate::relay::effective_agent_relay_url( + &record.relay_url, + &crate::relay::relay_ws_url_with_override(&state), + ) + }; + // 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 +1570,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 +1693,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..35f605e855 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -11,6 +11,11 @@ use crate::app_state::AppState; const DEFAULT_RELAY_WS_URL: &str = "ws://localhost:3000"; +// A reached-but-malformed 2xx body is NOT a connectivity failure, so this +// message must never carry the "relay unreachable:" prefix the frontend +// classifier keys on. Extracted to a const so a test can pin that contract. +const MALFORMED_RESPONSE_MESSAGE: &str = "relay returned malformed response: not valid JSON"; + fn configured_env_var(name: &str) -> Option { std::env::var(name) .ok() @@ -49,6 +54,23 @@ pub fn relay_api_base_url_with_override(state: &AppState) -> String { } } +/// Selects the relay a managed agent should use for a relay operation. +/// +/// An explicit per-agent `relay_url` always wins (highest precedence), pinning +/// the agent to that relay regardless of the active workspace. An empty or +/// whitespace-only `relay_url` falls back to the active workspace relay, which +/// resolves at read-time so a never-set record reconciles, spawns, and re-syncs +/// on the session's relay instead of a stale stored value. Uniform for both +/// Local and Provider backends. +pub fn effective_agent_relay_url(record_relay: &str, workspace_relay: &str) -> String { + let pinned = record_relay.trim(); + if pinned.is_empty() { + workspace_relay.to_string() + } else { + pinned.to_string() + } +} + pub fn relay_http_base_url(relay_url: &str) -> String { let trimmed = relay_url.trim().trim_end_matches('/'); @@ -211,11 +233,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(|_| MALFORMED_RESPONSE_MESSAGE.to_string()) } pub async fn relay_error_message(response: reqwest::Response) -> String { @@ -405,9 +432,11 @@ 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 uniformly pass the +/// relay resolved by `effective_agent_relay_url` for every agent regardless of +/// backend — an explicit per-agent pin, or the active workspace relay when the +/// agent has none — 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 +583,51 @@ 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, MALFORMED_RESPONSE_MESSAGE, }; use serde::Deserialize; + // ── effective_agent_relay_url: per-agent override precedence ───────────── + + #[test] + fn explicit_relay_wins_over_workspace() { + // An explicit per-agent relay pins the agent there regardless of the + // active workspace — this is the override taking highest precedence. + assert_eq!( + effective_agent_relay_url("wss://relay.other.com", "wss://staging.example.com"), + "wss://relay.other.com" + ); + } + + #[test] + fn explicit_relay_wins_even_when_equal_to_workspace() { + // No special-casing when the pin happens to match the active workspace. + assert_eq!( + effective_agent_relay_url("wss://staging.example.com", "wss://staging.example.com"), + "wss://staging.example.com" + ); + } + + #[test] + fn empty_relay_falls_back_to_workspace() { + // A never-set record resolves to the active workspace relay at read-time, + // so a stale stored default can never make it load-bearing. + assert_eq!( + effective_agent_relay_url("", "wss://staging.example.com"), + "wss://staging.example.com" + ); + } + + #[test] + fn whitespace_only_relay_falls_back_to_workspace() { + // Whitespace-only is treated as unset, same as empty. + assert_eq!( + effective_agent_relay_url(" ", "wss://staging.example.com"), + "wss://staging.example.com" + ); + } + // ── relay_http_base_url loopback normalization ─────────────────────────── #[test] @@ -676,6 +745,19 @@ mod tests { // classify_request_error requires a real reqwest::Error (not publicly // constructable) — tested indirectly through integration; skipped here. + // ── parse_json_response malformed-body contract ────────────────────────── + + #[test] + fn malformed_response_message_stays_off_unreachable_bucket() { + // A reached-but-malformed 2xx body is not a connectivity failure. If this + // message ever regains the "relay unreachable:" prefix, the frontend + // classifier would misroute it as unreachable — pin that it never does. + assert!( + !MALFORMED_RESPONSE_MESSAGE.starts_with("relay unreachable:"), + "malformed-response message must not match the unreachable prefix" + ); + } + // ── parse_command_response ─────────────────────────────────────────────── #[derive(Debug, Deserialize, PartialEq)] 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(""); diff --git a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx index 9dc19d9fba..f8be21c2ef 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx @@ -145,6 +145,8 @@ export function CreateAgentRuntimeFields({ return ( <>
+ {/* Relay URL pins the agent to a specific relay; blank falls back to + the active workspace relay. Shown for all agents. */}
diff --git a/desktop/src/shared/lib/relayError.test.mjs b/desktop/src/shared/lib/relayError.test.mjs index da38078029..356ddf4c19 100644 --- a/desktop/src/shared/lib/relayError.test.mjs +++ b/desktop/src/shared/lib/relayError.test.mjs @@ -31,6 +31,18 @@ test("isRelayUnreachableError: unrelated string returns false", () => { assert.equal(isRelayUnreachableError("something went wrong"), false); }); +test("isRelayUnreachableError: malformed-response message returns false", () => { + // The backend relabels a reached-but-malformed 2xx body to this exact string + // so it drops out of the unreachable bucket. Pin that the classifier agrees — + // if the backend re-prefixes it, this catches the misroute. + assert.equal( + isRelayUnreachableError( + "relay returned malformed response: not valid JSON", + ), + false, + ); +}); + test("isRelayUnreachableError: null returns false", () => { assert.equal(isRelayUnreachableError(null), false); });