diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 1910620159..f485d4a7ca 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -330,10 +330,14 @@ 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 spelling, not + // `key.relay_url` — Host-derived + // tenancy (see + // `child_connect_relay_url`). 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 f3b4cb67fd..b1d2d29757 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -445,10 +445,36 @@ pub(crate) fn configure_runtime_cli( } } +/// Relay URL for the spawned harness's `BUZZ_RELAY_URL` and git-credential +/// scope: always the caller's *configured* URL, never the canonical +/// runtime-key form. Relay tenancy is derived from the HTTP Host header, so +/// the canonical loopback rewrite (`ws://localhost:3000` → +/// `ws://127.0.0.1:3000`) names a different — empty — community than the one +/// the desktop wrote the agent's channel memberships into; a harness handed +/// the canonical form discovers 0 channels and never sees a mention. The +/// canonical form stays correct for identity only: runtime-map keys, +/// receipts, log paths, and the spawn-config hash (which `status_for` +/// recomputes from `key.relay_url`). +pub(crate) fn child_connect_relay_url<'a>( + configured: &'a str, + key: &ManagedAgentRuntimeKey, +) -> &'a str { + debug_assert!( + buzz_core_pkg::relay::normalize_relay_url(configured) + .is_ok_and(|canonical| canonical == key.relay_url), + "configured relay URL must canonicalize to the runtime key's relay" + ); + configured +} + /// Spawn an agent process without holding any locks on records or runtimes. /// 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 *configured* pair relay (workspace override / start +/// request), not a canonical runtime-key round-trip — see +/// `child_connect_relay_url`. +/// /// `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( @@ -545,9 +571,11 @@ 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 — in the + // configured spelling, because the Host header decides which community the + // child lands in (see `child_connect_relay_url`). + let child_relay_url = child_connect_relay_url(relay_url, &runtime_key); // Augment PATH for DMG launches so child processes can find: // - bundled CLI via ~/.local/bin symlink @@ -578,7 +606,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", &effective_relay_url); + command.env("BUZZ_RELAY_URL", child_relay_url); command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); @@ -826,7 +854,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(&effective_relay_url); + let relay_http_url = crate::relay::relay_http_base_url(child_relay_url); command.env("NOSTR_PRIVATE_KEY", &record.private_key_nsec); command.env("GIT_TERMINAL_PROMPT", "0"); @@ -914,13 +942,14 @@ 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* relay form: `status_for` recomputes this hash from + // `key.relay_url`, so hashing the configured spelling here would read as + // permanent drift and flag a spurious restart on every status pass. let spawn_config_hash = super::spawn_hash::spawn_config_hash( record, &personas, &teams, - &effective_relay_url, + &runtime_key.relay_url, &global, ); @@ -1003,7 +1032,9 @@ 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 spelling, not `key.relay_url` — Host-derived tenancy (see + // `child_connect_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/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 3f6ee996f6..8d319e3804 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1314,3 +1314,31 @@ fn restart_eligible_false_when_orphan_has_no_drift() { fn restart_eligible_false_when_non_orphan_has_no_drift() { assert!(!super::restart_eligible(false, false, false)); } + +// ── child_connect_relay_url tests ─────────────────────────────────────── + +#[test] +fn child_connect_relay_url_preserves_configured_loopback_authority() { + // Regression: relay tenancy is Host-header-derived, so handing the child + // the canonical runtime-key form (localhost → 127.0.0.1) parks every + // spawned agent in a different, empty community than the desktop's + // ws://localhost:3000 workspace — the harness logs "discovered 0 + // channel(s)" and never sees a mention. + let key = super::ManagedAgentRuntimeKey::new("a".repeat(64), "ws://localhost:3000") + .expect("valid runtime key"); + assert_eq!(key.relay_url, "ws://127.0.0.1:3000"); + assert_eq!( + super::child_connect_relay_url("ws://localhost:3000", &key), + "ws://localhost:3000", + ); +} + +#[test] +fn child_connect_relay_url_is_identity_for_non_loopback_relays() { + let key = super::ManagedAgentRuntimeKey::new("a".repeat(64), "wss://relay.example.com") + .expect("valid runtime key"); + assert_eq!( + super::child_connect_relay_url("wss://relay.example.com", &key), + "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..b91d012031 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -272,7 +272,13 @@ fn start_pair( .get_mut(&key) .is_some_and(|runtime| runtime.child.try_wait().ok().flatten().is_none()) { - let status = status_for(&app, record, &key, runtimes.get(&key), None); + let status = status_for( + &app, + record, + &key, + runtimes.get(&key), + Some(relay_url.clone()), + ); return Ok(status); } runtimes.remove(&key); @@ -283,7 +289,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())?; + // Configured spelling, not `key.relay_url` — Host-derived tenancy (see + // `child_connect_relay_url`). + 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(), @@ -302,7 +310,13 @@ fn start_pair( record.last_stopped_at = None; record.last_error = None; runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process)); - let status = status_for(&app, record, &key, runtimes.get(&key), None); + let status = status_for( + &app, + record, + &key, + runtimes.get(&key), + Some(relay_url.clone()), + ); drop(runtimes); save_managed_agents(&app, &records)?; emit_status(&app, &status); @@ -403,7 +417,10 @@ 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 over the requested authority, not the canonical key form — the + // Host header picks the community, and the probe must hit the same one + // the child will join (see `child_connect_relay_url`). + 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( @@ -502,9 +519,11 @@ pub async fn reconcile_managed_agent_runtimes( for probe in probes { match probe { Ok((record, key, requested)) => { + // Configured spelling, not `key.relay_url` — Host-derived + // tenancy (see `child_connect_relay_url`). match start_pair( record.pubkey.clone(), - key.relay_url.clone(), + requested.clone(), true, Some(&record.updated_at), app.clone(),