Skip to content
Draft
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
19 changes: 9 additions & 10 deletions crates/buzz-core/src/relay.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Canonical relay identities shared by runtime components.
//! Legacy canonical relay identities shared by compatibility consumers.

use thiserror::Error;
use url::{Host, Url};
Expand All @@ -23,17 +23,16 @@ pub enum NormalizeRelayUrlError {
MissingHost,
}

/// Canonicalize a WebSocket relay URL for use as a runtime identity key.
/// Canonicalize a WebSocket relay URL for legacy equivalence consumers.
///
/// This is the sole normalizer for `(agent, relay)` process identity. It keeps
/// the WebSocket scheme, lowercases DNS hosts, folds all loopback spellings to
/// `127.0.0.1`, removes default ports and a root slash, and preserves non-root
/// paths and queries. It deliberately is **not** the NIP-42 AUTH comparison
/// helper in `buzz-auth`: AUTH validation is a security boundary with narrower
/// equivalence rules and must not be widened by runtime-key canonicalization.
/// Bestie scope and pollen/profile migration retain this historical behavior:
/// keep the WebSocket scheme, lowercase DNS hosts, fold all loopback spellings
/// to `127.0.0.1`, remove default ports and trailing slashes, and preserve
/// queries. Managed-agent process identity intentionally uses a scoped
/// host-preserving normalizer because relay hosts are tenant authorities.
///
/// Connection code may retain the configured URL; this canonical form is for
/// identity, receipts, status and deduplication.
/// This deliberately is **not** the NIP-42 AUTH comparison helper in
/// `buzz-auth`; changing either equivalence contract requires a separate review.
pub fn normalize_relay_url(raw: &str) -> Result<String, NormalizeRelayUrlError> {
let mut url = Url::parse(raw.trim())
.map_err(|error| NormalizeRelayUrlError::InvalidUrl(error.to_string()))?;
Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,8 @@ async fn start_local_agent_with_preflight(
replay_floor_unix: Option<u64>,
) -> Result<ManagedAgentSummary, String> {
let launch_owner = workspace_owner_hex(state)?;
// Runtime keys fold loopback aliases for process bookkeeping, not tenant
// identity. Preserve the workspace authority across the preflight await.
// Runtime keys preserve the workspace host authority. Bind that same
// authority across the preflight await so the eventual spawn cannot move.
let launch_relay = crate::relay::bind_expected_relay_scope(
expected_relay_url,
relay_ws_url_with_override(state),
Expand Down
18 changes: 14 additions & 4 deletions desktop/src-tauri/src/managed_agents/remote_stop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,10 @@ mod tests {
let community = "ws://localhost:3037";
let agent = Keys::generate().public_key().to_hex();
let key = ManagedAgentRuntimeKey::new(&agent, community).unwrap();
assert_ne!(key.relay_url, community);
assert_eq!(key.relay_url, community);
let numeric_community = "ws://127.0.0.1:3037";
let numeric_key = ManagedAgentRuntimeKey::new(&agent, numeric_community).unwrap();
assert_ne!(key, numeric_key);
let scope = super::super::retention::RetentionScope {
db_path: scoped_retention_db_path(&root.join("agents"), community, &owner),
relay_url: community.into(),
Expand All @@ -293,9 +296,16 @@ mod tests {
)
.unwrap();
assert!(check_launch(app.handle(), &key, community, Some(&owner), None).is_err());
// The numeric community is a different authority, even though the
// process bookkeeping key historically folds the two spellings.
assert!(check_launch(app.handle(), &key, &key.relay_url, Some(&owner), None).is_ok());
// The numeric community is a different authority with its own runtime
// identity and fence database.
assert!(check_launch(
app.handle(),
&numeric_key,
numeric_community,
Some(&owner),
None
)
.is_ok());
let resume = capture_resume(app.handle(), &key, community, &owner).unwrap();
assert!(check_launch(app.handle(), &key, community, Some(&owner), Some(&resume)).is_ok());
let newer = request(&keys, &target, 101);
Expand Down
12 changes: 6 additions & 6 deletions desktop/src-tauri/src/managed_agents/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,12 +397,12 @@ pub async fn restore_managed_agents_on_launch(
continue;
};
let now = util::now_iso();
let receipt = super::ManagedAgentRuntimeReceipt {
key: key.clone(),
pid: process.child.id(),
desktop_instance_id: super::current_instance_id(app),
started_at: now.clone(),
};
let receipt = super::ManagedAgentRuntimeReceipt::new(
key.clone(),
process.child.id(),
super::current_instance_id(app),
now.clone(),
);
if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) {
let _ = super::terminate_process(process.child.id());
let _ = process.child.wait();
Expand Down
38 changes: 22 additions & 16 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ mod process;
#[cfg(test)]
use process::{
buzz_marker_entry, name_matches_interpreter, name_matches_known_binary,
terminate_runtime_receipt_with, valid_agent_runtime_receipt_with,
select_pair_runtime_receipt_with, terminate_runtime_receipt_with,
valid_agent_runtime_receipt_with,
};
pub(crate) use process::{
current_instance_id, process_belongs_to_us, process_has_buzz_marker, process_is_running,
terminate_process, terminate_untracked_pair_runtime, valid_agent_runtime_receipt,
with_pair_runtime_receipt_authority,
};

mod orphan_sweep;
Expand Down Expand Up @@ -108,8 +110,8 @@ fn persona_drift_state(
/// pin is ignored — see `effective_agent_relay_url`). Returns `None` for
/// records that cannot form a valid pair key yet (e.g. key-less agents that
/// mint keys on first start).
pub(crate) fn workspace_pair_key(
app: &AppHandle,
pub(crate) fn workspace_pair_key<R: tauri::Runtime>(
app: &AppHandle<R>,
record: &ManagedAgentRecord,
) -> Option<ManagedAgentRuntimeKey> {
let state = app.state::<crate::app_state::AppState>();
Expand Down Expand Up @@ -444,8 +446,8 @@ pub(crate) fn spawn_with_effort_proof(
/// publishes the triggering message before this spawn and passes its send
/// timestamp here so the harness's first REQ replays past that message no
/// matter how long the spawn takes. buzz-acp clamps stale floors to ~15 min.
pub fn spawn_agent_child(
app: &AppHandle,
pub fn spawn_agent_child<R: tauri::Runtime>(
app: &AppHandle<R>,
record: &ManagedAgentRecord,
relay_url: &str,
lazy: bool,
Expand All @@ -466,8 +468,8 @@ pub fn spawn_agent_child(
}

#[allow(clippy::too_many_arguments)]
pub(crate) fn spawn_agent_child_with_broker(
app: &AppHandle,
pub(crate) fn spawn_agent_child_with_broker<R: tauri::Runtime>(
app: &AppHandle<R>,
record: &ManagedAgentRecord,
relay_url: &str,
lazy: bool,
Expand Down Expand Up @@ -917,8 +919,8 @@ pub(crate) fn spawn_agent_child_with_broker(
/// exact workspace-relay read the caller's scope assertion passed on; it never
/// re-reads the mutable override (see `relay::scope`). The key comes from
/// [`bound_runtime_key`] — the seam the spawn-key regressions exercise.
pub fn start_managed_agent_process(
app: &AppHandle,
pub fn start_managed_agent_process<R: tauri::Runtime>(
app: &AppHandle<R>,
record: &mut ManagedAgentRecord,
runtimes: &mut HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>,
owner_hex: Option<&str>,
Expand All @@ -944,6 +946,10 @@ pub fn start_managed_agent_process(
// Scalar PIDs are migration-only and never establish pair liveness.
record.runtime_pid = None;

// A prior-session receipt is the only untracked process this pair may
// replace. Selection enforces host-preserving authority provenance and
// uses the ordinary process-tree termination contract.
terminate_untracked_pair_runtime(app, &key)?;
let mut process = spawn_agent_child(
app,
record,
Expand All @@ -954,12 +960,12 @@ pub fn start_managed_agent_process(
resume,
)?;
let now = now_iso();
let receipt = super::ManagedAgentRuntimeReceipt {
key: key.clone(),
pid: process.child.id(),
desktop_instance_id: current_instance_id(app),
started_at: now.clone(),
};
let receipt = super::ManagedAgentRuntimeReceipt::new(
key.clone(),
process.child.id(),
current_instance_id(app),
now.clone(),
);
if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) {
let _ = terminate_process(process.child.id());
let _ = process.child.wait();
Expand All @@ -979,7 +985,7 @@ pub fn start_managed_agent_process(
}

#[cfg(test)]
mod test_fixtures;
pub(super) mod test_fixtures;

#[cfg(test)]
mod tests;
Loading
Loading