Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion desktop/src-tauri/src/managed_agents/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
28 changes: 21 additions & 7 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
///
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
54 changes: 54 additions & 0 deletions desktop/src-tauri/src/managed_agents/runtime/spawn_relay.rs
Original file line number Diff line number Diff line change
@@ -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");
}
}
4 changes: 3 additions & 1 deletion desktop/src-tauri/src/managed_agents/runtime_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down