From 4438877a6ac7fe93efecb5b8d4bc7bde05adf0b3 Mon Sep 17 00:00:00 2001 From: Mathieu Balez Date: Wed, 1 Jul 2026 10:20:35 -0700 Subject: [PATCH 1/2] Fix false "relay unreachable" error on agent creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The New Agent dialog never pins a per-agent relay, so `create_managed_agent`'s `resolved_relay_url` is empty. Phase 4 (profile sync) passed that empty string straight to `sync_managed_agent_profile`, where `relay_http_base_url("")` returns "" and the profile POST targets a schemeless, hostless `/events`. reqwest cannot connect, and `classify_request_error` buckets the failure as "relay unreachable: could not connect to relay" — surfaced to the user as a false profile-sync error even though the workspace relay is fully reachable. Every other managed-agent relay path (reconcile, rename) resolves the target via `effective_agent_relay_url`, which falls back to the active workspace relay when the per-agent value is blank. The create-time sync was the sole exception. Fix: resolve the sync target through `effective_agent_relay_url` in Phase 4, mirroring the reconcile and rename paths. Hardening: `sync_managed_agent_profile` now rejects a blank/hostless relay URL with a distinct, actionable error instead of the misleading "relay unreachable", so any future caller that forgets to resolve the relay fails loudly. Tests: add regression coverage that a UI-created agent (empty relay_url) resolves the sync target to the reachable workspace host, and that a blank relay yields an empty HTTP base (the trap the new guard catches). Co-authored-by: Mathieu Balez Signed-off-by: Mathieu Balez Co-Authored-By: Claude Opus 4.8 (1M context) --- desktop/src-tauri/src/commands/agents.rs | 14 ++++++- desktop/src-tauri/src/relay.rs | 48 +++++++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index df890931f..b87415663 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -886,9 +886,21 @@ pub async fn create_managed_agent( // ── Phase 4: sync agent profile on relay (async, outside lock) ─────────── // Use the avatar persisted on the record so the published profile and any // later reconciliation agree on the same value. + // + // Resolve the sync target via `effective_agent_relay_url`: an explicit + // per-agent relay wins, and a blank one (the common case — the UI create + // flow never pins a relay) falls back to the active workspace relay. Without + // this, a blank `resolved_relay_url` would produce a hostless `/events` POST + // that fails to connect and surfaces as a false "relay unreachable" error, + // even though the workspace relay is reachable. This mirrors the reconcile + // (`reconcile_agent_profile`) and rename (`update_managed_agent`) paths. + let sync_relay_url = crate::relay::effective_agent_relay_url( + &resolved_relay_url, + &relay_ws_url_with_override(&state), + ); let profile_sync_error = (sync_managed_agent_profile( &state, - &resolved_relay_url, + &sync_relay_url, &agent_keys, &name, resolved_avatar_url.as_deref(), diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index a0fb1c4bd..18f51abef 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -382,12 +382,26 @@ pub async fn sync_managed_agent_profile( avatar_url: Option<&str>, auth_tag: Option<&str>, // NIP-OA auth tag JSON ) -> Result<(), String> { + // Guard against a blank/hostless relay URL. An empty `relay_url` collapses to + // an empty base, producing a schemeless `/events` POST that fails to connect + // and gets misclassified as "relay unreachable" — a false connectivity error + // even though the workspace relay is fine. Callers must resolve the effective + // relay (see `effective_agent_relay_url`) before syncing; fail loudly here so + // any future caller that forgets gets a clear, actionable error instead. + let base = relay_http_base_url(relay_url); + if base.is_empty() { + return Err( + "no relay configured for profile sync (resolve the agent's relay URL first)" + .to_string(), + ); + } + // Build a signed kind:0 profile event (with optional NIP-OA auth tag). let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?; let event_json = event.as_json(); let body_bytes = event_json.into_bytes(); - let url = format!("{}/events", relay_http_base_url(relay_url)); + let url = format!("{}/events", base); let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, &url, &body_bytes)?; let mut request = state @@ -708,6 +722,38 @@ mod tests { ); } + #[test] + fn blank_relay_http_base_is_empty() { + // A blank relay URL has no scheme to rewrite, so the HTTP base collapses + // to empty. This is the trap behind the false "relay unreachable" toast: + // an empty base makes the profile-sync POST target a hostless "/events". + // `sync_managed_agent_profile` now guards against this base explicitly. + assert_eq!(relay_http_base_url(""), ""); + assert_eq!(relay_http_base_url(" "), ""); + } + + #[test] + fn ui_created_agent_blank_relay_resolves_to_reachable_host() { + // Regression: the New Agent dialog never pins a per-agent relay, so the + // create flow's `resolved_relay_url` is empty. `create_managed_agent` + // Phase 4 must resolve the sync target through `effective_agent_relay_url` + // (falling back to the active workspace relay) — exactly as reconcile and + // rename do — before calling `sync_managed_agent_profile`. Before the fix, + // create passed the raw empty string straight through, yielding a hostless + // base and a false "relay unreachable" profile-sync error. + let resolved_relay_url = ""; // UI create flow: no per-agent relay pinned + let workspace_relay = "wss://staging.example.com"; + + let sync_relay_url = effective_agent_relay_url(resolved_relay_url, workspace_relay); + let base = relay_http_base_url(&sync_relay_url); + + assert!( + !base.is_empty(), + "blank per-agent relay must resolve to a real workspace host, not a hostless base" + ); + assert_eq!(base, "https://staging.example.com"); + } + // ── classify_intercepted_response ──────────────────────────────────────── #[test] From 74651f5f4bdb9cd13f60de340678d7acfcb24ce3 Mon Sep 17 00:00:00 2001 From: Mathieu Balez Date: Thu, 2 Jul 2026 16:59:56 -0700 Subject: [PATCH 2/2] Address review: trim verbose comments, fix guard-comment accuracy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Wes's review: - Collapse the Phase 4, guard, and test comment blocks to a single line each — house style keeps the "why" in the PR, not restated in source. - Guard comment no longer claims "blank/hostless" coverage; only blank is caught (a schemed-but-hostless input like "wss://" is a pre-existing edge). No behavior change — comments and test docs only. Co-Authored-By: Claude Opus 4.8 (1M context) --- desktop/src-tauri/src/commands/agents.rs | 9 +-------- desktop/src-tauri/src/relay.rs | 21 ++++----------------- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index b87415663..6df9a58c0 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -886,14 +886,7 @@ pub async fn create_managed_agent( // ── Phase 4: sync agent profile on relay (async, outside lock) ─────────── // Use the avatar persisted on the record so the published profile and any // later reconciliation agree on the same value. - // - // Resolve the sync target via `effective_agent_relay_url`: an explicit - // per-agent relay wins, and a blank one (the common case — the UI create - // flow never pins a relay) falls back to the active workspace relay. Without - // this, a blank `resolved_relay_url` would produce a hostless `/events` POST - // that fails to connect and surfaces as a false "relay unreachable" error, - // even though the workspace relay is reachable. This mirrors the reconcile - // (`reconcile_agent_profile`) and rename (`update_managed_agent`) paths. + // Blank per-agent relay falls back to the workspace relay (matches reconcile/rename). let sync_relay_url = crate::relay::effective_agent_relay_url( &resolved_relay_url, &relay_ws_url_with_override(&state), diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 18f51abef..5d3f90923 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -382,12 +382,7 @@ pub async fn sync_managed_agent_profile( avatar_url: Option<&str>, auth_tag: Option<&str>, // NIP-OA auth tag JSON ) -> Result<(), String> { - // Guard against a blank/hostless relay URL. An empty `relay_url` collapses to - // an empty base, producing a schemeless `/events` POST that fails to connect - // and gets misclassified as "relay unreachable" — a false connectivity error - // even though the workspace relay is fine. Callers must resolve the effective - // relay (see `effective_agent_relay_url`) before syncing; fail loudly here so - // any future caller that forgets gets a clear, actionable error instead. + // Fail loudly on a blank relay URL — callers must resolve via `effective_agent_relay_url` first. let base = relay_http_base_url(relay_url); if base.is_empty() { return Err( @@ -724,23 +719,15 @@ mod tests { #[test] fn blank_relay_http_base_is_empty() { - // A blank relay URL has no scheme to rewrite, so the HTTP base collapses - // to empty. This is the trap behind the false "relay unreachable" toast: - // an empty base makes the profile-sync POST target a hostless "/events". - // `sync_managed_agent_profile` now guards against this base explicitly. + // A blank relay URL has no scheme to rewrite, so the HTTP base is empty. assert_eq!(relay_http_base_url(""), ""); assert_eq!(relay_http_base_url(" "), ""); } #[test] fn ui_created_agent_blank_relay_resolves_to_reachable_host() { - // Regression: the New Agent dialog never pins a per-agent relay, so the - // create flow's `resolved_relay_url` is empty. `create_managed_agent` - // Phase 4 must resolve the sync target through `effective_agent_relay_url` - // (falling back to the active workspace relay) — exactly as reconcile and - // rename do — before calling `sync_managed_agent_profile`. Before the fix, - // create passed the raw empty string straight through, yielding a hostless - // base and a false "relay unreachable" profile-sync error. + // A blank per-agent relay (the UI create case) resolves through + // `effective_agent_relay_url` to a real workspace host, not a hostless base. let resolved_relay_url = ""; // UI create flow: no per-agent relay pinned let workspace_relay = "wss://staging.example.com";