From d578d95c46eea3af2c76f755324546b01ca3cbc1 Mon Sep 17 00:00:00 2001 From: D12Labs Date: Fri, 31 Jul 2026 07:45:46 -0500 Subject: [PATCH] fix(desktop): keep the agent relay authority unnormalized at spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Managed agents on a local relay authenticated into a different tenant than their owner, discovered zero channels, and idled while reporting healthy. spawn_agent_child derived the child's BUZZ_RELAY_URL from runtime_key.relay_url. ManagedAgentRuntimeKey::new canonicalizes that field with buzz_core::relay::normalize_relay_url, which folds every loopback host to 127.0.0.1 so a pair spelled "localhost" and one spelled "127.0.0.1" collapse to one runtime identity. That is right for identity and wrong for connecting: the relay resolves each connection's tenant from the request authority, and under row-zero host binding localhost:3000 and 127.0.0.1:3000 are distinct communities. So a desktop on ws://localhost:3000 published its channels, owner row, and events into the localhost:3000 community while every agent it spawned connected to ws://127.0.0.1:3000 and saw an empty one. The harness then logged "discovered 0 channel(s)" and "no channel subscriptions resolved — agent will sit idle", which reads as a misconfigured agent rather than a tenant split. relay::relay_http_base_url already documents and tests this invariant ("The desktop must not rewrite localhost to 127.0.0.1, or local dev HTTP calls target a different unmapped community than the WebSocket URL"), but the guard was defeated upstream — it received a value this code had already rewritten, so the git-credential HTTP base was mis-scoped too. Pass the caller's workspace relay URL through a named, authority-preserving helper instead. Identity stays canonical (dedup, runtime_id, log paths); only the connection keeps the caller's spelling. Remote hosts are unaffected, since normalization only rewrote loopback. Signed-off-by: D12Labs --- .../src-tauri/src/managed_agents/runtime.rs | 31 +++++++++- .../src/managed_agents/runtime/tests.rs | 56 +++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 37927961ed..ffc6183579 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -445,6 +445,17 @@ pub(crate) fn configure_runtime_cli( } } +/// The relay URL a spawned agent actually connects to. +/// +/// Authority-preserving by contract: only whitespace and a trailing root slash +/// are trimmed. The host is never rewritten, because the relay derives each +/// connection's tenant from the request authority. See the call site in +/// `spawn_agent_child` for why the canonical (normalized) pair URL must not be +/// used here. +fn agent_connect_relay_url(workspace_relay_url: &str) -> String { + workspace_relay_url.trim().trim_end_matches('/').to_string() +} + /// 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. @@ -545,9 +556,23 @@ 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. + // + // Deliberately NOT `runtime_key.relay_url`: that field is canonicalized by + // `normalize_relay_url`, which folds every loopback host to `127.0.0.1` so a + // pair spelled `localhost` and one spelled `127.0.0.1` collapse to a single + // runtime identity. Tenant host-binding resolves the community from the + // request authority, where `localhost:3000` and `127.0.0.1:3000` are + // distinct communities — so handing the canonical form to the child makes + // every managed agent on a local relay authenticate into a different tenant + // than its owner, discover zero channels, and sit idle looking healthy. + // `relay::relay_http_base_url` already guards this for HTTP; the guard is + // useless if the value reaching it was rewritten here first. + // + // Identity stays canonical (dedup, `runtime_id`, log paths); the connection + // keeps the caller's authority. + let effective_relay_url = agent_connect_relay_url(relay_url); // Augment PATH for DMG launches so child processes can find: // - bundled CLI via ~/.local/bin symlink diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 3f6ee996f6..163be124c5 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1314,3 +1314,59 @@ 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)); } + +// ── agent_connect_relay_url tests ─────────────────────────────────────── +// +// Tenant host-binding resolves a community from the request authority, so +// `localhost:3000` and `127.0.0.1:3000` are different communities. The URL +// handed to a spawned agent must therefore preserve the authority the +// workspace uses. `ManagedAgentRuntimeKey` canonicalizes loopback hosts to +// `127.0.0.1` for identity/dedup; feeding that canonical form to the child +// stranded every managed agent on a local relay in an empty tenant, where it +// discovered zero channels and idled while looking healthy. + +#[test] +fn connect_url_preserves_localhost_authority() { + // The regression: must NOT come back as ws://127.0.0.1:3000. + assert_eq!( + super::agent_connect_relay_url("ws://localhost:3000"), + "ws://localhost:3000" + ); +} + +#[test] +fn connect_url_preserves_ipv4_loopback_authority() { + assert_eq!( + super::agent_connect_relay_url("ws://127.0.0.1:3000"), + "ws://127.0.0.1:3000" + ); +} + +#[test] +fn connect_url_differs_from_canonical_key_for_localhost() { + // Pins the actual invariant: identity canonicalizes, connection does not. + let key = + crate::managed_agents::ManagedAgentRuntimeKey::new("a".repeat(64), "ws://localhost:3000") + .expect("valid key"); + assert_eq!(key.relay_url, "ws://127.0.0.1:3000"); + assert_ne!( + super::agent_connect_relay_url("ws://localhost:3000"), + key.relay_url + ); +} + +#[test] +fn connect_url_trims_whitespace_and_trailing_slash() { + assert_eq!( + super::agent_connect_relay_url(" ws://localhost:3000/ "), + "ws://localhost:3000" + ); +} + +#[test] +fn connect_url_leaves_remote_host_untouched() { + assert_eq!( + super::agent_connect_relay_url("wss://relay.example.com"), + "wss://relay.example.com" + ); +}