diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 25dadbeec6..46d069c8a5 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -332,10 +332,12 @@ pub async fn restore_managed_agents_on_launch( // mid-turn session is not resumed by an // eager child — and silently reintroduces // N idle brains on every launch. + // Configured URL, not `key.relay_url`: + // the canonical form is identity-only. spawn_agent_child( app, record, - &key.relay_url, + &relay_url, true, owner_hex_ref, ) diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index ec804869c4..429c9c7168 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -68,6 +68,9 @@ mod lifecycle; use lifecycle::kill_stale_tracked_processes_with; pub use lifecycle::{kill_stale_tracked_processes, sync_managed_agent_processes}; +mod spawn_relay; +use spawn_relay::spawn_relay_roles; + /// Classify an agent's persona against the live catalog for the Agents-menu /// drift indicator. Returns `(out_of_date, orphaned)`. /// @@ -399,6 +402,9 @@ pub(crate) fn configure_runtime_cli( /// Returns the child process and log path on success. The caller is responsible /// for updating `ManagedAgentRecord` fields and inserting into the runtimes map. /// +/// `relay_url`: the workspace-resolved relay URL **as configured** — handed to +/// the child verbatim; see `spawn_relay_roles` for the identity/connection split. +/// /// `owner_hex`: the workspace owner's pubkey, used as a fallback for legacy /// records that have no NIP-OA `auth_tag`. See `build_respond_to_env`. pub fn spawn_agent_child( @@ -411,7 +417,10 @@ pub fn spawn_agent_child( if let Some(error) = spawn_key_refusal(record) { return Err(error); } - let runtime_key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; + // `effective_relay_url` is the configured URL, handed to the child verbatim; + // `runtime_key` is canonical and identity-only (log path, spawn-config + // hash). See `spawn_relay_roles` for why the two must not be conflated. + let (runtime_key, effective_relay_url) = spawn_relay_roles(record.pubkey.clone(), relay_url)?; // Resolve the effective harness (agent command) from the linked persona, so // persona harness edits propagate on the next spawn; an explicit per-agent // override wins. `agent_args` and `mcp_command` are pure derivations of the @@ -496,10 +505,6 @@ pub fn spawn_agent_child( .map(|p| p.display().to_string()) .unwrap_or_else(|| effective_command.clone()); - // The caller supplies the explicit canonical pair relay. This is the only - // relay this child may connect to, regardless of the record/workspace default. - let effective_relay_url = runtime_key.relay_url.clone(); - // Augment PATH for DMG launches so child processes can find: // - bundled CLI via ~/.local/bin symlink // - nvm-managed node/npm (nvm initializes only in interactive shells) @@ -839,7 +844,15 @@ pub fn spawn_agent_child( super::spawn_snapshot::SpawnConfigInputs { record, descriptor: &descriptor, - relay_url: &effective_relay_url, + // CANONICAL pair relay, not the connection URL: the prospective + // side (`prospective_spawn_config_snapshot`, via + // `build_managed_agent_summary`) recomputes with the canonical + // `workspace_pair_key(...).relay_url`, so stamping the configured + // spelling would flag a permanent spurious restart whenever the + // two differ. Known boundary: a spelling-only relay edit (same + // canonical form) therefore does not badge, even though the + // child's connection host follows the configured spelling. + relay_url: &runtime_key.relay_url, team_instructions: team_instructions.as_deref(), system_prompt: effective_prompt.as_deref(), model: effective_model.as_deref(), @@ -951,7 +964,8 @@ pub fn start_managed_agent_process( // Scalar PIDs are migration-only and never establish pair liveness. record.runtime_pid = None; - let mut process = spawn_agent_child(app, record, &key.relay_url, false, owner_hex)?; + // Configured URL, not the identity-only canonical `key.relay_url`. + let mut process = spawn_agent_child(app, record, &relay_url, false, owner_hex)?; let now = now_iso(); let receipt = super::ManagedAgentRuntimeReceipt { key: key.clone(), diff --git a/desktop/src-tauri/src/managed_agents/runtime/spawn_relay.rs b/desktop/src-tauri/src/managed_agents/runtime/spawn_relay.rs new file mode 100644 index 0000000000..88f14620a1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/spawn_relay.rs @@ -0,0 +1,54 @@ +use crate::managed_agents::ManagedAgentRuntimeKey; + +/// Split a spawn's relay input into its two roles: the canonical pair key +/// (identity) and the URL the child actually connects with. +/// +/// The runtime key canonicalizes via `buzz_core::relay::normalize_relay_url`, +/// which folds every loopback spelling to `127.0.0.1`. That is correct for +/// identity, receipts, log paths, and dedup — and explicitly NOT for +/// connections: the normalizer's own contract says "Connection code may +/// retain the configured URL; this canonical form is for identity, receipts, +/// status and deduplication." On a per-host multi-tenant relay, +/// `ws://localhost:3100` and `ws://127.0.0.1:3100` resolve to *different +/// communities*, so handing the canonical form to the child connects the +/// agent to a different (typically empty) community than the desktop's own +/// traffic — the harness logs "discovered 0 channel(s)" and idles while the +/// UI writes memberships to a tenant the agent never sees. The child +/// therefore gets the configured URL byte-for-byte. +pub(super) fn spawn_relay_roles( + pubkey: String, + configured_relay_url: &str, +) -> Result<(ManagedAgentRuntimeKey, String), String> { + let key = ManagedAgentRuntimeKey::new(pubkey, configured_relay_url)?; + Ok((key, configured_relay_url.to_string())) +} + +#[cfg(test)] +mod tests { + use super::spawn_relay_roles; + + #[test] + fn spawn_relay_roles_keeps_configured_url_for_the_child() { + // Identity canonicalizes loopback spellings to 127.0.0.1; the connection + // URL handed to the child must stay exactly as configured. On a per-host + // multi-tenant relay `ws://localhost:3100` and `ws://127.0.0.1:3100` are + // different communities, so folding the child's URL strands the agent in + // an empty parallel tenant ("discovered 0 channel(s)", idles) while the + // desktop's own traffic — and the memberships the user creates in the UI — + // land under the configured host. + let (key, connection) = + spawn_relay_roles("a".repeat(64), "ws://localhost:3100").expect("valid relay URL"); + assert_eq!(key.relay_url, "ws://127.0.0.1:3100"); + assert_eq!(connection, "ws://localhost:3100"); + } + + #[test] + fn spawn_relay_roles_agree_for_non_loopback_hosts() { + // For real deployments the two roles agree (modulo canonical lowercasing), + // which is why this bug was invisible against hosted relays. + let (key, connection) = + spawn_relay_roles("b".repeat(64), "wss://relay.example.com").expect("valid relay URL"); + assert_eq!(key.relay_url, "wss://relay.example.com"); + assert_eq!(connection, "wss://relay.example.com"); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b1..ecc2100470 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -283,7 +283,9 @@ fn start_pair( .lock() .ok() .map(|keys| keys.public_key().to_hex()); - let mut process = spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref())?; + // Pass the configured URL, not `key.relay_url` — the canonical form is + // identity-only and must not become the child's connection target. + let mut process = spawn_agent_child(&app, record, &relay_url, lazy, owner.as_deref())?; let now = crate::util::now_iso(); let receipt = ManagedAgentRuntimeReceipt { key: key.clone(),