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
6 changes: 5 additions & 1 deletion desktop/src-tauri/src/managed_agents/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
49 changes: 40 additions & 9 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(","));
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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,
);

Expand Down Expand Up @@ -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(),
Expand Down
28 changes: 28 additions & 0 deletions desktop/src-tauri/src/managed_agents/runtime/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
}
29 changes: 24 additions & 5 deletions desktop/src-tauri/src/managed_agents/runtime_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(),
Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(),
Expand Down