diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index fab481e12b..dfd456cbb3 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -320,10 +320,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. + // Requested URL, not the canonical + // key: the child dials this. 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 44cee49aeb..72e4c3f422 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1692,9 +1692,17 @@ 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(); + // The caller supplies the explicit pair relay. This is the only relay this + // child may connect to, regardless of the record/workspace default. + // + // Dial the URL as requested, NOT `runtime_key.relay_url`. The key is an + // identity: `ManagedAgentRuntimeKey::new` canonicalizes it (folding loopback + // to 127.0.0.1) so `runtime_id()` stays stable, which is right for keying + // and wrong for addressing. The relay resolves a community from the literal + // request Host and fails closed on an unmapped one, so a deployment + // configured as `ws://localhost:PORT` rejects a canonicalized + // `ws://127.0.0.1:PORT` with a generic 404 and the harness never connects. + let effective_relay_url = relay_url.to_string(); // Augment PATH for DMG launches so child processes can find: // - bundled CLI via ~/.local/bin symlink @@ -2045,13 +2053,19 @@ pub fn spawn_agent_child( // Stamp the effective spawn config so the summary builder can flag // needs_restart when disk state drifts from what this process runs. - // `effective_relay_url` is already resolved, and resolution is idempotent, - // so it serves as the workspace-relay input here. + // + // Hash the CANONICAL pair URL, not the dialed one. `needs_restart` + // recomputes this hash from `key.relay_url` (the canonical identity), so + // feeding the requested spelling here would make the two disagree for any + // host the key folds (e.g. `localhost` vs `127.0.0.1`) and report + // needs_restart forever. The relay a child dials and the config it was + // spawned with are separate concerns: dial the request, fingerprint the + // identity. let spawn_config_hash = super::spawn_hash::spawn_config_hash( record, &personas, &teams, - &effective_relay_url, + &runtime_key.relay_url, &global, ); @@ -2134,7 +2148,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)?; + // Pass the requested URL, not the canonical key: the child dials this. + 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_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b1..e18699969a 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -283,7 +283,8 @@ 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 requested URL, not the canonical key: the child dials this. + 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(), @@ -403,7 +404,11 @@ async fn probe_agent_relay_access( let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), &requested_relay_url)?; let keys = nostr::Keys::parse(record.private_key_nsec.trim()) .map_err(|error| format!("invalid managed-agent key: {error}"))?; - let api_base = crate::relay::relay_http_base_url(&key.relay_url); + // Probe the relay as requested, not via the canonical key: the relay + // resolves a community from the literal Host and fails closed on an + // unmapped one, so probing a canonicalized loopback host 404s on a + // deployment configured with `localhost`. + let api_base = crate::relay::relay_http_base_url(&requested_relay_url); tokio::time::timeout( std::time::Duration::from_secs(10), crate::relay::query_relay_at_with_keys( @@ -504,7 +509,8 @@ pub async fn reconcile_managed_agent_runtimes( Ok((record, key, requested)) => { match start_pair( record.pubkey.clone(), - key.relay_url.clone(), + // Requested URL, not the canonical key: this is dialed. + requested.clone(), true, Some(&record.updated_at), app.clone(), diff --git a/desktop/src-tauri/src/managed_agents/runtime_types.rs b/desktop/src-tauri/src/managed_agents/runtime_types.rs index 4862cedbae..2b05ac8f2e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_types.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_types.rs @@ -31,6 +31,43 @@ impl ManagedAgentRuntimeKey { } } +#[cfg(test)] +mod tests { + use super::ManagedAgentRuntimeKey; + + #[test] + fn runtime_key_relay_url_is_canonical_identity_not_a_dial_address() { + // `relay_url` here is canonicalized (loopback folds to 127.0.0.1) so + // `runtime_id()` is stable for one pair no matter how the operator spelled + // the host. That is correct for keying, and it is exactly why this field + // must never be handed to the harness as the URL to connect to: the relay + // resolves a community from the literal request Host and fails closed on + // an unmapped one, so a deployment configured as `ws://localhost:PORT` + // rejects `ws://127.0.0.1:PORT` with a generic 404. `spawn_agent_child` + // dials the URL as requested; see the comment there. + let pubkey = "a".repeat(64); + for spelling in [ + "ws://localhost:3000", + "ws://127.0.0.1:3000", + "ws://[::1]:3000", + ] { + let key = ManagedAgentRuntimeKey::new(pubkey.clone(), spelling) + .expect("loopback relay URL should be accepted"); + assert_eq!( + key.relay_url, "ws://127.0.0.1:3000", + "spelling {spelling} should canonicalize" + ); + } + + // One identity across spellings, so the on-disk runtime path is stable. + let named = ManagedAgentRuntimeKey::new(pubkey.clone(), "ws://localhost:3000") + .expect("valid relay URL"); + let numeric = ManagedAgentRuntimeKey::new(pubkey.clone(), "ws://127.0.0.1:3000") + .expect("valid relay URL"); + assert_eq!(named.runtime_id(), numeric.runtime_id()); + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ManagedAgentRuntimeLifecycle {